// 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. const DB_NAME = "teecup-offline" const DB_VERSION = 1 const STORE = "pending-writes" export type QueueEntry = { id: number url: string method: string body: unknown matchId: string createdAt: number // Identifiserer HVILKEN rad på serveren denne skrivingen gjelder -- // default url (uendret oppførsel for kallere med én rad per URL, f.eks. // round_hole). NØDVENDIG å sette eksplisitt når flere ulike rader deler // samme URL (f.eks. scoring.py sin /hole-scores, som tar imot ALLE hull // i en match på nøyaktig samme endepunkt) -- ellers ville versjons- // kjedingen i flushQueue under blandet sammen versjonsnummer mellom // urelaterte rader (se ADR-060). resourceKey?: string } function openDb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION) req.onupgradeneeded = () => { const db = req.result if (!db.objectStoreNames.contains(STORE)) { const store = db.createObjectStore(STORE, { keyPath: "id", autoIncrement: true }) store.createIndex("matchId", "matchId", { unique: false }) } } req.onsuccess = () => resolve(req.result) req.onerror = () => reject(req.error) }) } export async function enqueueWrite( entry: Omit, ): Promise { const db = await openDb() return new Promise((resolve, reject) => { const tx = db.transaction(STORE, "readwrite") const req = tx.objectStore(STORE).add({ ...entry, createdAt: Date.now() }) req.onsuccess = () => resolve(req.result as number) req.onerror = () => reject(req.error) }) } export async function listQueue(matchId: string): Promise { const db = await openDb() return new Promise((resolve, reject) => { const tx = db.transaction(STORE, "readonly") const req = tx.objectStore(STORE).index("matchId").getAll(matchId) req.onsuccess = () => resolve((req.result as QueueEntry[]).sort((a, b) => a.createdAt - b.createdAt)) req.onerror = () => reject(req.error) }) } async function removeFromQueue(id: number): Promise { const db = await openDb() return new Promise((resolve, reject) => { const tx = db.transaction(STORE, "readwrite") tx.objectStore(STORE).delete(id) tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) }) } export async function queueCount(matchId: string): Promise { return (await listQueue(matchId)).length } export type FlushOutcome = { entry: QueueEntry; ok: boolean; message?: string } // Sender køede skrivinger i rekkefølge, ETT om gangen (ikke parallelt) -- // server-siden er en upsert per hull, så rekkefølgen har betydning hvis // samme hull ble endret flere ganger offline. Stopper ved første ekte // nettverksfeil (resten blir liggende til neste forsøk); en DEFINITIV // HTTP-feil (f.eks. 409 "matchen er avgjort") fjernes fra køen og // rapporteres tilbake i stedet for å bli hengende for alltid. export async function flushQueue(matchId: string): Promise { const entries = await listQueue(matchId) const outcomes: FlushOutcome[] = [] // Kjeder expected_version fremover for flere køede skrivinger til SAMME // RESSURS i én flush-runde (2026-08-10, ADR-057 versjonssjekk; nøkkel // generalisert til resourceKey 2026-08-10, ADR-060). Uten dette ville en // spillers EGNE påfølgende offline-redigeringer av samme rad avvist // hverandre som falske konflikter: kun den FØRSTE køede oppføringen har // en expected_version som fortsatt stemmer med serveren -- lokal state // (og dermed enqueue-tidspunktets versjon) oppdateres aldri mellom to // sekvensielle avspillinger i samme batch, så oppføring 2 ville ellers // sendt den samme (nå utdaterte) versjonen som 1. // // NØKKELEN er `resourceKey ?? url`, IKKE bare url -- for round_hole er // url alene entydig (én URL per hull), men scoring.py sine /hole-scores/ // /hole-results tar imot ALLE hull i en match på nøyaktig samme URL. Uten // en mer presis nøkkel ville versjonen fra hull 3 sin skriving blitt // brukt som "forventet versjon" for en påfølgende, helt urelatert // skriving til hull 7 -- verre enn ingen kjeding i det hele tatt. const latestVersionByKey = new Map() for (const entry of entries) { const key = entry.resourceKey ?? entry.url let body: unknown = entry.body if ( body !== null && typeof body === "object" && "expected_version" in body && latestVersionByKey.has(key) ) { body = { ...body, expected_version: latestVersionByKey.get(key) } } let res: Response try { 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 } if (res.ok) { const updated: { version?: number } | null = await res.json().catch(() => null) if (updated && typeof updated.version === "number") { latestVersionByKey.set(key, updated.version) } await removeFromQueue(entry.id) outcomes.push({ entry, ok: true }) } else { const respBody: { detail?: { message?: string } } | null = await res.json().catch(() => null) await removeFromQueue(entry.id) outcomes.push({ entry, ok: false, message: respBody?.detail?.message ?? `Feil ${res.status}` }) } } return outcomes }