diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9c289a5..16cb80d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -297,7 +297,25 @@ "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/matches.py'\\).read\\(\\)\\)\")", "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/scoring.py'\\).read\\(\\)\\)\")", "Bash(mkdir -p /opt/teecup/frontend/app/t/\\\\[id\\\\]/live)", - "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/messaging.py'\\).read\\(\\)\\)\")" + "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/messaging.py'\\).read\\(\\)\\)\")", + "Bash(python3 -c \"import PIL; print\\('PIL ok', PIL.__version__\\)\")", + "Bash(python3 -c \"import cairosvg\")", + "Bash(node -e \"console.log\\(require\\('sharp'\\)\\)\")", + "Bash(timeout 60 npx --yes -p sharp -e ' *)", + "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/iconbuild)", + "Bash(timeout 90 npm install sharp --no-audit --no-fund)", + "Bash(rm -rf node_modules package-lock.json)", + "Bash(timeout 90 npm install sharp@0.33.5 --no-audit --no-fund)", + "Bash(node gen.js)", + "Bash(cat)", + "Bash(node gen-apple.js)", + "Bash(curl -s http://localhost:18734/manifest.webmanifest)", + "Bash(curl -s http://localhost:18734/sw.js)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18734/offline.html)", + "Bash(curl -s -o /dev/null -w \"192: %{http_code}\\\\n\" http://localhost:18734/icons/icon-192.png)", + "Bash(curl -s -o /dev/null -w \"512: %{http_code}\\\\n\" http://localhost:18734/icons/icon-512.png)", + "Bash(curl -s -o /dev/null -w \"maskable: %{http_code}\\\\n\" http://localhost:18734/icons/icon-maskable-512.png)", + "Bash(curl -s -o /dev/null -w \"apple: %{http_code}\\\\n\" http://localhost:18734/apple-icon.png)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index ab0db4e..9df83f1 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -1200,6 +1200,106 @@ status for scratch-verifisering og utrulling. --- +## ADR-028: PWA — installasjon + offline scoreregistrering + +Reist 2026-07-19, brukeren valgte å ta fatt på PWA (stod som "neste steg" i +CLAUDE.md siden ADR-006 vedtok prinsippet helt i starten av prosjektet — aldri +bygget). To beslutninger avklart eksplisitt med bruker (AskUserQuestion) før +bygging. + +**Beslutning A — Full offline scoreregistrering i v1, ikke bare +installasjon.** Bruker valgte det mest ambisiøse alternativet: BÅDE +manifest/ikoner/service worker (installerbar app) OG at scorekort-skjermen +skal fungere uten nett — skriv til en lokal IndexedDB-kø, synk automatisk når +nettet er tilbake. Matcher ADR-006s opprinnelige formulering ordrett +("score må kunne registreres uten nett og synkes senere"). + +**Beslutning B — Omfang av selve offline-skrivingen: kun `hole-scores`/ +`hole-results`, ingenting annet.** De to eksisterende scoreregistrerings- +endepunktene (ADR-012) er de eneste som køes. Bevisst UTENFOR omfang, ikke +glemt: +- Walkover/konsesjon (ADR-024) — sjeldnere handling, kan kreve nett. +- Chat/feed-posting (ADR-025) — bilder gjør en offline-kø vesentlig mer + komplisert (blob-lagring i IndexedDB), og er ikke "score"-handlingen + ADR-006 faktisk siktet til. +- Alle andre skrive-endepunkter (oppsett, roster, økter osv.) — forutsetter + nett i v1, uendret. + +**Beslutning C — Køen lever i klientkoden (IndexedDB, `lib/offline- +queue.ts`), IKKE i service workeren, og bruker `window` sitt `online`- +event, IKKE Background Sync API.** To bevisste forenklinger: +1. Å gi umiddelbar, presis UI-tilbakemelding ("lagret lokalt, venter på + synk", pending-antall) er enklere og mer testbart fra selve + React-komponenten enn fra en service worker sin `fetch`-handler. +2. Background Sync API (som ville gitt synk selv om appen er lukket) støttes + IKKE av iOS Safari i det hele tatt — en stor andel av klubb-/ + vennegjeng-brukerne er trolig på iPhone, og et rent + Background-Sync-avhengig design ville derfor vært brutt for dem. Et + `window.addEventListener("online", ...)`-mønster (pluss en manuell + "Synkroniser nå"-knapp i UI-et som reserve) fungerer overalt, på + bekostning av at synk krever at appen faktisk er åpen når nettet kommer + tilbake — akseptabelt for v1. + +`submitStroke`/`submitHoleResult` (`components/session-scorecard.tsx`) +sjekker `navigator.onLine` FØRST (unngår en unødvendig ventetid på et +nettverkskall som uansett vil feile), og fanger ellers en EKTE nettverksfeil +i fetch-kallet separat fra et avvist HTTP-svar (`res.ok === false`, f.eks. +409 "matchen er avgjort") — kun den førstnevnte køordner, sistnevnte viser +fortsatt den vanlige feilteksten uendret. Et lokalt overlay +(`pendingStrokes`/`pendingResults`, ikke persistert i selve +`scorecard`-staten) viser køede verdier umiddelbart i UI-et, merket +"Lagret lokalt · venter på synk", inkludert i hull-navigasjonens +registrert-markering. + +**Konfliktmodell: samme tillitsnivå som resten av appen, ingen ny +mekanisme.** Server-siden er allerede en upsert per hull (`ON CONFLICT ... +DO UPDATE`) — siste innsending vinner, ingen audit-trail (kjent, tidligere +dokumentert åpent spørsmål, se «Scoring-autorisasjon» i FEATURE_BACKLOG.md). +Køen sender i rekkefølge (aldri parallelt) for å respektere lokal +innsendingsrekkefølge ved flere endringer av samme hull offline. Et +definitivt HTTP-avvist forsøk ved synk (typisk: matchen ble avgjort på en +ANNEN enhet mens denne var offline) fjernes fra køen og vises som en +feilmelding — blir ALDRI hengende for alltid. + +**Service worker-strategi: nettverk først, cache som fallback — bevisst +IKKE stale-while-revalidate.** `public/sw.js` cacher (a) side-navigasjon og +(b) GET-kall under `/orgs/*`. Begge prøver ekte nettverk FØRST og faller +kun tilbake til cache ved reell feil. En stale-while-revalidate-strategi ble +vurdert og avvist: scorekortet gjør et `refetchScorecard()`-kall RETT ETTER +hver innsending, og må da alltid få fersk data — en umiddelbart utdatert +cache ville vist feil matchstatus/hull-tall rett etter en vellykket +innsending. `/auth/*` og `/public/*` caches bevisst ikke — omfanget er +begrenset til akkurat det scoreregistrerings-flyten trenger. + +**Kjent, akseptert begrensning (ikke løst i v1):** Cache Storage er nøklet +på URL, ikke på innlogget bruker — deler flere kontoer samme enhet/ +nettleser, kan en offline-fallback teoretisk vise data cachet av en +TIDLIGERE innlogget bruker på samme enhet. Ingen cache-tømming ved +utlogging bygget. Samme tillitsnivå/trusselmodell som appen ellers opererer +med (tillitsbasert klubb-/vennegjeng-verktøy, jf. `team_authz.py` sin +begrunnelse i ADR-023/025). + +**Ikoner: enkelt, midlertidig sett generert programmatisk (grønt +golf-flagg), ikke endelig design.** Bruker valgte å generere nå fremfor å +vente på ekte design, MED eksplisitt beskjed om at disse skal erstattes +senere — notert i FEATURE_BACKLOG.md. Erstattet samtidig den gamle +`public/apple-icon.png` (v0.app sin generiske plassholder-logo, ikke +TeeCup-merkevare i det hele tatt) med samme nye ikon, av konsistens. + +**Ikke testet i ekte nettleser (viktig, ikke bare en formalitet):** +verifisert med typesjekket produksjonsbuild (samme `Dockerfile` som +deployes) og en kort container-boot med `curl` (manifest/service worker/ +ikoner/offline.html svarer riktig), men INGEN faktisk browser-basert +offline-test (DevTools "Offline"-modus, "Legg til på hjemskjerm") er +gjennomført denne runden — ingen nettleserverktøy tilgjengelig i denne +økten. Anbefales sterkt at brukeren selv tester scorekort-siden med Chrome +DevTools sin Offline-bryter før tillit legges til flyten i skarp bruk. + +**Status: ✅ BYGGET, IKKE ENNÅ RULLET UT.** Se CLAUDE.md-status for +byggerunden. Venter på brukerens utrullingsbekreftelse. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/CLAUDE.md b/CLAUDE.md index cd0b891..4bf32f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1670,18 +1670,95 @@ Ferdig og verifisert: (ukjent turnering-id, ingen ekte data berørt) ga korrekt 404 fra selve applikasjonslaget. +- **Fire brukerrapporterte UI-/UX-hull, DIAGNOSTISERT OG NOTERT, IKKE fikset + (2026-07-19):** brukeren rapporterte fire ting fra faktisk bruk av + `teecup.teeoff.no` rett før PWA-runden startet. Root cause funnet ved + kodegjennomgang for tre av fire (ikke gjettet). Full detalj i + FEATURE_BACKLOG.md sin nye seksjon "Rapporterte UI-/UX-hull (2026-07-19)". + Kort: + 1. Dashboard-turneringskortet viser "Ingen datoer satt" alltid — leser + `tournament.start_date`/`end_date` (eget felt, ADR-015), som INGEN + UI-skjema noensinne skriver til. Skal enten få et faktisk skjemafelt, + eller kortet bør heller utlede datoen fra øktenes `scheduled_at`. + 2. **Reell, bekreftet rewrite-bug:** `/orgs/{id}/members` gir en rå + FastAPI-404 (`{"detail":"Not Found"}`) i stedet for medlemssiden. + `next.config.mjs` sin `rewrites()` returnerer en plain array (implisitt + "afterFiles") — DYNAMISKE Next.js-sider sjekkes ETTER rewrites, så + `/orgs/:path*`-proxy-regelen (ADR-016) fanger kallet FØR + `app/orgs/[id]/members/page.tsx` noensinne nås. Dette er den FØRSTE + frontend-siden som er nestet direkte under et allerede proxyet prefiks + — ingen tidligere skjerm har truffet dette. Selve siden/komponenten er + riktig bygget, kun ruten dit er blokkert. + 3. Ingen UI-vei til å opprette en ANDRE organisasjon når man allerede har + én — `CreateOrganizationState` i `dashboard.tsx` vises kun ved null + org-er. Backend støtter det fullt ut allerede (ADR-021). + 4. Ingen sammendrag/indikator noe sted for "alle runder har fått dato" — + må sjekkes manuelt per øktkort på program-skjermen. + **Ingen av de fire fikset i denne runden** — kun dokumentert på brukerens + eksplisitte instruks, PWA-runden prioriteres først. + +- **PWA: installasjon + full offline scoreregistrering, BYGGET, IKKE ENNÅ + RULLET UT (2026-07-19, ADR-028):** bruker valgte det mest ambisiøse + omfanget (installerbar app OG offline scoreregistrering, ikke bare + installasjon), og valgte å generere enkle ikoner nå fremfor å vente på + ekte design (med eksplisitt beskjed om at de er midlertidige). + **Ikoner:** ingen `PIL`/`rsvg-convert`/`imagemagick` tilgjengelig i miljøet + — løst med et scratch npm-prosjekt (`sharp@0.33.5`, node18-kompatibel + versjon; nyeste `sharp` krever node ≥20 og feilet først) som genererte et + enkelt grønt golf-flagg-ikonsett (`public/icons/icon-192.png`, + `icon-512.png`, `icon-maskable-512.png`) + erstattet den gamle + `public/apple-icon.png` (var v0.app sin generiske plassholderlogo, ikke + TeeCup-merkevare i det hele tatt). + **Bygget:** `app/manifest.ts` (Next.js sin innebygde manifest-generator, + ikke en statisk `manifest.json`), `appleWebApp`-metadata i `layout.tsx` + (iOS leser ikke manifest.json for hjemskjerm-oppførsel), + `components/sw-register.tsx` (stille no-op uten SW-støtte), + hånd­skrevet `public/sw.js` (ingen next-pwa/workbox-avhengighet — nettverk + først/cache-fallback for navigasjon + `/orgs/*`-GET-er, BEVISST ikke + stale-while-revalidate, se ADR-028 for hvorfor), `public/offline.html`. + **Offline scoreregistrering:** ny `lib/offline-queue.ts` (IndexedDB-kø, + ren klientkode — ikke i SW-en), koblet inn i `session-scorecard.tsx` sin + `submitStroke`/`submitHoleResult`: sjekker `navigator.onLine` først, køer + kun ved en EKTE nettverksfeil (ikke ved et avvist HTTP-svar som "matchen + er avgjort" — det vises fortsatt som vanlig feiltekst). Lokalt overlay + (`pendingStrokes`/`pendingResults`) viser køede verdier umiddelbart, + merket "Lagret lokalt · venter på synk". Auto-synk ved `window`s + `online`-event PLUSS en manuell "Synkroniser nå"-knapp (bevisst IKKE + Background Sync API — iOS Safari støtter den ikke). Et definitivt avvist + synk-forsøk (f.eks. matchen ble avgjort på en annen enhet mens denne var + offline) fjernes fra køen og vises som feilmelding, henger aldri for + alltid. + **Verifisert:** ekte typesjekket produksjonsbuild (samme `Dockerfile` som + deployes) kjørt og bekreftet — alle 16 ruter listet inkl. + `/manifest.webmanifest`. Kort container-boot + `curl` bekreftet manifest/ + service worker/ikoner/offline.html alle svarer riktig (200, riktig + innhold). + **IKKE gjort denne runden, viktig å være ærlig om:** ingen faktisk + nettleser-basert offline-test (Chrome DevTools sin Offline-bryter, + faktisk "Legg til på hjemskjerm") — intet nettleserverktøy tilgjengelig i + denne økten. Kun kodegjennomgang + build-verifisering. **Brukeren bør selv + teste scorekort-siden med DevTools Offline-modus før tillit i skarp + bruk.** + **Ikke rullet ut ennå** — venter på eksplisitt utrullingsbekreftelse. + Ingen migrasjon, kun `teecup_frontend` trenger redeploy. + Neste steg: -1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. -2. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for +1. PWA er bygget, venter på utrullingsbekreftelse — se over. ANBEFAL + brukeren å browser-teste offline-flyten selv (DevTools Offline-modus) + uavhengig av om/når den rulles ut. +2. Fire UI-/UX-hull notert 2026-07-19 (se over) — ingen fikset ennå. + Rewrite/medlemsside-bugen (#2) er den mest presserende siden siden er + helt utilgjengelig i dag. +3. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger (bevisst utsatt i ADR-025). -3. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/ +4. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/ Robbins/Try all, notert 2026-07-19) — ingen ADR-runde startet ennå. -4. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute, +5. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute, ADR-025) ligger uncommitted i det SEPARATE `/opt/teeoff`-repoet, ikke i `teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring, lett å glemme siden denne økten ellers kun har jobbet i `/opt/teecup`. -5. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt), +6. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver gang — diff mot live-treet i et scratch-område før noe pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index a41a9ce..98fc49b 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -806,12 +806,101 @@ manuelt tildelt makt»-designet). --- +## Rapporterte UI-/UX-hull (2026-07-19) — notert, IKKE fikset ennå + +Fire punkter rapportert av brukeren fra faktisk bruk av `teecup.teeoff.no`. +Root cause funnet ved kodegjennomgang for de tre første (ikke bare gjettet); +det fjerde er et reelt manglende UI-element, ingen kodefeil. Ingen av de fire +er fikset i denne runden — kun dokumentert slik at de ikke går i glemmeboken. + +1. **Dashboard-turneringskortet viser "Ingen datoer satt" selv om øktene + (rundene) har dato/klokkeslett satt.** `components/tournament-card.tsx` + leser `tournament.start_date`/`end_date` — et EGET, frittstående felt på + selve turneringen (ADR-015), atskilt fra `session.scheduled_at` (dato per + økt/runde). Grep bekrefter: disse to tournament-feltene LESES tre steder + i frontend (`dashboard.tsx`, `public-tournament.tsx`, `public-club.tsx`), + men skrives INGEN steder — det finnes ingen UI for å sette dem i det hele + tatt. Kortet vil derfor alltid vise "ingen datoer satt", uansett hvor mange + økter som har fått en `scheduled_at`, fordi feltet det leser aldri kan bli + satt gjennom UI-et. To mulige retninger: (a) bygg et faktisk + `start_date`/`end_date`-skjemafelt på turneringen, eller (b) la kortet + utlede visningsdatoen fra øktenes `scheduled_at`-spenn i stedet for et + eget, separat felt — sistnevnte er trolig det organisatoren faktisk + forventer. + +2. **`/orgs/{id}/members`-siden gir en rå API-404 (`{"detail":"Not + Found"}`), ikke medlemssiden.** Bekreftet root cause, ikke bare + reprodusert: `frontend/next.config.mjs` sin `rewrites()` returnerer en + PLAIN ARRAY (implisitt "afterFiles"-semantikk i Next.js) — det betyr at + ikke-dynamiske filer/sider sjekkes FØR rewrites, men DYNAMISKE sider + (som `app/orgs/[id]/members/page.tsx`) sjekkes ETTER. Rewrite-regelen + `{ source: "/orgs/:path*", destination: ".../orgs/:path*" }` (satt opp i + ADR-016 for å proxye API-kall) fanger derfor `/orgs/{id}/members` FØR + Next.js noensinne når frem til den faktiske siden, og sender kallet til + FastAPI i stedet — som naturligvis ikke har noen `GET /orgs/{id}/members`- + rute (kun `/orgs/{id}/memberships`), derav den rå FastAPI-404-formen + (ikke engang appens egen `app_error`-kontrakt, siden ruten ikke matcher + noe sted i det hele tatt). Selve siden (`org-members.tsx`, lenken fra + dashbordet) er ellers riktig bygget — dette er en ren + rewrite/dynamisk-rute-presedens-krasj, samme klasse fallgruve som + ADR-016 sin opprinnelige "alt nytt API-prefiks må inn i rewrites"-lærdom, + bare i motsatt retning (en frontend-SIDE ble skjult AV en rewrite). Dette + er den FØRSTE frontend-siden som noensinne har blitt nestet direkte under + et allerede proxyet prefiks (`/orgs/*`) — ingen tidligere skjerm har + truffet dette. Sannsynlig fiks: flytt siden til en ikke-proxyet sti + (f.eks. `/organizations/[id]/members`), ELLER gjør rewrites-regelen mer + presis (kun kjente API-undermønstre som `/orgs/:id/tournaments`, + `/orgs/:id/memberships` osv., ikke et bredt `:path*`). + +3. **Dashboard: ingen vei til å opprette/legge til en ANDRE organisasjon.** + Bekreftet i `dashboard.tsx`: `CreateOrganizationState` + (opprett-organisasjon-skjemaet) vises KUN når `hasOrg` er `false`, altså + når brukeren har null organisasjoner fra før. Har brukeren allerede én + org, finnes ingen knapp/lenke noe sted i UI-et for å opprette en til — + selv om backend-et støtter dette fullt ut og uten begrensning (ADR-021, + bekreftet: `POST /orgs` har ingen grense på antall org-er én bruker kan + eie). Ren manglende UI, ikke en backend-begrensning. + +4. **Ingen måte å se, på ett blikk, at ALLE runder/økter i en turnering har + fått dato/klokkeslett satt.** `tournament-program.tsx` viser + `scheduled_at` per øktkort hvis satt, ingenting spesielt (ingen + fremhevet "mangler dato"-tilstand) hvis ikke. Ingen sammendrag/telling + noe sted ("X av Y runder har dato") — organisatoren må åpne + program-skjermen og lese hvert kort manuelt. Ren UX-mangel, ingen + bakenforliggende datamodell-begrensning (all nødvendig data finnes + allerede i `GET .../sessions`). + +**Ingenting av dette er fikset ennå** — kun diagnostisert og notert på +brukerens eksplisitte instruks, for å ikke gå i glemmeboken mens PWA-runden +prioriteres. + +--- + ## UX / frontend (senere fase) - 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp (banebruk i sollys/med solbriller). -- ✅ Offline-first (ADR-006) — prinsipp besluttet; implementasjon senere. -- 📋 PWA: manifest, service workers, «Legg til på hjemskjerm». +- ✅ Offline-first (ADR-006) — BYGGET 2026-07-19 (ADR-028), se eget punkt + under. Scoreregistrering (hole-scores/hole-results) fungerer nå offline + med automatisk synk. +- ✅ PWA: manifest, service worker, «Legg til på hjemskjerm» — BYGGET + 2026-07-19 (ADR-028). + +### PWA — ✅ BYGGET 2026-07-19 (ADR-028), IKKE ENNÅ RULLET UT + +Full design i ARCHITECTURE_DECISIONS.md ADR-028. Kort: + +| Del | Status | Notat | +|---|---|---| +| Installerbar app (manifest + ikoner + «Legg til på hjemskjerm») | ✅ bygget | `app/manifest.ts` (Next.js sin innebygde manifest-generator), `components/sw-register.tsx`, `appleWebApp`-metadata for iOS. | +| Ikoner | ✅ bygget, **MIDLERTIDIG** | Enkelt grønt golf-flagg generert programmatisk (`public/icons/*`, `public/apple-icon.png`) — **skal erstattes med ekte design senere.** Erstattet samtidig den gamle v0.app-plassholderlogoen som lå i `apple-icon.png` fra før (var aldri TeeCup-merkevare). | +| Service worker: cache app-navigasjon + `/orgs/*`-GET-er | ✅ bygget | `public/sw.js`, nettverk-først/cache-fallback (bevisst IKKE stale-while-revalidate, se ADR-028). `public/offline.html` som siste utvei. | +| Offline scoreregistrering (hole-scores/hole-results) | ✅ bygget | `lib/offline-queue.ts` (IndexedDB-kø) + `components/session-scorecard.tsx`. Synker automatisk ved `window`s `online`-event, pluss manuell "Synkroniser nå"-knapp. Bevisst IKKE Background Sync API (iOS Safari støtter den ikke). | +| Andre skrivehandlinger offline (walkover, chat/feed, oppsett) | 💤 bevisst utenfor omfang | Kun de to scoreregistrerings-endepunktene er køet — se ADR-028 Beslutning B for begrunnelse per type. | +| Faktisk browser-testet (DevTools Offline-modus) | ❌ IKKE gjort | Kun verifisert med typesjekket build + container-boot/curl. Ingen nettleserverktøy tilgjengelig denne runden — **anbefales sterkt at brukeren selv tester offline-flyten i Chrome DevTools før tillit i skarp bruk.** | + +**Ikke rullet ut ennå** — venter på brukerens eksplisitte +utrullingsbekreftelse (ingen migrasjon, kun `teecup_frontend`). --- diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 27ce342..5e123f7 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from 'next' import { Nunito } from 'next/font/google' +import { SwRegister } from '@/components/sw-register' import './globals.css' const nunito = Nunito({ @@ -33,6 +34,14 @@ export const metadata: Metadata = { ], apple: '/apple-icon.png', }, + // PWA-installasjon (ADR-028). iOS Safari leser ikke manifest.json for + // «Legg til på hjemskjerm»-oppførsel -- disse Apple-spesifikke metafeltene + // trengs i tillegg til selve manifestet (app/manifest.ts). + appleWebApp: { + capable: true, + statusBarStyle: 'default', + title: 'TeeCup', + }, } export const viewport: Viewport = { @@ -50,7 +59,10 @@ export default function RootLayout({ }>) { return ( - {children} + + + {children} + ) } diff --git a/frontend/app/manifest.ts b/frontend/app/manifest.ts new file mode 100644 index 0000000..c4b4825 --- /dev/null +++ b/frontend/app/manifest.ts @@ -0,0 +1,22 @@ +import type { MetadataRoute } from "next" + +// PWA-manifest (ADR-028). Ikonene under er en enkel, midlertidig +// merkevareriktig placeholder (grønn golfflagg) generert programmatisk -- +// se FEATURE_BACKLOG.md, skal erstattes med ekte design senere. +export default function manifest(): MetadataRoute.Manifest { + return { + name: "TeeCup", + short_name: "TeeCup", + description: "Golfturnering i Ryder Cup-format — lag, scorekort og leaderboard.", + start_url: "/", + scope: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#8BC24A", + icons: [ + { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, + { src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + ], + } +} diff --git a/frontend/components/session-scorecard.tsx b/frontend/components/session-scorecard.tsx index 6f8fcf0..5f7cfe9 100644 --- a/frontend/components/session-scorecard.tsx +++ b/frontend/components/session-scorecard.tsx @@ -11,10 +11,13 @@ import { ChevronsUpDown, Minus, Plus, + RefreshCw, Trophy, + WifiOff, } from "lucide-react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" +import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" // --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/scoring.py/courses.py) @@ -87,6 +90,12 @@ type ApiScorecard = { // scramble) -- se app/routers/scoring.py sin is_individual-sjekk. type Unit = { id: string; side: "a" | "b"; label: string; matchParticipantId: string | null } +// Nøkkel for det lokale offline-overlayet (ADR-028) -- entydig per +// hull+side+enhet, samme identitet som backend bruker for upsert. +function strokeKey(holeNumber: number, unit: Unit): string { + return `${holeNumber}:${unit.side}:${unit.matchParticipantId ?? ""}` +} + function playedHoleNumbers(holeConfig: HoleConfig): number[] { if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1) if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10) @@ -118,6 +127,100 @@ export function SessionScorecard({ const [showConcede, setShowConcede] = useState(false) const [concedeTeamIndex, setConcedeTeamIndex] = useState<0 | 1 | null>(null) + // --- Offline scoreregistrering (ADR-028) ----------------------------- + // isOnline styrer kun banner-teksten (navigator.onLine er allerede + // sjekket direkte der det faktisk betyr noe, i submitStroke/ + // submitHoleResult) -- pendingStrokes/pendingResults er et lokalt + // overlay som viser verdier som er lagret i IndexedDB-køen, men ikke + // bekreftet mot serveren ennå. + const [isOnline, setIsOnline] = useState(true) + const [pendingCount, setPendingCount] = useState(0) + const [syncing, setSyncing] = useState(false) + const [pendingStrokes, setPendingStrokes] = useState>(new Map()) + const [pendingResults, setPendingResults] = useState>(new Map()) + + async function flushPending() { + if (syncing) return + setSyncing(true) + try { + const outcomes = await flushQueue(matchId) + if (outcomes.length === 0) return + setPendingStrokes((prev) => { + const next = new Map(prev) + for (const o of outcomes) { + if (o.entry.url.endsWith("/hole-scores")) { + const b = o.entry.body as { + hole_number: number + team_side: "a" | "b" + match_participant_id: string | null + } + next.delete(`${b.hole_number}:${b.team_side}:${b.match_participant_id ?? ""}`) + } + } + return next + }) + setPendingResults((prev) => { + const next = new Map(prev) + for (const o of outcomes) { + if (o.entry.url.endsWith("/hole-results")) { + const b = o.entry.body as { hole_number: number } + next.delete(b.hole_number) + } + } + return next + }) + const failed = outcomes.filter((o) => !o.ok) + if (failed.length > 0) { + setError( + `${failed.length} lagret ${failed.length === 1 ? "endring" : "endringer"} kunne ikke synkroniseres: ${failed[0].message}`, + ) + } + setPendingCount(await queueCount(matchId)) + await refetchScorecard() + } finally { + setSyncing(false) + } + } + + useEffect(() => { + setIsOnline(navigator.onLine) + function handleOnline() { + setIsOnline(true) + void flushPending() + } + 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 + }, [matchId]) + + async function queueStroke(holeNumber: number, unit: Unit, grossStrokes: number) { + const url = `/orgs/${organizationId}/matches/${matchId}/hole-scores` + const body = { + team_side: unit.side, + match_participant_id: unit.matchParticipantId, + hole_number: holeNumber, + gross_strokes: grossStrokes, + } + await enqueueWrite({ url, method: "POST", body, matchId }) + setPendingStrokes((prev) => new Map(prev).set(strokeKey(holeNumber, unit), grossStrokes)) + setPendingCount((c) => c + 1) + } + + async function queueHoleResult(holeNumber: number, winningSide: "a" | "b" | null) { + const url = `/orgs/${organizationId}/matches/${matchId}/hole-results` + const body = { hole_number: holeNumber, winning_side: winningSide } + await enqueueWrite({ url, method: "POST", body, matchId }) + setPendingResults((prev) => new Map(prev).set(holeNumber, winningSide)) + setPendingCount((c) => c + 1) + } + async function refetchScorecard() { const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { credentials: "include", @@ -161,6 +264,10 @@ export function SessionScorecard({ const played = playedHoleNumbers(foundSession.hole_config) const nextUnregistered = played[scorecardData.holes.length] ?? played[played.length - 1] setCurrentHole(nextUnregistered) + + const count = await queueCount(matchId).catch(() => 0) + if (!cancelled) setPendingCount(count) + if (count > 0 && navigator.onLine) void flushPending() } catch { if (!cancelled) setError("Klarte ikke å laste scorekortet. Prøv å laste siden på nytt.") } finally { @@ -194,8 +301,13 @@ export function SessionScorecard({ async function submitStroke(holeNumber: number, unit: Unit, grossStrokes: number) { setError(null) + if (!navigator.onLine) { + await queueStroke(holeNumber, unit, grossStrokes) + return + } + let res: Response try { - const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-scores`, { + res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-scores`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", @@ -206,11 +318,17 @@ export function SessionScorecard({ gross_strokes: grossStrokes, }), }) - if (!res.ok) throw new Error(`submit stroke: ${res.status}`) - await refetchScorecard() } catch { - setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.") + // Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet + // for å vise en feil, se ADR-028. + await queueStroke(holeNumber, unit, grossStrokes) + return } + if (!res.ok) { + setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.") + return + } + await refetchScorecard() } // Walkover/konsesjon (ADR-024): kun kaptein for laget som GIR SEG (eller @@ -240,18 +358,27 @@ export function SessionScorecard({ async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) { setError(null) + if (!navigator.onLine) { + await queueHoleResult(holeNumber, winningSide) + return + } + let res: Response try { - const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-results`, { + res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-results`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ hole_number: holeNumber, winning_side: winningSide }), }) - if (!res.ok) throw new Error(`submit result: ${res.status}`) - await refetchScorecard() } catch { - setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.") + await queueHoleResult(holeNumber, winningSide) + return } + if (!res.ok) { + setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.") + return + } + await refetchScorecard() } const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` @@ -297,11 +424,17 @@ export function SessionScorecard({ const played = playedHoleNumbers(session.hole_config) const totalHoles = played.length const par = holes.find((h) => h.hole_number === currentHole)?.par ?? 4 - const registeredHoleNumbers = new Set(scorecard.holes.map((h) => h.hole_number)) + const registeredHoleNumbers = new Set([ + ...scorecard.holes.map((h) => h.hole_number), + ...Array.from(pendingStrokes.keys(), (k) => Number(k.split(":")[0])), + ...Array.from(pendingResults.keys()), + ]) const statusLabel = scorecard.status_text ?? "AS" function strokeValue(holeNumber: number, unit: Unit): number | null { + const key = strokeKey(holeNumber, unit) + if (pendingStrokes.has(key)) return pendingStrokes.get(key)! const entry = (scorecard!.stroke_entries ?? []).find( (e) => e.hole_number === holeNumber && @@ -318,9 +451,9 @@ export function SessionScorecard({ void submitStroke(currentHole!, unit, next) } - const currentResultEntry = (scorecard.hole_result_entries ?? []).find( - (e) => e.hole_number === currentHole, - ) + const currentResultEntry = pendingResults.has(currentHole) + ? { hole_number: currentHole, winning_side: pendingResults.get(currentHole) ?? null } + : (scorecard.hole_result_entries ?? []).find((e) => e.hole_number === currentHole) function goPrev() { setCurrentHole((h) => { @@ -402,6 +535,30 @@ export function SessionScorecard({
+ {!isOnline && ( +
+
+ )} + {pendingCount > 0 && ( +
+ + {pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering. + + {isOnline && ( + + )} +
+ )} {error && (

{error} @@ -548,6 +705,7 @@ export function SessionScorecard({ {units.map((unit) => { const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b" const value = strokeValue(currentHole, unit) + const isPending = pendingStrokes.has(strokeKey(currentHole, unit)) return (

- {value === null ? "Ikke registrert" : scoreToParLabel(value, par)} + {value === null + ? "Ikke registrert" + : isPending + ? "Lagret lokalt · venter på synk" + : scoreToParLabel(value, par)}
+ + + diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..12e3cbe --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,76 @@ +// 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)), + ) + } +})