PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
// TeeCup service worker (ADR-028) -- håndskrevet, ingen next-pwa/workbox-
|
|
|
|
|
// avhengighet. To ansvar, begge begrenset med vilje:
|
|
|
|
|
//
|
|
|
|
|
// 1) Cache side-NAVIGASJON (nettverk først, cache som fallback, offline.html
|
|
|
|
|
// som siste utvei) -- en tidligere besøkt side kan åpnes på nytt uten
|
|
|
|
|
// nett.
|
|
|
|
|
// 2) Cache GET-kall under /orgs/* (nettverk først, cache som fallback) --
|
|
|
|
|
// slik at data for en allerede åpnet side (f.eks. et scorekort) fortsatt
|
|
|
|
|
// er LESBAR offline. Bevisst IKKE stale-while-revalidate: scorekortet
|
|
|
|
|
// leses på nytt rett etter hver innsending, og må da alltid prøve ekte
|
|
|
|
|
// fersk data først -- en umiddelbart utdatert cache ville vist feil
|
|
|
|
|
// matchstatus/hull-tall rett etter en vellykket innsending.
|
|
|
|
|
//
|
|
|
|
|
// Selve SKRIVINGEN (hole-scores/hole-results) fanges ALDRI her -- det
|
|
|
|
|
// håndteres i lib/offline-queue.ts, med en egen IndexedDB-kø i klientkoden,
|
|
|
|
|
// hvor UI-et kan gi umiddelbar, presis tilbakemelding ("lagret lokalt,
|
|
|
|
|
// venter på synk"). /auth/* og /public/* caches bevisst ikke -- caching er
|
|
|
|
|
// begrenset til akkurat det scoreregistrerings-flyten trenger.
|
|
|
|
|
//
|
|
|
|
|
// Kjent, akseptert begrensning: Cache Storage er nøklet på URL, ikke på
|
|
|
|
|
// innlogget bruker/sesjon -- deler flere kontoer samme enhet/nettleser,
|
|
|
|
|
// kan en offline-fallback vise data cachet av en TIDLIGERE innlogget
|
|
|
|
|
// bruker på samme enhet. Ikke løst i v1 (samme tillitsnivå som resten av
|
|
|
|
|
// appen, se team_authz.py sin trusselmodell-begrunnelse), ingen
|
|
|
|
|
// cache-tømming ved utlogging bygget ennå.
|
|
|
|
|
|
|
|
|
|
const RUNTIME_CACHE = "teecup-runtime-v1"
|
|
|
|
|
const OFFLINE_URL = "/offline.html"
|
|
|
|
|
|
|
|
|
|
self.addEventListener("install", (event) => {
|
|
|
|
|
event.waitUntil(
|
|
|
|
|
caches
|
|
|
|
|
.open(RUNTIME_CACHE)
|
|
|
|
|
.then((cache) => cache.add(OFFLINE_URL))
|
|
|
|
|
.then(() => self.skipWaiting()),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
|
|
|
event.waitUntil(
|
|
|
|
|
caches
|
|
|
|
|
.keys()
|
|
|
|
|
.then((keys) => Promise.all(keys.filter((k) => k !== RUNTIME_CACHE).map((k) => caches.delete(k))))
|
|
|
|
|
.then(() => self.clients.claim()),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
|
|
|
const req = event.request
|
|
|
|
|
if (req.method !== "GET") return // skriving går aldri via SW-en
|
|
|
|
|
|
|
|
|
|
if (req.mode === "navigate") {
|
|
|
|
|
event.respondWith(
|
|
|
|
|
fetch(req)
|
|
|
|
|
.then((res) => {
|
|
|
|
|
const copy = res.clone()
|
|
|
|
|
caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, copy))
|
|
|
|
|
return res
|
|
|
|
|
})
|
|
|
|
|
.catch(async () => (await caches.match(req)) || (await caches.match(OFFLINE_URL))),
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = new URL(req.url)
|
|
|
|
|
if (url.origin === self.location.origin && url.pathname.startsWith("/orgs/")) {
|
|
|
|
|
event.respondWith(
|
|
|
|
|
fetch(req)
|
|
|
|
|
.then((res) => {
|
|
|
|
|
if (res.ok) caches.open(RUNTIME_CACHE).then((cache) => cache.put(req, res.clone()))
|
|
|
|
|
return res
|
|
|
|
|
})
|
|
|
|
|
.catch(() => caches.match(req)),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
})
|
Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)
Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)
Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)
Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)
Scratch-verifiser backend for alle fire (27/27 sjekker gjort)
Browserverifiser alle fire i nettleser mot scratch-backend
Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:
PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00
|
|
|
|
|
|
|
|
// Push-varsler til telefonens OS (Web Push/VAPID, 2026-07-28). Payloaden er
|
|
|
|
|
// alltid JSON {title, body, link_path} (se app/push.py) -- viser en native
|
|
|
|
|
// systemvarsling selv når ingen fane er åpen. notificationclick fokuserer en
|
|
|
|
|
// allerede åpen fane på samme origin hvis én finnes, ellers åpner en ny.
|
|
|
|
|
self.addEventListener("push", (event) => {
|
|
|
|
|
let data = { title: "TeeCup", body: "Du har et nytt varsel.", link_path: "/my-notifications" }
|
|
|
|
|
try {
|
|
|
|
|
if (event.data) data = { ...data, ...event.data.json() }
|
|
|
|
|
} catch {
|
|
|
|
|
// Behold standardteksten hvis payloaden mot formodning ikke er JSON.
|
|
|
|
|
}
|
|
|
|
|
event.waitUntil(
|
|
|
|
|
self.registration.showNotification(data.title, {
|
|
|
|
|
body: data.body,
|
|
|
|
|
icon: "/icons/icon-192.png",
|
|
|
|
|
badge: "/icons/icon-192.png",
|
|
|
|
|
data: { link_path: data.link_path },
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
self.addEventListener("notificationclick", (event) => {
|
|
|
|
|
event.notification.close()
|
|
|
|
|
const linkPath = event.notification.data?.link_path || "/my-notifications"
|
|
|
|
|
event.waitUntil(
|
|
|
|
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
|
|
|
|
for (const client of clients) {
|
|
|
|
|
if (client.url.includes(self.location.origin) && "focus" in client) {
|
|
|
|
|
client.navigate(linkPath)
|
|
|
|
|
return client.focus()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return self.clients.openWindow(linkPath)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|