From dd8a8c133d12330079dbeeb53450408b41f354a5 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Fri, 14 Aug 2026 11:51:30 +0200 Subject: [PATCH] Offline kommentar-/bildeposting i rundefeeden (ADR-069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Utvider den eksisterende offline-skrivekøen (ADR-028, hittil kun hull-scoreføring) til også å dekke rundefeedens kommentarer/bilder. offline-queue.ts fikk et isMultipart-flagg (Blob-verdier lagres nativt i IndexedDB, flushQueue bygger FormData ved synk). round-messages.tsx fikk sin egen isolerte kø (matchId scoped til "{roundId}:messages", ikke bare roundId) og et optimistisk "Venter på synk"-kort. Migrasjon 073: round_message.client_message_id (nullbar uuid) + delvis unik indeks -- POST /messages er ulikt hull-score-PATCH IKKE naturlig idempotent, så en avbrutt synk kunne duplisert en kommentar uten dette. Klienten sender en selvgenerert id ved både første forsøk og et evt. køet gjenforsøk; serveren deduplikerer på den. Se ARCHITECTURE_DECISIONS.md (ADR-069) og CHANGELOG.md (punkt 85) for full begrunnelse og verifiseringslogg (inkl. et reload-mens-offline- scenario som beviser fravær av duplikater). Co-Authored-By: Claude Sonnet 5 --- 073_round_message_client_id.sql | 25 +++ ARCHITECTURE_DECISIONS.md | 92 +++++++++ CHANGELOG.md | 45 +++++ app/routers/round_messages.py | 26 ++- frontend/components/round-messages.tsx | 263 ++++++++++++++++++++----- frontend/lib/offline-queue.ts | 47 +++-- tests/test_message_tags.py | 61 +++++- 7 files changed, 494 insertions(+), 65 deletions(-) create mode 100644 073_round_message_client_id.sql diff --git a/073_round_message_client_id.sql b/073_round_message_client_id.sql new file mode 100644 index 0000000..a6d334c --- /dev/null +++ b/073_round_message_client_id.sql @@ -0,0 +1,25 @@ +-- ===================================================================== +-- TeeCup — Idempotens for kommentar-posting via offline-kø (ADR-028- +-- tillegget "kommentarer/bilder offline", 2026-08-14, migrasjon 073). +-- +-- Hull-score-køen (ADR-028) er trygg å synke på nytt fordi PATCH + +-- expected_version konvergerer uansett hvor mange ganger den gjentas. +-- POST /rounds/{id}/messages er IKKE naturlig idempotent -- hvert kall +-- oppretter en ny rad. En avbrutt synk (nettleseren lukkes midt i et +-- flush-kall, eller samme kø flushes fra to faner) kunne dermed skrevet +-- samme kommentar to ganger. Løsning: klienten sender en selvgenerert +-- `client_message_id` (crypto.randomUUID()); et gjentatt kall med samme +-- id returnerer den allerede opprettede meldingen i stedet for å +-- opprette en duplikat (se app/routers/round_messages.py:: +-- post_round_message). +-- ===================================================================== +\set ON_ERROR_STOP on + +ALTER TABLE round_message ADD COLUMN client_message_id uuid; + +-- Delvis unik indeks (kun når feltet faktisk er satt) -- eldre/andre +-- kallere som ikke sender client_message_id er upåvirket, samme +-- "None = ikke i bruk"-konvensjon som resten av appen. +CREATE UNIQUE INDEX round_message_client_id_idx + ON round_message (round_id, client_message_id) + WHERE client_message_id IS NOT NULL; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 0c63476..29d416e 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -6818,6 +6818,98 @@ alle bygget OG rullet ut. --- +## ADR-069: Offline-utvidelse -- kommentar-/bildeposting i rundefeeden (ADR-028-tillegg) — 2026-08-14 + +Foranledning: bruker spurte om installasjonsbanneret sin påstand +"virker delvis uten nett" faktisk stemte. Undersøkelse bekreftet at den +GJORDE det (ekte IndexedDB-skrivekø for hull-scoreføring, service +worker-cache for allerede besøkte sider) -- men bruker ønsket å gjøre +"delvis" til mer fullstendig. Presentert tre konkrete utvidelsesretninger +(AskUserQuestion); bruker valgte "Utvid offline-skriving til mer enn +scoreføring (kommentarer/bilder)" -- IKKE de to andre alternativene +(proaktiv full-runde-precaching ved åpning, eller et generelt app-skall +for en aldri-besøkt kaldstart). + +**Datamodell (migrasjon 073) -- idempotens, IKKE en ny funksjon i seg +selv.** Hull-score-køen (ADR-028) er trygg å gjenta fordi PATCH + +`expected_version` konvergerer uansett hvor mange ganger den sendes. +`POST /rounds/{id}/messages` er IKKE naturlig idempotent -- hvert kall +oppretter en ny rad. En avbrutt synk (nettleseren lukkes midt i et +flush-kall, eller samme kø flushes fra to faner) kunne dermed skrevet +samme kommentar to ganger. Løsning: `round_message` fikk en nullbar +`client_message_id uuid`-kolonne + en delvis unik indeks +`(round_id, client_message_id) WHERE client_message_id IS NOT NULL`. +Klienten genererer denne (`crypto.randomUUID()`) FØR første forsøk +(både det direkte online-forsøket og et evt. køet gjenforsøk sender +samme id) -- et gjentatt kall med samme id returnerer den allerede +opprettede meldingen (`post_round_message`, round_messages.py) i stedet +for å opprette en duplikat. 3 nye backend-tester (repetert id → samme +melding, ulike id-er → separate meldinger, manglende id → uendret +oppførsel for bakoverkompatible kallere) -- full backend-suite 58/58. + +**offline-queue.ts utvidet til multipart, ikke bare JSON.** Køen +(opprinnelig kun `Content-Type: application/json`) fikk et nytt +`isMultipart`-flagg på `QueueEntry`. Når satt, er `body` et FLATT +objekt av string/Blob-felt i stedet for en JSON-serialiserbar verdi -- +IndexedDB sin structured-clone-algoritme lagrer `Blob`/`File`-verdier +NATIVT, ingen egen blob-lagringsstruktur trengs. `flushQueue` bygger da +`FormData` i stedet for å JSON-serialisere. Uendret oppførsel for +eksisterende (hull-score-)oppføringer uten flagget. + +**Frontend (round-messages.tsx) -- SAMME mønster som round-detail.tsx +sin hull-score-kø, men et EGET, isolert kø-navnerom.** `matchId: +\`${roundId}:messages\`` (ikke bare `roundId`) -- forhindrer at denne +komponentens kø noensinne blandes med round-detail.tsx sin egen +hull-score-kø i samme IndexedDB-database, selv om begge er montert på +samme side samtidig. Egne `isOnline`/`pendingCount`/`syncingMessages`- +tilstander, egne online/offline-lyttere, egen mount-tids-sjekk for +gjenværende kø fra en tidligere økt -- fullstendig frikoblet fra +round-detail.tsx sin tilsvarende logikk. Et lokalt "optimistisk kort" +(egen `PendingRoundMessage`-type, ALDRI blandet inn i den ekte +`messages`-listen) vises øverst med "Venter på synk" + klokke-ikon +inntil en vellykket synk trigger en full `load()` som erstatter det med +serverens fasit (fanger bl.a. opp tagger forkastet server-side). +Bilde-forhåndsvisningen bruker sin EGEN, dedikerte `URL.createObjectURL` +(ikke composerens `imagePreview`, som revokes med det samme av +`clearSelectedImage()`). + +**Bevisst UTENFOR omfang:** kommentar-på-kommentar (`round_message_ +comment`, `PostEngagement`-tråden) fikk IKKE samme idempotens/kø-støtte +-- kun rundefeedens hovedinnlegg (tekst+bilde), som var det brukeren +faktisk pekte på. Å gjenopprette et optimistisk kort etter en FULL +nettleser-lukking+gjenåpning mens offline er bevisst IKKE forsøkt +(bilde-forhåndsvisningens object-URL overlever uansett ikke en reload) +-- en køet skriving flushes likevel korrekt automatisk når nettet er +tilbake, den vises bare ikke optimistisk i mellomtiden. Denne +avgrensningen er testet eksplisitt (se under) og fungerer som forventet. + +**Verifisert:** `python3 -m py_compile` + full +`./scripts/run_backend_tests.sh` (58/58, migrasjon 073 inkludert). +`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch +`teecup_api`-container (port 18002, live-mountet kode) + lokal `next +dev` (port 13002), ekte nettverk-frakoblet-emulering (Chrome DevTools +MCP sin `networkConditions: "Offline"`, IKKE bare CDP-nivå -- bekreftet +at appens egen `navigator.onLine`-avledede tilstand faktisk reagerte): +(1) tekst-only kommentar postet offline → optimistisk kort med +"Venter på synk" → satt online igjen → automatisk synk, banner +forsvant, EKTE melding med reaksjoner/slett-knapp dukket opp, bekreftet +NØYAKTIG 1 rad i databasen (ingen duplikat); (2) samme med bilde +(ekte opplastet PNG via Chrome DevTools sin `upload_file`) -- optimistisk +bilde-forhåndsvisning vist offline, ekte AVIF-konvertert MinIO-bilde +etter synk; (3) reload-mens-offline-scenario -- postet en tredje +kommentar offline, lastet siden PÅ NYTT mens fortsatt offline (simulerer +at appen lukkes/gjenåpnes), satt online igjen -- den køede skrivingen +fra FØR reload ble automatisk funnet og synket ved mount, bekreftet +NØYAKTIG 3 rader totalt (ingen duplikat på tvers av reload). Lys+mørk +bekreftet. Scratch-stacken (database, rolle, container, MinIO- +scratch-bucket) fullstendig revet ned etterpå -- ekte `teecup_db`/ +`teecup_api`/`teecup_frontend` urørt gjennom hele verifiseringen. + +**IKKE rullet ut ennå** -- migrasjon 073 venter på eksplisitt +bekreftelse fra bruker. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7878105..29722e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11235,3 +11235,48 @@ Neste steg: containere startet rent (ruller også ut de tre UI-rettelsene). Dette var siste del av den tredelte Flaggturnering-utvidelsen -- Del A/B/C alle bygget OG rullet ut. + +85. **Offline kommentar-/bildeposting i rundefeeden (ADR-069) — + 2026-08-14.** Bruker spurte om installasjonsbannerets "virker + delvis uten nett"-påstand faktisk stemte -- undersøkelse bekreftet + at den gjorde det, men bruker ønsket å tette det største gjenværende + gapet: kun hull-scoreføring hadde offline-støtte, kommentarer/bilder + krevde fortsatt nett. Valgte dette fremfor to andre foreslåtte + retninger (proaktiv full-runde-precaching, generelt app-skall for + aldri-besøkt kaldstart). + + Migrasjon 073: `round_message.client_message_id` (nullbar uuid) + + delvis unik indeks -- IKKE en ny funksjon i seg selv, men + idempotens. Ulikt hull-score-køens PATCH+expected_version (naturlig + trygg å gjenta), er `POST /messages` IKKE idempotent -- en avbrutt + synk kunne skrevet samme kommentar to ganger uten dette. Klienten + sender en `crypto.randomUUID()`-generert id ved både første forsøk + OG et evt. køet gjenforsøk; et gjentatt kall med samme id returnerer + den allerede opprettede meldingen fremfor å duplisere. 3 nye + backend-tester -- full suite 58/58. + + `offline-queue.ts` utvidet med et `isMultipart`-flagg -- `body` blir + da et flatt string/Blob-objekt (IndexedDB lagrer Blob/File nativt) + i stedet for JSON, og `flushQueue` bygger `FormData` ved synk. + `round-messages.tsx` fikk sin EGEN, isolerte kø (`matchId: + \`${roundId}:messages\``, ikke bare `roundId`) -- blandes aldri med + round-detail.tsx sin hull-score-kø selv om begge er montert + samtidig. Optimistisk "Venter på synk"-kort med egen bilde-object- + URL (ikke composerens, som revokes med det samme). + + **Verifisert:** full `./scripts/run_backend_tests.sh` (58/58, + migrasjon 073 inkludert). `tsc --noEmit` rent + 45/45 vitest. Egen + scratch-database + scratch `teecup_api`-container (port 18002, + live-mountet kode) + lokal `next dev` (port 13002), ekte + nettverk-frakoblet-emulering: tekst-only kommentar offline → synk → + nøyaktig 1 rad i databasen (ingen duplikat); samme med ekte + opplastet bilde (AVIF-konvertert av MinIO etter synk); reload-mens- + offline-scenario (postet offline, lastet siden på nytt MENS + fortsatt offline, satt online) -- den køede skrivingen ble funnet + og synket automatisk ved mount, nøyaktig 3 rader totalt (ingen + duplikat på tvers av en reload). Lys+mørk bekreftet. Scratch-stacken + fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/ + `teecup_frontend` urørt. + + **IKKE rullet ut ennå** -- migrasjon 073 venter på eksplisitt + bekreftelse fra bruker. diff --git a/app/routers/round_messages.py b/app/routers/round_messages.py index d0312c1..ece5858 100644 --- a/app/routers/round_messages.py +++ b/app/routers/round_messages.py @@ -318,6 +318,12 @@ async def post_round_message( # siden dette endepunktet er multipart (bilde-opplasting), ikke en ren # JSON-body som CommentIn under kan bruke direkte. tags: str | None = Form(default=None), + # Idempotens for offline-kø-synk (ADR-028-tillegget, migrasjon 073, + # 2026-08-14) -- klient-generert (crypto.randomUUID()), valgfri for + # bakoverkompatible kallere. Et gjentatt kall med SAMME id (f.eks. en + # avbrutt synk som prøves på nytt) returnerer den allerede opprettede + # meldingen fremfor å opprette en duplikat -- se migrasjonens header. + client_message_id: str | None = Form(default=None), user: CurrentUser = Depends(get_current_user), ) -> RoundMessageOut: if not body and image is None: @@ -333,6 +339,20 @@ async def post_round_message( # ingen snevrere gate i tillegg (ADR-044 Beslutning A). await _get_viewable_round_or_404(conn, round_id, user.user_id) + if client_message_id is not None: + existing = await conn.fetchrow( + f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 AND client_message_id = $2", + round_id, client_message_id, + ) + if existing is not None: + reactions = await _round_message_reactions(conn, [existing["id"]], user.user_id) + comment_counts = await _round_message_comment_counts(conn, [existing["id"]]) + tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", [existing["id"]]) + return _row_to_round_message( + existing, reactions.get(existing["id"]), comment_counts.get(existing["id"], 0), + tags_by_id.get(existing["id"]), + ) + image_key = None if raw is not None: try: @@ -345,8 +365,9 @@ async def post_round_message( row = await conn.fetchrow( f""" - INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO round_message + (round_id, author_user_id, author_display_name, body, image_key, client_message_id) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING {_ROUND_MESSAGE_COLUMNS} """, round_id, @@ -354,6 +375,7 @@ async def post_round_message( display_name, body, image_key, + client_message_id, ) await _insert_tags_and_notify( conn, table="round_message_tag", fk_column="round_message_id", message_id=row["id"], diff --git a/frontend/components/round-messages.tsx b/frontend/components/round-messages.tsx index 24e13ed..e4c7acc 100644 --- a/frontend/components/round-messages.tsx +++ b/frontend/components/round-messages.tsx @@ -1,10 +1,12 @@ "use client" import { useCallback, useEffect, useRef, useState } from "react" -import { Camera, ImagePlus, Send, Trash2, X } from "lucide-react" +import { Camera, Clock, ImagePlus, RefreshCw, Send, Trash2, WifiOff, X } from "lucide-react" import { MentionTextarea, TaggedText } from "./mention-input" import type { Tag } from "@/lib/mentions" import { PostEngagement, type ReactionSummary } from "./post-engagement" +import { cn } from "@/lib/utils" +import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" /** Rå meldingsform fra backend. */ export type ApiRoundMessage = { @@ -20,6 +22,13 @@ export type ApiRoundMessage = { tags: Tag[] } +// Et lokalt, ikke-synkronisert innlegg (offline-kø-utvidelse, 2026-08-14, +// se ADR-028-tillegget). `id` er en midlertidig klient-id (aldri sendt til +// serveren) -- brukes kun som React-key inntil `load()` erstatter hele +// listen med serverens fasit etter vellykket synk. `image_url` er da en +// lokal object-URL (blob-forhåndsvisning), ikke en ekte MinIO-URL. +type PendingRoundMessage = ApiRoundMessage & { _clientMessageId: string } + export type RoundMessagesProps = { roundId: string currentUserId: string @@ -108,34 +117,112 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres const [postError, setPostError] = useState(null) const [deletingId, setDeletingId] = useState(null) + // Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) -- + // egen kø-navnerom (`${roundId}:messages`, ikke bare roundId) slik at + // denne komponentens kø ALDRI blandes med round-detail.tsx sin + // hull-score-kø, selv om begge deler samme IndexedDB-database. De to + // køene flushes og telles helt uavhengig av hverandre. + const messagesMatchId = `${roundId}:messages` + const [isOnline, setIsOnline] = useState(true) + const [pendingCount, setPendingCount] = useState(0) + const [syncingMessages, setSyncingMessages] = useState(false) + // Ikke-synkroniserte innlegg, vist FØRST i listen inntil `load()` (kalt + // etter en vellykket synk) erstatter dem med serverens fasit. Kan IKKE + // legges inn i `messages` direkte -- de har ingen ekte server-id ennå. + const [pendingMessages, setPendingMessages] = useState([]) + const fileInputRef = useRef(null) const cameraInputRef = useRef(null) + const load = useCallback(async () => { + setLoading(true) + setLoadError(null) + try { + const res = await fetch(`/rounds/${roundId}/messages`, { credentials: "include" }) + if (!res.ok) throw new Error(`Status ${res.status}`) + const data: ApiRoundMessage[] = await res.json() + setMessages(data) + } catch { + setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.") + } finally { + setLoading(false) + } + }, [roundId]) + // Hent meldinger ved mount, og på nytt hver gang refreshKey endres // (WS-signal fra round-detail.tsx). useEffect(() => { - const controller = new AbortController() - async function load() { - setLoading(true) - setLoadError(null) - try { - const res = await fetch(`/rounds/${roundId}/messages`, { - credentials: "include", - signal: controller.signal, + void load() + }, [load, refreshKey]) + + const flushPendingMessages = useCallback(async () => { + if (syncingMessages) return + setSyncingMessages(true) + try { + const outcomes = await flushQueue(messagesMatchId) + if (outcomes.length === 0) return + for (const o of outcomes) { + const sentBody = o.entry.body as Record + const clientMessageId = sentBody.client_message_id as string | undefined + setPendingMessages((prev) => { + const match = prev.find((m) => m._clientMessageId === clientMessageId) + if (match?.image_url) URL.revokeObjectURL(match.image_url) + return prev.filter((m) => m._clientMessageId !== clientMessageId) }) - if (!res.ok) throw new Error(`Status ${res.status}`) - const data: ApiRoundMessage[] = await res.json() - setMessages(data) - } catch (err) { - if ((err as Error).name === "AbortError") return - setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.") - } finally { - setLoading(false) } + const failed = outcomes.filter((o) => !o.ok) + if (failed.length > 0) { + setPostError( + `${failed.length} ${failed.length === 1 ? "kommentar" : "kommentarer"} kunne ikke synkroniseres: ${failed[0].message}`, + ) + } + setPendingCount(await queueCount(messagesMatchId)) + // Serverens fasit -- fanger opp bl.a. tagger som ble forkastet + // server-side (f.eks. mot noen som ikke lenger er medspiller/venn). + await load() + } finally { + setSyncingMessages(false) } - load() - return () => controller.abort() - }, [roundId, refreshKey]) + }, [messagesMatchId, syncingMessages, load]) + + useEffect(() => { + setIsOnline(navigator.onLine) + function handleOnline() { + setIsOnline(true) + void flushPendingMessages() + } + function handleOffline() { + setIsOnline(false) + } + window.addEventListener("online", handleOnline) + window.addEventListener("offline", handleOffline) + return () => { + window.removeEventListener("online", handleOnline) + window.removeEventListener("offline", handleOffline) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roundId]) + + // Køede skrivinger fra en tidligere økt (f.eks. nettleseren ble lukket + // mens offline) -- samme engangs-sjekk-ved-mount-mønster som + // round-detail.tsx. MERK: en optimistisk visning av DISSE gjenopprettes + // bevisst IKKE (bilde-forhåndsvisningens object-URL overlever uansett + // ikke en reload) -- de dukker opp i selve listen først når `load()` + // kjører etter vellykket synk, en akseptert forenkling. + useEffect(() => { + let cancelled = false + queueCount(messagesMatchId) + .then((count) => { + if (cancelled) return + setPendingCount(count) + if (count > 0 && navigator.onLine) void flushPendingMessages() + }) + .catch(() => {}) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roundId]) // Rydd opp objekt-URL-en for bildeforhåndsvisning. useEffect(() => { @@ -173,24 +260,72 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres const trimmedBody = body.trim() const canPost = (trimmedBody.length > 0 || imageFile != null) && !posting + // Lagrer skrivingen i offline-køen og speiler den optimistisk øverst i + // listen (ADR-028-tillegget, 2026-08-14). `client_message_id` sendes med + // BÅDE her og i det direkte (online) forsøket under -- serveren + // dedupliserer på den ved en evt. gjentatt synk (migrasjon 073). + async function queueMessageOffline(fields: Record, clientMessageId: string) { + await enqueueWrite({ + url: `/rounds/${roundId}/messages`, + method: "POST", + body: fields, + matchId: messagesMatchId, + isMultipart: true, + }) + // Egen, LOKAL object-URL for den optimistiske visningen -- må IKKE + // gjenbruke composerens `imagePreview` (den revokes av + // clearSelectedImage() rett under, som ville ugyldiggjort et delt bilde). + const pendingImageUrl = imageFile ? URL.createObjectURL(imageFile) : null + setPendingMessages((prev) => [ + { + id: `pending-${clientMessageId}`, + round_id: roundId, + author_user_id: currentUserId, + author_display_name: "Du", + body: trimmedBody || null, + image_url: pendingImageUrl, + created_at: new Date().toISOString(), + reactions: [], + comment_count: 0, + tags, + _clientMessageId: clientMessageId, + }, + ...prev, + ]) + setPendingCount((c) => c + 1) + setBody("") + setTags([]) + clearSelectedImage() + } + async function handlePost(e: React.FormEvent) { e.preventDefault() if (!canPost) return setPosting(true) setPostError(null) + const clientMessageId = crypto.randomUUID() + const fields: Record = { client_message_id: clientMessageId } + if (trimmedBody.length > 0) fields.body = trimmedBody + if (imageFile) fields.image = imageFile + if (tags.length > 0) fields.tags = JSON.stringify(tags) + + if (!navigator.onLine) { + await queueMessageOffline(fields, clientMessageId) + setPosting(false) + return + } + try { const formData = new FormData() - if (trimmedBody.length > 0) formData.append("body", trimmedBody) - if (imageFile) formData.append("image", imageFile) - if (tags.length > 0) formData.append("tags", JSON.stringify(tags)) + for (const [k, v] of Object.entries(fields)) formData.append(k, v) const res = await fetch(`/rounds/${roundId}/messages`, { method: "POST", credentials: "include", body: formData, }) - if (res.status !== 201) { + if (!res.ok) { setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen.")) return } @@ -203,7 +338,9 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres setTags([]) clearSelectedImage() } catch { - setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.") + // Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet + // for å tape kommentaren, samme prinsipp som hull-scoreføring (ADR-028). + await queueMessageOffline(fields, clientMessageId) } finally { setPosting(false) } @@ -344,7 +481,35 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres )} - {/* Meldingsliste */} + {/* Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) -- + samme banner-mønster som round-detail.tsx sin hull-scoreføring. */} + {!isOnline && ( +
+
+ )} + {pendingCount > 0 && ( +
+ + {pendingCount} {pendingCount === 1 ? "kommentar venter" : "kommentarer venter"} på synkronisering. + + {isOnline && ( + + )} +
+ )} + + {/* Meldingsliste -- ikke-synkroniserte innlegg ligger ALLTID først, + uavhengig av created_at (de er per definisjon de ferskeste). */} {loading ? (
{loadError}

- ) : messages.length === 0 ? ( + ) : pendingMessages.length === 0 && messages.length === 0 ? (

Ingen kommentarer ennå — vær den første til å dele et bilde eller en kommentar fra runden.

) : (
    - {messages.map((m) => { - const canDelete = m.author_user_id === currentUserId || roundOwnerUserId === currentUserId + {[...pendingMessages, ...messages].map((m) => { + const isPending = "_clientMessageId" in m + const canDelete = !isPending && (m.author_user_id === currentUserId || roundOwnerUserId === currentUserId) const isDeleting = deletingId === m.id const { label, title } = formatRelativeTime(m.created_at) return ( @@ -374,13 +540,20 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres {m.author_display_name} - + {isPending ? ( + + + ) : ( + + )}
{canDelete && ( @@ -419,14 +592,16 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres

)} - + {!isPending && ( + + )} ) })} diff --git a/frontend/lib/offline-queue.ts b/frontend/lib/offline-queue.ts index 6d8d38c..b649c8c 100644 --- a/frontend/lib/offline-queue.ts +++ b/frontend/lib/offline-queue.ts @@ -1,11 +1,13 @@ -// Offline skrive-kø for scoreregistrering (ADR-028). Ren IndexedDB, ingen -// avhengighet til service workeren -- gir umiddelbar, presis -// UI-tilbakemelding ("lagret lokalt, venter på synk") direkte fra -// scorekort-komponenten, som er enklere og mer testbart enn å prøve å gjøre -// det samme inni en SW fetch-handler. Bevisst IKKE Background Sync API: -// iOS Safari støtter den ikke i det hele tatt, og en stor andel av -// klubb-/vennegjeng-brukerne er trolig på iPhone -- et enkelt -// window.online-lytter-mønster (se session-scorecard.tsx) fungerer overalt. +// Offline skrive-kø, opprinnelig for scoreregistrering (ADR-028), utvidet +// 2026-08-14 til også å dekke kommentar-/bildeposting i rundefeeden (se +// round-messages.tsx). Ren IndexedDB, ingen avhengighet til service +// workeren -- gir umiddelbar, presis UI-tilbakemelding ("lagret lokalt, +// venter på synk") direkte fra kalleren, som er enklere og mer testbart +// enn å prøve å gjøre det samme inni en SW fetch-handler. Bevisst IKKE +// Background Sync API: iOS Safari støtter den ikke i det hele tatt, og en +// stor andel av klubb-/vennegjeng-brukerne er trolig på iPhone -- et +// enkelt window.online-lytter-mønster (se session-scorecard.tsx) fungerer +// overalt. const DB_NAME = "teecup-offline" const DB_VERSION = 1 @@ -26,6 +28,13 @@ export type QueueEntry = { // kjedingen i flushQueue under blandet sammen versjonsnummer mellom // urelaterte rader (se ADR-060). resourceKey?: string + // Multipart-skrivinger (kommentar+bilde til rundefeeden, 2026-08-14) -- + // `body` er da et FLATT objekt av string/Blob-felt i stedet for en + // JSON-serialiserbar verdi. IndexedDB sin structured-clone-algoritme + // lagrer Blob-verdier (og dermed File, som arver fra Blob) NATIVT -- + // ingen egen blob-lagringsstruktur trengs. Uendret oppførsel + // (JSON.stringify + Content-Type: application/json) når feltet mangler. + isMultipart?: boolean } function openDb(): Promise { @@ -112,6 +121,7 @@ export async function flushQueue(matchId: string): Promise { const key = entry.resourceKey ?? entry.url let body: unknown = entry.body if ( + !entry.isMultipart && body !== null && typeof body === "object" && "expected_version" in body && @@ -121,12 +131,21 @@ export async function flushQueue(matchId: string): Promise { } let res: Response try { - res = await fetch(entry.url, { - method: entry.method, - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(body), - }) + if (entry.isMultipart) { + const formData = new FormData() + for (const [k, v] of Object.entries(body as Record)) { + if (v === undefined || v === null) continue + formData.append(k, v) + } + res = await fetch(entry.url, { method: entry.method, credentials: "include", body: formData }) + } else { + res = await fetch(entry.url, { + method: entry.method, + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body), + }) + } } catch { break // fortsatt offline -- stopp, resten prøves igjen senere } diff --git a/tests/test_message_tags.py b/tests/test_message_tags.py index fe407d0..18c4490 100644 --- a/tests/test_message_tags.py +++ b/tests/test_message_tags.py @@ -69,7 +69,7 @@ async def test_post_message_with_valid_tag_creates_tag_and_notification(pool): body = "God runde med @Venn i dag" tag_json = f'[{{"user_id": "{friend_id}", "start_index": 14, "end_index": 18}}]' - out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id)) + out = await post_round_message(round_id, body=body, image=None, tags=tag_json, client_message_id=None, user=CurrentUser(user_id=owner_id)) assert len(out.tags) == 1 assert out.tags[0].user_id == friend_id @@ -89,7 +89,7 @@ async def test_post_message_with_stranger_tag_is_silently_dropped(pool): body = "Hyggelig runde" tag_json = f'[{{"user_id": "{stranger_id}", "start_index": 0, "end_index": 9}}]' - out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id)) + out = await post_round_message(round_id, body=body, image=None, tags=tag_json, client_message_id=None, user=CurrentUser(user_id=owner_id)) assert out.body == body assert out.tags == [] @@ -103,13 +103,13 @@ async def test_post_message_with_out_of_bounds_tag_is_dropped(pool): body = "Kort" tag_json = f'[{{"user_id": "{friend_id}", "start_index": 0, "end_index": 999}}]' - out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id)) + out = await post_round_message(round_id, body=body, image=None, tags=tag_json, client_message_id=None, user=CurrentUser(user_id=owner_id)) assert out.tags == [] async def test_post_comment_with_valid_tag(pool): owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() - message = await post_round_message(round_id, body="Innlegg", image=None, tags=None, user=CurrentUser(user_id=owner_id)) + message = await post_round_message(round_id, body="Innlegg", image=None, tags=None, client_message_id=None, user=CurrentUser(user_id=owner_id)) comment_body = "Bra jobba @Venn" payload = CommentIn(body=comment_body, tags=[TagIn(user_id=friend_id, start_index=10, end_index=14)]) @@ -124,5 +124,56 @@ async def test_cannot_tag_self(pool): owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() body = "Meg selv" tag_json = f'[{{"user_id": "{owner_id}", "start_index": 0, "end_index": 4}}]' - out = await post_round_message(round_id, body=body, image=None, tags=tag_json, user=CurrentUser(user_id=owner_id)) + out = await post_round_message(round_id, body=body, image=None, tags=tag_json, client_message_id=None, user=CurrentUser(user_id=owner_id)) assert out.tags == [] + + +# --------------------------------------------------------------------------- +# Idempotens for offline-kø-synk (ADR-028-tillegget, migrasjon 073, +# 2026-08-14) -- se round_messages.py::post_round_message. +# --------------------------------------------------------------------------- + +async def test_repeated_client_message_id_returns_same_message_not_a_duplicate(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + client_message_id = "11111111-1111-1111-1111-111111111111" + + first = await post_round_message( + round_id, body="Fra banen", image=None, tags=None, + client_message_id=client_message_id, user=CurrentUser(user_id=owner_id), + ) + second = await post_round_message( + round_id, body="Fra banen", image=None, tags=None, + client_message_id=client_message_id, user=CurrentUser(user_id=owner_id), + ) + + assert first.id == second.id + async with app_db.plain_connection() as conn: + count = await conn.fetchval( + "SELECT count(*) FROM round_message WHERE round_id = $1 AND client_message_id = $2", + round_id, client_message_id, + ) + assert count == 1 + + +async def test_different_client_message_ids_create_separate_messages(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + + first = await post_round_message( + round_id, body="Første", image=None, tags=None, + client_message_id="22222222-2222-2222-2222-222222222222", user=CurrentUser(user_id=owner_id), + ) + second = await post_round_message( + round_id, body="Andre", image=None, tags=None, + client_message_id="33333333-3333-3333-3333-333333333333", user=CurrentUser(user_id=owner_id), + ) + + assert first.id != second.id + + +async def test_missing_client_message_id_still_works(pool): + owner_id, friend_id, stranger_id, round_id = await _setup_round_with_owner_and_friend() + out = await post_round_message( + round_id, body="Uten idempotens-nøkkel", image=None, tags=None, + client_message_id=None, user=CurrentUser(user_id=owner_id), + ) + assert out.body == "Uten idempotens-nøkkel"