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
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 ? (
+
+
+ Venter på synk
+
+ ) : (
+
+ )}