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.
This commit is contained in:
parent
24af937fe0
commit
a32b8027f3
15 changed files with 770 additions and 22 deletions
|
|
@ -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/matches.py'\\).read\\(\\)\\)\")",
|
||||||
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/scoring.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(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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
|
|
@ -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å)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
87
CLAUDE.md
87
CLAUDE.md
|
|
@ -1670,18 +1670,95 @@ Ferdig og verifisert:
|
||||||
(ukjent turnering-id, ingen ekte data berørt) ga korrekt 404 fra selve
|
(ukjent turnering-id, ingen ekte data berørt) ga korrekt 404 fra selve
|
||||||
applikasjonslaget.
|
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åndskrevet `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:
|
Neste steg:
|
||||||
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
1. PWA er bygget, venter på utrullingsbekreftelse — se over. ANBEFAL
|
||||||
2. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
|
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
|
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
|
||||||
(bevisst utsatt i ADR-025).
|
(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å.
|
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
|
ADR-025) ligger uncommitted i det SEPARATE `/opt/teeoff`-repoet, ikke i
|
||||||
`teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring,
|
`teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring,
|
||||||
lett å glemme siden denne økten ellers kun har jobbet i `/opt/teecup`.
|
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
|
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
|
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
|
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR
|
||||||
|
|
|
||||||
|
|
@ -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)
|
## UX / frontend (senere fase)
|
||||||
|
|
||||||
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp
|
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp
|
||||||
(banebruk i sollys/med solbriller).
|
(banebruk i sollys/med solbriller).
|
||||||
- ✅ Offline-first (ADR-006) — prinsipp besluttet; implementasjon senere.
|
- ✅ Offline-first (ADR-006) — BYGGET 2026-07-19 (ADR-028), se eget punkt
|
||||||
- 📋 PWA: manifest, service workers, «Legg til på hjemskjerm».
|
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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import { Nunito } from 'next/font/google'
|
import { Nunito } from 'next/font/google'
|
||||||
|
import { SwRegister } from '@/components/sw-register'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const nunito = Nunito({
|
const nunito = Nunito({
|
||||||
|
|
@ -33,6 +34,14 @@ export const metadata: Metadata = {
|
||||||
],
|
],
|
||||||
apple: '/apple-icon.png',
|
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 = {
|
export const viewport: Viewport = {
|
||||||
|
|
@ -50,7 +59,10 @@ export default function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="no" className={`${nunito.variable} bg-background`}>
|
<html lang="no" className={`${nunito.variable} bg-background`}>
|
||||||
<body className="font-sans antialiased">{children}</body>
|
<body className="font-sans antialiased">
|
||||||
|
<SwRegister />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
frontend/app/manifest.ts
Normal file
22
frontend/app/manifest.ts
Normal file
|
|
@ -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" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,13 @@ import {
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Minus,
|
Minus,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
WifiOff,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
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)
|
// --- 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.
|
// scramble) -- se app/routers/scoring.py sin is_individual-sjekk.
|
||||||
type Unit = { id: string; side: "a" | "b"; label: string; matchParticipantId: string | null }
|
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[] {
|
function playedHoleNumbers(holeConfig: HoleConfig): number[] {
|
||||||
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
||||||
if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
|
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 [showConcede, setShowConcede] = useState(false)
|
||||||
const [concedeTeamIndex, setConcedeTeamIndex] = useState<0 | 1 | null>(null)
|
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<Map<string, number>>(new Map())
|
||||||
|
const [pendingResults, setPendingResults] = useState<Map<number, "a" | "b" | null>>(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() {
|
async function refetchScorecard() {
|
||||||
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, {
|
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
|
@ -161,6 +264,10 @@ export function SessionScorecard({
|
||||||
const played = playedHoleNumbers(foundSession.hole_config)
|
const played = playedHoleNumbers(foundSession.hole_config)
|
||||||
const nextUnregistered = played[scorecardData.holes.length] ?? played[played.length - 1]
|
const nextUnregistered = played[scorecardData.holes.length] ?? played[played.length - 1]
|
||||||
setCurrentHole(nextUnregistered)
|
setCurrentHole(nextUnregistered)
|
||||||
|
|
||||||
|
const count = await queueCount(matchId).catch(() => 0)
|
||||||
|
if (!cancelled) setPendingCount(count)
|
||||||
|
if (count > 0 && navigator.onLine) void flushPending()
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("Klarte ikke å laste scorekortet. Prøv å laste siden på nytt.")
|
if (!cancelled) setError("Klarte ikke å laste scorekortet. Prøv å laste siden på nytt.")
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -194,8 +301,13 @@ export function SessionScorecard({
|
||||||
|
|
||||||
async function submitStroke(holeNumber: number, unit: Unit, grossStrokes: number) {
|
async function submitStroke(holeNumber: number, unit: Unit, grossStrokes: number) {
|
||||||
setError(null)
|
setError(null)
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
await queueStroke(holeNumber, unit, grossStrokes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let res: Response
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-scores`, {
|
res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-scores`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
|
@ -206,11 +318,17 @@ export function SessionScorecard({
|
||||||
gross_strokes: grossStrokes,
|
gross_strokes: grossStrokes,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`submit stroke: ${res.status}`)
|
|
||||||
await refetchScorecard()
|
|
||||||
} catch {
|
} 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
|
// 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) {
|
async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
|
||||||
setError(null)
|
setError(null)
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
await queueHoleResult(holeNumber, winningSide)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let res: Response
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-results`, {
|
res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-results`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ hole_number: holeNumber, winning_side: winningSide }),
|
body: JSON.stringify({ hole_number: holeNumber, winning_side: winningSide }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`submit result: ${res.status}`)
|
|
||||||
await refetchScorecard()
|
|
||||||
} catch {
|
} 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)}`
|
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
|
@ -297,11 +424,17 @@ export function SessionScorecard({
|
||||||
const played = playedHoleNumbers(session.hole_config)
|
const played = playedHoleNumbers(session.hole_config)
|
||||||
const totalHoles = played.length
|
const totalHoles = played.length
|
||||||
const par = holes.find((h) => h.hole_number === currentHole)?.par ?? 4
|
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"
|
const statusLabel = scorecard.status_text ?? "AS"
|
||||||
|
|
||||||
function strokeValue(holeNumber: number, unit: Unit): number | null {
|
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(
|
const entry = (scorecard!.stroke_entries ?? []).find(
|
||||||
(e) =>
|
(e) =>
|
||||||
e.hole_number === holeNumber &&
|
e.hole_number === holeNumber &&
|
||||||
|
|
@ -318,9 +451,9 @@ export function SessionScorecard({
|
||||||
void submitStroke(currentHole!, unit, next)
|
void submitStroke(currentHole!, unit, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentResultEntry = (scorecard.hole_result_entries ?? []).find(
|
const currentResultEntry = pendingResults.has(currentHole)
|
||||||
(e) => e.hole_number === currentHole,
|
? { hole_number: currentHole, winning_side: pendingResults.get(currentHole) ?? null }
|
||||||
)
|
: (scorecard.hole_result_entries ?? []).find((e) => e.hole_number === currentHole)
|
||||||
|
|
||||||
function goPrev() {
|
function goPrev() {
|
||||||
setCurrentHole((h) => {
|
setCurrentHole((h) => {
|
||||||
|
|
@ -402,6 +535,30 @@ export function SessionScorecard({
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
{!isOnline && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||||||
|
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span>Du er offline. Endringer lagres lokalt og sendes automatisk når du er tilbake på nett.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
|
||||||
|
<span>
|
||||||
|
{pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering.
|
||||||
|
</span>
|
||||||
|
{isOnline && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void flushPending()}
|
||||||
|
disabled={syncing}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncing && "animate-spin")} />
|
||||||
|
Synkroniser nå
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||||
{error}
|
{error}
|
||||||
|
|
@ -548,6 +705,7 @@ export function SessionScorecard({
|
||||||
{units.map((unit) => {
|
{units.map((unit) => {
|
||||||
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
|
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
|
||||||
const value = strokeValue(currentHole, unit)
|
const value = strokeValue(currentHole, unit)
|
||||||
|
const isPending = pendingStrokes.has(strokeKey(currentHole, unit))
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={unit.id}
|
key={unit.id}
|
||||||
|
|
@ -582,7 +740,11 @@ export function SessionScorecard({
|
||||||
{value === null ? "–" : value}
|
{value === null ? "–" : value}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-xs font-medium text-muted-foreground">
|
<span className="mt-1 text-xs font-medium text-muted-foreground">
|
||||||
{value === null ? "Ikke registrert" : scoreToParLabel(value, par)}
|
{value === null
|
||||||
|
? "Ikke registrert"
|
||||||
|
: isPending
|
||||||
|
? "Lagret lokalt · venter på synk"
|
||||||
|
: scoreToParLabel(value, par)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -644,6 +806,11 @@ export function SessionScorecard({
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{pendingResults.has(currentHole) && (
|
||||||
|
<p className="mt-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Lagret lokalt · venter på synk
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
14
frontend/components/sw-register.tsx
Normal file
14
frontend/components/sw-register.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
// Registrerer service workeren (ADR-028). Stille no-op i nettlesere uten
|
||||||
|
// støtte -- appen skal fungere helt uendret uten SW, bare uten
|
||||||
|
// installasjon/offline-cache.
|
||||||
|
export function SwRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!("serviceWorker" in navigator)) return
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {})
|
||||||
|
}, [])
|
||||||
|
return null
|
||||||
|
}
|
||||||
108
frontend/lib/offline-queue.ts
Normal file
108
frontend/lib/offline-queue.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// 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[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(entry.url, {
|
||||||
|
method: entry.method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(entry.body),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
break // fortsatt offline -- stopp, resten prøves igjen senere
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
await removeFromQueue(entry.id)
|
||||||
|
outcomes.push({ entry, ok: true })
|
||||||
|
} else {
|
||||||
|
const body: { detail?: { message?: string } } | null = await res.json().catch(() => null)
|
||||||
|
await removeFromQueue(entry.id)
|
||||||
|
outcomes.push({ entry, ok: false, message: body?.detail?.message ?? `Feil ${res.status}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcomes
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.3 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
65
frontend/public/offline.html
Normal file
65
frontend/public/offline.html
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="no">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Ingen nettforbindelse — TeeCup</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1c261d;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html, body { background: #1c261d; color: #f2f7ee; }
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #5a9c2f;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.25rem; margin: 0; }
|
||||||
|
p { max-width: 28rem; line-height: 1.5; opacity: 0.75; margin: 0; }
|
||||||
|
button {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #5a9c2f;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="icon" aria-hidden="true">⛳</div>
|
||||||
|
<h1>Ingen nettforbindelse</h1>
|
||||||
|
<p>
|
||||||
|
Denne siden er ikke tilgjengelig offline ennå. Har du åpnet et scorekort
|
||||||
|
tidligere, kan du fortsette å registrere score der — det lagres lokalt
|
||||||
|
og sendes automatisk når du er tilbake på nett.
|
||||||
|
</p>
|
||||||
|
<button onclick="location.reload()">Prøv igjen</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
76
frontend/public/sw.js
Normal file
76
frontend/public/sw.js
Normal file
|
|
@ -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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue