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:
Erol Haagenrud 2026-07-19 23:51:08 +02:00
parent 24af937fe0
commit a32b8027f3
15 changed files with 770 additions and 22 deletions

View file

@ -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",

View file

@ -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:

View file

@ -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

View file

@ -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`).
---

View file

@ -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 (
<html lang="no" className={`${nunito.variable} bg-background`}>
<body className="font-sans antialiased">{children}</body>
<body className="font-sans antialiased">
<SwRegister />
{children}
</body>
</html>
)
}

22
frontend/app/manifest.ts Normal file
View 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" },
],
}
}

View file

@ -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<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() {
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({
</header>
<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 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"} 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
</button>
)}
</div>
)}
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{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 (
<div
key={unit.id}
@ -582,7 +740,11 @@ export function SessionScorecard({
{value === null ? "" : value}
</span>
<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>
</div>
<button
@ -644,6 +806,11 @@ export function SessionScorecard({
)
})}
</div>
{pendingResults.has(currentHole) && (
<p className="mt-2 text-xs font-medium text-muted-foreground">
Lagret lokalt · venter synk
</p>
)}
</section>
)}

View 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
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View 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
View 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)),
)
}
})