1. WHS Rule 3.1b — ferdig, testet (117/117), rullet ut... nei, venter fortsatt (se under). 2. round.tee_name_snapshot-synk — ferdig, scratch-verifisert. 3. Optimistisk versjonssjekk for samtidig hull-redigering — ferdig bygget, og jeg fant og rettet en reell UX-bug underveis (en fullskjerm-feilvisning i stedet for en liten banner) som ellers ville gjort funksjonen verre enn ingenting. Alt er dokumentert i ADR-056/057 og CHANGELOG punkt 65–66. Deploy — rekkefølgen har betydning (migrasjonen må kjøre først, siden ny kode leser version-kolonnen): # 1. Migrasjon mot ekte teecup_db docker exec teeoff_db psql -U teeoff_admin -d teecup_db -f /opt/teecup/062_round_hole_version.sql # 2. Backend docker compose build teecup_api docker compose up -d teecup_api # 3. Frontend docker compose build teecup_frontend docker compose up -d teecup_frontend Og fortsatt uavklart fra tidligere: datakorreksjonen for din spesifikke runde UPDATE round SET tee_name_snapshot = '55' WHERE id = '520f9681-6e7f-49bf-8504-9ab223874285';
130 lines
5 KiB
TypeScript
130 lines
5 KiB
TypeScript
// 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
|
|
}
|
|
|
|
function openDb(): Promise<IDBDatabase> {
|
|
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<QueueEntry, "id" | "createdAt">,
|
|
): Promise<number> {
|
|
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<QueueEntry[]> {
|
|
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<void> {
|
|
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<number> {
|
|
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<FlushOutcome[]> {
|
|
const entries = await listQueue(matchId)
|
|
const outcomes: FlushOutcome[] = []
|
|
// Kjeder expected_version fremover for flere køede skrivinger til SAMME
|
|
// URL i én flush-runde (2026-08-10, ADR-057 versjonssjekk). Uten dette
|
|
// ville en spillers EGNE påfølgende offline-redigeringer av samme hull
|
|
// 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.
|
|
const latestVersionByUrl = new Map<string, number>()
|
|
for (const entry of entries) {
|
|
let body: unknown = entry.body
|
|
if (
|
|
body !== null &&
|
|
typeof body === "object" &&
|
|
"expected_version" in body &&
|
|
latestVersionByUrl.has(entry.url)
|
|
) {
|
|
body = { ...body, expected_version: latestVersionByUrl.get(entry.url) }
|
|
}
|
|
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") {
|
|
latestVersionByUrl.set(entry.url, 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
|
|
}
|