Gjør romnr til autoritativ tilfluktsrom-ID (Room-PK og IndexedDB-key)

Produktbeslutning (Forgejo #15): romnr — DSBs romnummer — er den
autoritative IDen. lokalId regenereres av Geonorge ved hver eksport og
beholdes bare som informasjonsfelt; det skal aldri sammenlignes eller
nøkles på. Utredningen mot Geonorge/DSB droppes.

Dette retter også en reell feil på Android: etter «Oppdater data» holdt
selectedShelter det gamle objektet med utdatert lokalId, så
rebuildShelterList() la til en duplikatrad med «utenfor nærområdet»-
badge for et rom som var blant de nærmeste, og markørhøylysingen mistet
målet (markørkartet var nøklet på nye lokalId-er).

Android:
- Shelter: @PrimaryKey romnr; lokalId vanlig kolonne
- ShelterDatabase: versjon 2 + fallbackToDestructiveMigration() — tabellen
  er ren cache som reseedes fra asset når den er tom
- Parsere (GeoJSON + bundled): krever romnr > 0, lokalId valgfri
- MainActivity: markørkart Map<Int, Marker>, høylysing og
  isSelectedAmongNearest/indexOfFirst på romnr
- ShelterListAdapter: DiffUtil areItemsTheSame på romnr

PWA:
- shelter-db.ts: DB_VERSION 2, keyPath 'romnr', upgrade sletter og
  gjenoppretter store ved oldVersion < 2
- map-view.ts: _shelterRomnr i stedet for _shelterLokalId
- types.ts: dokumentert identitet; fetch-shelters.ts hopper over
  features uten romnr (speiler Android-parseren)

ARCHITECTURE.md: nytt avsnitt «Shelter identity — romnr is authoritative»,
felt-tabell, valideringsregel og IndexedDB-skjema oppdatert.

Verifisert: emulator med v1.10.2-DB → migrering til v2 + reseed (556 rom);
velg rad 2 → Oppdater → ingen duplikatrad, valg og høylysing beholdt.
Playwright: seedet v1-IndexedDB → app oppgraderer til v2 (keyPath romnr,
556 poster, get(1681) treffer), dyplenke og markør OK.

Forgejo: #15

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrDGDmN2WAXvNPS85rWJGK
This commit is contained in:
Ole-Morten Duesund 2026-08-17 13:30:25 +02:00
commit c3ab73c915
13 changed files with 108 additions and 54 deletions

View file

@ -7,10 +7,11 @@
{"id":"tilfluktsrom-nt8","title":"Åpne i kartapp for gangvei til tilfluktsrom","description":"Mirror av Forgejo-issue #2.\n\nLegg til knapp (på bunnark og/eller kompassvisning) som åpner gangveibeskrivelse til valgt tilfluktsrom i ekstern kartapp.\n\nImplementasjon:\n- ACTION_VIEW intent med geo: URI: geo:lat,lon?q=lat,lon(Tilfluktsrom - adresse)\n- geo: håndteres av tilgjengelig kartapp (OsmAnd, Organic Maps, Google Maps, ...)\n- OsmAnd og Organic Maps støtter offline-navigasjon med geo: — ideelt for degradert nett\n- IKKE hardkode Google Maps-URL-er — bruk geo:\n- Faller pent tilbake hvis ingen kartapp er installert (Toast med koordinater å kopiere)\n- Knapp ved siden av tilfluktsrom-adresse i bunnarket\n\nI en akuttsituasjon er det å finne tilfluktsrommet på kartet bare halve problemet — du må vite gangveien dit. geo:-intent fungerer med offline-kapable kartapper, kritisk når nettet er nede.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/2","status":"closed","priority":2,"issue_type":"feature","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T13:57:03Z","created_by":"Ole-Morten Duesund","updated_at":"2026-08-17T11:00:14Z","started_at":"2026-08-17T10:54:02Z","closed_at":"2026-08-17T11:00:14Z","close_reason":"Implementert: navigateButton i bunnarket (ic_directions), openInMapApp() med geo:lat,lon?q=lat,lon(label) via ACTION_VIEW, Locale.US-formatering, klippebord+Toast-fallback ved ActivityNotFoundException. Verifisert på emulator: intent fyres og fanges av Maps.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-nt8","title":"Åpne i kartapp for gangvei til tilfluktsrom","description":"Mirror av Forgejo-issue #2.\n\nLegg til knapp (på bunnark og/eller kompassvisning) som åpner gangveibeskrivelse til valgt tilfluktsrom i ekstern kartapp.\n\nImplementasjon:\n- ACTION_VIEW intent med geo: URI: geo:lat,lon?q=lat,lon(Tilfluktsrom - adresse)\n- geo: håndteres av tilgjengelig kartapp (OsmAnd, Organic Maps, Google Maps, ...)\n- OsmAnd og Organic Maps støtter offline-navigasjon med geo: — ideelt for degradert nett\n- IKKE hardkode Google Maps-URL-er — bruk geo:\n- Faller pent tilbake hvis ingen kartapp er installert (Toast med koordinater å kopiere)\n- Knapp ved siden av tilfluktsrom-adresse i bunnarket\n\nI en akuttsituasjon er det å finne tilfluktsrommet på kartet bare halve problemet — du må vite gangveien dit. geo:-intent fungerer med offline-kapable kartapper, kritisk når nettet er nede.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/2","status":"closed","priority":2,"issue_type":"feature","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T13:57:03Z","created_by":"Ole-Morten Duesund","updated_at":"2026-08-17T11:00:14Z","started_at":"2026-08-17T10:54:02Z","closed_at":"2026-08-17T11:00:14Z","close_reason":"Implementert: navigateButton i bunnarket (ic_directions), openInMapApp() med geo:lat,lon?q=lat,lon(label) via ACTION_VIEW, Locale.US-formatering, klippebord+Toast-fallback ved ActivityNotFoundException. Verifisert på emulator: intent fyres og fanges av Maps.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"tilfluktsrom-gmu","title":"Test og ferdigstill PWA-versjonen","description":"Mirror av Forgejo-issue #7.\n\nFå den eksisterende PWA-en i pwa/-katalogen til å fungere og testet som webfallback.\n\nStatus:\n- Vite + TypeScript + Leaflet + idb + vite-plugin-pwa\n- Shelter-data forhåndsprosesseres ved bygg (scripts/fetch-shelters.ts)\n- Markert som ikke-testet i README (issue #1)\n\nOppgaver:\n- bun install + bun run dev — fikse byggefeil\n- Verifiser at bun run fetch-shelters genererer public/data/shelters.json\n- Test offline (service worker)\n- Test på mobilnettleser (iOS Safari, Android Chrome)\n- Deploy til statisk hosting\n- Lenke til PWA fra Android-appens om-side eller README\n\nWebfallback for iOS-brukere og folk uten Android-app. Også raskest tilgang i en akuttsituasjon.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/7","status":"closed","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:46Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:02:57Z","closed_at":"2026-04-29T14:02:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"tilfluktsrom-gmu","title":"Test og ferdigstill PWA-versjonen","description":"Mirror av Forgejo-issue #7.\n\nFå den eksisterende PWA-en i pwa/-katalogen til å fungere og testet som webfallback.\n\nStatus:\n- Vite + TypeScript + Leaflet + idb + vite-plugin-pwa\n- Shelter-data forhåndsprosesseres ved bygg (scripts/fetch-shelters.ts)\n- Markert som ikke-testet i README (issue #1)\n\nOppgaver:\n- bun install + bun run dev — fikse byggefeil\n- Verifiser at bun run fetch-shelters genererer public/data/shelters.json\n- Test offline (service worker)\n- Test på mobilnettleser (iOS Safari, Android Chrome)\n- Deploy til statisk hosting\n- Lenke til PWA fra Android-appens om-side eller README\n\nWebfallback for iOS-brukere og folk uten Android-app. Også raskest tilgang i en akuttsituasjon.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/7","status":"closed","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:46Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:02:57Z","closed_at":"2026-04-29T14:02:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"tilfluktsrom-9sf","title":"Dyplenket tilfluktsrom utenfor lista vises ikke","description":"Mirror av Forgejo-issue #13.\n\nNår en dyplenke åpner et tilfluktsrom som ikke er blant de 3 nærmeste, blir det valgt i kartet, men vises ikke i lista i bunnpanelet. Brukeren ser ikke hva som er valgt.\n\nForslag:\n1. Legg til det dyplenkede tilfluktsrommet som ekstra element i lista (med markering om at det ikke er blant de 3 nærmeste), eller\n2. Rull lista slik at det valgte elementet er synlig.\n\nIdentifisert i bruksanalyse. Moderat prioritet.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/13","status":"closed","priority":2,"issue_type":"bug","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:49:15Z","started_at":"2026-04-29T14:46:18Z","closed_at":"2026-04-29T14:49:15Z","close_reason":"Hybrid implementert: ShelterListItem-wrapper med isOutsideNearest-flagg, badge i item_shelter.xml, smoothScrollToPosition på rebuildShelterList. Visuell verifisering på enhet/emulator gjenstår.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-9sf","title":"Dyplenket tilfluktsrom utenfor lista vises ikke","description":"Mirror av Forgejo-issue #13.\n\nNår en dyplenke åpner et tilfluktsrom som ikke er blant de 3 nærmeste, blir det valgt i kartet, men vises ikke i lista i bunnpanelet. Brukeren ser ikke hva som er valgt.\n\nForslag:\n1. Legg til det dyplenkede tilfluktsrommet som ekstra element i lista (med markering om at det ikke er blant de 3 nærmeste), eller\n2. Rull lista slik at det valgte elementet er synlig.\n\nIdentifisert i bruksanalyse. Moderat prioritet.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/13","status":"closed","priority":2,"issue_type":"bug","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:15Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T14:49:15Z","started_at":"2026-04-29T14:46:18Z","closed_at":"2026-04-29T14:49:15Z","close_reason":"Hybrid implementert: ShelterListItem-wrapper med isOutsideNearest-flagg, badge i item_shelter.xml, smoothScrollToPosition på rebuildShelterList. Visuell verifisering på enhet/emulator gjenstår.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"tilfluktsrom-jmv","title":"Geonorge: lokalId regenereres på hver eksport — bytt til romnr som ekstern nøkkel","description":"Mirror av Forgejo-issue #15.\n\nTilfluktsromdata fra Geonorge regenererer lokalId-feltet ved hver eksport (verifisert: alle 556 lokalId-er endres mellom snapshots, mens romnr/plasser/adresse/koordinater er stabile).\n\nKonsekvens: delingslenker basert på lokalId ble brutt mellom datasett-oppdateringer. Vi har allerede byttet ekstern delingsidentifikator til romnr og beholdt lokalId som intern Room-PK.\n\nGjenstår:\n- Spørre Geonorge/DSB hvorfor lokalId regenereres (tilsiktet gml:id-stil eller FME/SOSI-feil?)\n- Hvis feil: be om at lokalId persisteres mellom eksporter\n- Hvis tilsiktet: be om dokumentasjon\n- Sjekke om WFS-endepunktet returnerer stabile ID-er\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/15","status":"deferred","priority":2,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:55:59Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-30T11:16:26Z","defer_until":"2026-10-30T00:00:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-jmv","title":"Geonorge: lokalId regenereres på hver eksport — bytt til romnr som ekstern nøkkel","description":"Mirror av Forgejo-issue #15.\n\nTilfluktsromdata fra Geonorge regenererer lokalId-feltet ved hver eksport (verifisert: alle 556 lokalId-er endres mellom snapshots, mens romnr/plasser/adresse/koordinater er stabile).\n\nKonsekvens: delingslenker basert på lokalId ble brutt mellom datasett-oppdateringer. Vi har allerede byttet ekstern delingsidentifikator til romnr og beholdt lokalId som intern Room-PK.\n\nGjenstår:\n- Spørre Geonorge/DSB hvorfor lokalId regenereres (tilsiktet gml:id-stil eller FME/SOSI-feil?)\n- Hvis feil: be om at lokalId persisteres mellom eksporter\n- Hvis tilsiktet: be om dokumentasjon\n- Sjekke om WFS-endepunktet returnerer stabile ID-er\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/15","status":"closed","priority":2,"issue_type":"task","assignee":"Ole-Morten Duesund","owner":"olemd@glemt.net","created_at":"2026-04-29T13:55:59Z","created_by":"Ole-Morten Duesund","updated_at":"2026-08-17T11:30:16Z","started_at":"2026-08-17T11:25:00Z","closed_at":"2026-08-17T11:30:16Z","close_reason":"Beslutning 2026-08-17: romnr er den autoritative tilfluktsrom-IDen. Utredning mot Geonorge/DSB droppes. Koden er justert: romnr som Room-PK (v2, destruktiv migrering) og IndexedDB-keyPath (v2), alle identitetssammenligninger/markørkart på romnr, parsere krever romnr. Retter også duplikatrad + tapt markørhøylysing etter «Oppdater data» på Android. Verifisert på emulator og i Playwright.","defer_until":"2026-10-30T00:00:00Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"tilfluktsrom-jvn","title":"Filtrere tilfluktsrom etter minimumskapasitet","description":"Mirror av Forgejo-issue #5.\n\nLegg til filter for minimumskapasitet slik at brukere kan finne tilfluktsrom store nok for gruppen sin.\n\nImplementasjon:\n- Filterchip eller dropdown over tilfluktsromlista (f.eks. \"Min. plasser: 50 / 100 / 200 / Alle\")\n- Filteret gjelder både nærmeste-lista og kartmarkørene\n- Lagre valg i SharedPreferences\n- Default: vis alle (intet filter)\n\nSkoler, arbeidsplasser og familier trenger tilfluktsrom med nok kapasitet. Et lite tilfluktsrom med 20 plasser er ubrukelig for en gruppe på 50. Enkel UX-forbedring med reell praktisk verdi.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/5","status":"closed","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:51Z","created_by":"Ole-Morten Duesund","updated_at":"2026-08-17T11:20:06Z","closed_at":"2026-08-17T11:20:06Z","close_reason":"Wontfix (besluttet 2026-08-17): kapasitetstallene fra Geonorge er ikke sikre nok til å ha denne typen verdi, og i en krisesituasjon er det uansett umulig å vite hvor mange ledige plasser det er. Et filter ville gi falsk presisjon.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-jvn","title":"Filtrere tilfluktsrom etter minimumskapasitet","description":"Mirror av Forgejo-issue #5.\n\nLegg til filter for minimumskapasitet slik at brukere kan finne tilfluktsrom store nok for gruppen sin.\n\nImplementasjon:\n- Filterchip eller dropdown over tilfluktsromlista (f.eks. \"Min. plasser: 50 / 100 / 200 / Alle\")\n- Filteret gjelder både nærmeste-lista og kartmarkørene\n- Lagre valg i SharedPreferences\n- Default: vis alle (intet filter)\n\nSkoler, arbeidsplasser og familier trenger tilfluktsrom med nok kapasitet. Et lite tilfluktsrom med 20 plasser er ubrukelig for en gruppe på 50. Enkel UX-forbedring med reell praktisk verdi.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/5","status":"closed","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:51Z","created_by":"Ole-Morten Duesund","updated_at":"2026-08-17T11:20:06Z","closed_at":"2026-08-17T11:20:06Z","close_reason":"Wontfix (besluttet 2026-08-17): kapasitetstallene fra Geonorge er ikke sikre nok til å ha denne typen verdi, og i en krisesituasjon er det uansett umulig å vite hvor mange ledige plasser det er. Et filter ville gi falsk presisjon.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"tilfluktsrom-nyz","title":"Støtte for internasjonale tilfluktsromdata","description":"Mirror av Forgejo-issue #9.\n\nI dag støtter Tilfluktsrom kun norske data fra Geonorge (GeoJSON, EPSG:25833). Mål: identifisere og integrere data fra andre land.\n\nMål:\n1. Identifisere internasjonale datakilder (NO, SE/MSB, FI/Pelastustoimi, CH/FOCP, SG/SCDF, US/FEMA)\n2. Støtte flere dataformater uten å bryte eksisterende funksjonalitet\n3. Auto-nedlasting basert på brukerens posisjon\n\nTekniske vurderinger:\n- ShelterDataSource-grensesnitt med per-land-implementasjoner\n- Parsefeil i én kilde må aldri ødelegge andre kilder (isolert per kilde, valider per record)\n- Generalisere Shelter-modellen (kjernefelt: koordinater WGS84, kapasitet, adresse, kildeland)\n- Bbox-basert dataset-registry, last bare ned relevante datasett\n- Offline-first beholdes — alle nedlastede datasett caches i Room\n\nOut of scope: brukerbidratte lokasjoner, sanntidsstatus, ruting.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/9 (3 kommentarer)","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:34Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:34Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-nyz","title":"Støtte for internasjonale tilfluktsromdata","description":"Mirror av Forgejo-issue #9.\n\nI dag støtter Tilfluktsrom kun norske data fra Geonorge (GeoJSON, EPSG:25833). Mål: identifisere og integrere data fra andre land.\n\nMål:\n1. Identifisere internasjonale datakilder (NO, SE/MSB, FI/Pelastustoimi, CH/FOCP, SG/SCDF, US/FEMA)\n2. Støtte flere dataformater uten å bryte eksisterende funksjonalitet\n3. Auto-nedlasting basert på brukerens posisjon\n\nTekniske vurderinger:\n- ShelterDataSource-grensesnitt med per-land-implementasjoner\n- Parsefeil i én kilde må aldri ødelegge andre kilder (isolert per kilde, valider per record)\n- Generalisere Shelter-modellen (kjernefelt: koordinater WGS84, kapasitet, adresse, kildeland)\n- Bbox-basert dataset-registry, last bare ned relevante datasett\n- Offline-first beholdes — alle nedlastede datasett caches i Room\n\nOut of scope: brukerbidratte lokasjoner, sanntidsstatus, ruting.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/9 (3 kommentarer)","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:34Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:34Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"tilfluktsrom-bnw","title":"Klyngevisning for kartmarkører","description":"Mirror av Forgejo-issue #10.\n\nNår mange tilfluktsrom ligger tett i kartet, overlapper markørene og det er vanskelig å trykke på riktig en.\n\nForslag: legg til klyngevisning (marker clustering) som grupperer nærliggende markører og viser et tall. Når brukeren zoomer inn, splittes klyngene.\n\nAlternativer:\n- Android: OSMBonusPack MarkerClusterer, eller egen logikk\n- PWA: Leaflet.markercluster-plugin\n\nLavere prioritet enn tilgjengelighetsforbedringer.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/10","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:23Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-bnw","title":"Klyngevisning for kartmarkører","description":"Mirror av Forgejo-issue #10.\n\nNår mange tilfluktsrom ligger tett i kartet, overlapper markørene og det er vanskelig å trykke på riktig en.\n\nForslag: legg til klyngevisning (marker clustering) som grupperer nærliggende markører og viser et tall. Når brukeren zoomer inn, splittes klyngene.\n\nAlternativer:\n- Android: OSMBonusPack MarkerClusterer, eller egen logikk\n- PWA: Leaflet.markercluster-plugin\n\nLavere prioritet enn tilgjengelighetsforbedringer.\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/10","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:23Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:23Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"tilfluktsrom-k5i","title":"PWA: legg til sivilforsvarsinformasjons-dialog","description":"Mirror av Forgejo-issue #12.\n\nPWA-versjonen mangler sivilforsvarsinformasjonsdialogen som finnes i Android-appen (CivilDefenseInfoDialog).\n\nPort dialogen til PWA-en med samme innhold (5 steg + DSB-kilde). Vis som modal/overlay.\n\nAvhengighet: bør gjøres etter #7 (test og ferdigstill PWA).\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/12","status":"closed","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-08-17T10:51:37Z","closed_at":"2026-08-17T10:51:37Z","close_reason":"Allerede implementert i 7d40c9e (civil-defense-dialog.ts, 5 steg + DSB-kilde, modal med a11y). Forgejo #12 lukket.","dependencies":[{"issue_id":"tilfluktsrom-k5i","depends_on_id":"tilfluktsrom-gmu","type":"blocks","created_at":"2026-04-29T16:01:22Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-k5i","title":"PWA: legg til sivilforsvarsinformasjons-dialog","description":"Mirror av Forgejo-issue #12.\n\nPWA-versjonen mangler sivilforsvarsinformasjonsdialogen som finnes i Android-appen (CivilDefenseInfoDialog).\n\nPort dialogen til PWA-en med samme innhold (5 steg + DSB-kilde). Vis som modal/overlay.\n\nAvhengighet: bør gjøres etter #7 (test og ferdigstill PWA).\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/12","status":"closed","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:19Z","created_by":"Ole-Morten Duesund","updated_at":"2026-08-17T10:51:37Z","closed_at":"2026-08-17T10:51:37Z","close_reason":"Allerede implementert i 7d40c9e (civil-defense-dialog.ts, 5 steg + DSB-kilde, modal med a11y). Forgejo #12 lukket.","dependencies":[{"issue_id":"tilfluktsrom-k5i","depends_on_id":"tilfluktsrom-gmu","type":"blocks","created_at":"2026-04-29T16:01:22Z","created_by":"Ole-Morten Duesund","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"tilfluktsrom-52s","title":"Migrere UI fra Views/ViewBinding til Jetpack Compose","description":"Mirror av Forgejo-issue #14.\n\nMigrere fra tradisjonelle Android Views med ViewBinding til Jetpack Compose.\n\nOmfang:\n- activity_main.xml → rot-Composable med tilstandsheving\n- RecyclerView + ShelterListAdapter → LazyColumn\n- DirectionArrowView (Canvas) → Compose Canvas\n- Bunnark → Card / BottomSheetScaffold\n- dialog_civil_defense.xml → AlertDialog composable\n- Lasteoverlegg → AnimatedVisibility\n- Innfør MainViewModel\n\nForblir Views: OSMDroid MapView (pakkes i AndroidView), Widget (RemoteViews, eventuell Glance-migrering er separat).\n\nVurderinger:\n- APK +2-3 MB (Compose runtime)\n- @Preview gir bedre dev-loop for ikke-kart-komponenter\n- Stort tiltak — ikke kritisk\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/14","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:10Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-52s","title":"Migrere UI fra Views/ViewBinding til Jetpack Compose","description":"Mirror av Forgejo-issue #14.\n\nMigrere fra tradisjonelle Android Views med ViewBinding til Jetpack Compose.\n\nOmfang:\n- activity_main.xml → rot-Composable med tilstandsheving\n- RecyclerView + ShelterListAdapter → LazyColumn\n- DirectionArrowView (Canvas) → Compose Canvas\n- Bunnark → Card / BottomSheetScaffold\n- dialog_civil_defense.xml → AlertDialog composable\n- Lasteoverlegg → AnimatedVisibility\n- Innfør MainViewModel\n\nForblir Views: OSMDroid MapView (pakkes i AndroidView), Widget (RemoteViews, eventuell Glance-migrering er separat).\n\nVurderinger:\n- APK +2-3 MB (Compose runtime)\n- @Preview gir bedre dev-loop for ikke-kart-komponenter\n- Stort tiltak — ikke kritisk\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/14","status":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:10Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:10Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"tilfluktsrom-bok","title":"Forberede F-Droid-metadata for innsending","description":"Mirror av Forgejo-issue #8.\n\nNB: F-Droid-distribusjon er pauset (jf. memory feedback_fdroid_paused.md). Skal IKKE jobbes på før brukeren gjenopptar F-Droid-spor.\n\nForberede repoet for F-Droid-innsending med fastlane metadata-struktur:\n- fastlane/metadata/android/-katalog\n- en-US/ og nb-NO/ med full_description, short_description, title, changelogs/\n- images/ med skjermbilder og feature graphic\n- Dokumenter at appen bruker play-services-location men faller tilbake (anti-features)\n- Vurdere .fdroid.yml hvis spesielle byggesteg trengs\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/8 (1 kommentar)","status":"open","priority":4,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:40Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:40Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"tilfluktsrom-bok","title":"Forberede F-Droid-metadata for innsending","description":"Mirror av Forgejo-issue #8.\n\nNB: F-Droid-distribusjon er pauset (jf. memory feedback_fdroid_paused.md). Skal IKKE jobbes på før brukeren gjenopptar F-Droid-spor.\n\nForberede repoet for F-Droid-innsending med fastlane metadata-struktur:\n- fastlane/metadata/android/-katalog\n- en-US/ og nb-NO/ med full_description, short_description, title, changelogs/\n- images/ med skjermbilder og feature graphic\n- Dokumenter at appen bruker play-services-location men faller tilbake (anti-features)\n- Vurdere .fdroid.yml hvis spesielle byggesteg trengs\n\nForgejo: https://kode.naiv.no/olemd/tilfluktsrom/issues/8 (1 kommentar)","status":"open","priority":4,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:40Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:40Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"produktbeslutning-2026-08-17-ikke-bygg-funksjoner-som","value":"Produktbeslutning (2026-08-17): ikke bygg funksjoner som behandler kapasitetstall (plasser) som presise — Geonorge-tallene er usikre, og i en krise er ledige plasser uansett ukjent. Kapasitetsfilter (#5/jvn) lukket som wontfix. Vis kapasitet som informasjon, ikke som filter/sortering."}

View file

@ -67,8 +67,8 @@ Geonorge ZIP (EPSG:25833 UTM33N)
| Field | Description | | Field | Description |
|------------|--------------------------------------| |------------|--------------------------------------|
| `lokalId` | Unique identifier (UUID) | | `romnr` | Shelter room number — **authoritative ID / primary key** (see [Shelter identity](#shelter-identity--romnr-is-authoritative)) |
| `romnr` | Shelter room number | | `lokalId` | Upstream UUID, re-rolled on every export — informational only |
| `plasser` | Capacity (number of people) | | `plasser` | Capacity (number of people) |
| `adresse` | Street address | | `adresse` | Street address |
| `latitude` | WGS84 latitude (converted from UTM) | | `latitude` | WGS84 latitude (converted from UTM) |
@ -135,7 +135,7 @@ OkHttp response stream
→ Per feature: → Per feature:
→ Extract UTM33N coordinates → Extract UTM33N coordinates
→ CoordinateConverter.utm33nToWgs84() → CoordinateConverter.utm33nToWgs84()
→ Validate: lokalId not blank, plasser ≥ 0, within Norway bounds → Validate: romnr > 0, plasser ≥ 0, within Norway bounds
→ Create Shelter entity (skip malformed features with warning) → Create Shelter entity (skip malformed features with warning)
``` ```
@ -207,19 +207,21 @@ The domain is configured in one place: `DEEP_LINK_DOMAIN` in `build.gradle.kts`
Share messages include the HTTPS URL, which SMS apps auto-link as a tappable URL. Share messages include the HTTPS URL, which SMS apps auto-link as a tappable URL.
#### Deep link identifier — why `romnr`, not `lokalId` #### Shelter identity — `romnr` is authoritative
The path component is the shelter's `romnr` (DSB room number — an integer like `776`), not the GeoJSON `lokalId` UUID, even though `lokalId` is what Room and Leaflet use as the in-memory primary key. **Decision (2026-08-17, Forgejo #15):** `romnr` — DSB's room number — is the authoritative shelter identifier everywhere: Room primary key, IndexedDB `keyPath`, marker maps, list diffing, selection equality and the deep-link path. `lokalId` is stored as informational data only and must never be compared or keyed on. Records without a positive `romnr` are skipped by both parsers.
The deep-link path component is therefore `romnr` (an integer like `776`), not the GeoJSON `lokalId` UUID.
**Empirical reason:** the upstream GeoJSON ZIP at `nedlasting.geonorge.no/.../TilfluktsromOffentlige_GeoJSON.zip` re-rolls every `lokalId` on every export. Three snapshots of the same dataset (taken Dec 2025, Apr 20 2026, Apr 27 2026) had **556/556 different lokalIds** while every other field (`romnr`, `adresse`, `plasser`, `latitude`, `longitude`) was byte-identical and the shelter count was unchanged. The most recent two snapshots are only seven days apart, so this is regular drift, not a one-off re-issue. **Empirical reason:** the upstream GeoJSON ZIP at `nedlasting.geonorge.no/.../TilfluktsromOffentlige_GeoJSON.zip` re-rolls every `lokalId` on every export. Three snapshots of the same dataset (taken Dec 2025, Apr 20 2026, Apr 27 2026) had **556/556 different lokalIds** while every other field (`romnr`, `adresse`, `plasser`, `latitude`, `longitude`) was byte-identical and the shelter count was unchanged. The most recent two snapshots are only seven days apart, so this is regular drift, not a one-off re-issue.
That makes `lokalId` unsuitable for any cross-device or cross-build identifier: That makes `lokalId` unsuitable for any cross-device or cross-build identifier:
- Sender and receiver who fetched the dataset on different days have different lokalIds for the same physical shelter, so a lokalId-keyed share link fails with "shelter not found" on the receiving side. - Sender and receiver who fetched the dataset on different days have different lokalIds for the same physical shelter, so a lokalId-keyed share link fails with "shelter not found" on the receiving side.
- Even on a single device, a user who hits "Refresh data" while a shelter is selected would lose the selection if it was tracked by lokalId. - Even on a single device, a user who hits "Refresh data" while a shelter is selected keeps the *object* but its `lokalId` no longer matches anything in the new dataset — before the switch this produced a duplicate "outside nearest" list row and a lost marker highlight on Android.
`romnr` is the actual DSB business key — the room number physically assigned to the shelter by the civil-defence authority — and is stable across exports. Verified unique (556/556) and present (no zeros) on the current dataset. `romnr` is the actual DSB business key — the room number physically assigned to the shelter by the civil-defence authority — and is stable across exports. Verified unique (556/556) and present (no zeros) on the current dataset.
The internal Room primary key remains `lokalId` because (a) it's already the upstream-supplied UUID and changing it would force a destructive Room schema migration, and (b) within a single fetch it's a perfectly fine in-memory key. Only the *external* deep-link identifier was switched. Room schema v2 and IndexedDB v2 switched the primary key to `romnr` (destructive migration in both — the tables are pure caches re-seeded from the bundled asset / `data/shelters.json` when empty). The weekly romnr-stability routine (`scripts/romnr-baseline.json`) remains the safety net: it diffs each Geonorge export against the baseline and flags any romnr that disappears, appears or moves.
--- ---
@ -292,7 +294,7 @@ bun run build # TypeScript check + Vite build + SW generation
**Storage:** IndexedDB via the `idb` library. **Storage:** IndexedDB via the `idb` library.
**Schema:** **Schema:**
- Object store `shelters` (keyPath: `lokalId`) — full shelter records - Object store `shelters` (keyPath: `romnr`, DB v2) — full shelter records
- Object store `metadata``lastUpdate` timestamp - Object store `metadata``lastUpdate` timestamp
**Loading strategy:** **Loading strategy:**

View file

@ -86,9 +86,11 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// guard, every location update re-checks hasCacheForLocation and re-shows // guard, every location update re-checks hasCacheForLocation and re-shows
// the prompt if the user previously chose "Skip". // the prompt if the user previously chose "Skip".
private var mapCachePromptPending = true private var mapCachePromptPending = true
// Map from shelter lokalId to its map marker, for icon swapping on selection // Map from shelter romnr to its map marker, for icon swapping on selection.
private var shelterMarkerMap: MutableMap<String, Marker> = mutableMapOf() // Keyed on romnr (not lokalId): the selection survives a data refresh
private var highlightedMarkerId: String? = null // that re-rolls every lokalId, so the highlight must too.
private var shelterMarkerMap: MutableMap<Int, Marker> = mutableMapOf()
private var highlightedMarkerRomnr: Int? = null
// Whether a compass sensor is available on this device // Whether a compass sensor is available on this device
private var hasCompassSensor = false private var hasCompassSensor = false
@ -99,7 +101,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// devices that fetched at different times have different lokalIds for // devices that fetched at different times have different lokalIds for
// the same physical shelter, breaking cross-device share links. // the same physical shelter, breaking cross-device share links.
// Romnr is the actual DSB business key and is stable across exports. // Romnr is the actual DSB business key and is stable across exports.
// See ARCHITECTURE.md → "Deep link identifier". // See ARCHITECTURE.md → "Shelter identity".
private var pendingDeepLinkRomnr: Int? = null private var pendingDeepLinkRomnr: Int? = null
// The currently selected shelter — can be any shelter, not just one from nearestShelters // The currently selected shelter — can be any shelter, not just one from nearestShelters
@ -524,9 +526,12 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
.map { ShelterListItem(it, isOutsideNearest = false) } .map { ShelterListItem(it, isOutsideNearest = false) }
.toMutableList() .toMutableList()
// Compare by romnr: after a data refresh selectedShelter still holds
// the pre-refresh object, whose lokalId no longer matches anything —
// comparing lokalIds would append a duplicate "outside nearest" row.
val selected = selectedShelter val selected = selectedShelter
val isSelectedAmongNearest = selected != null && val isSelectedAmongNearest = selected != null &&
nearestShelters.any { it.shelter.lokalId == selected.shelter.lokalId } nearestShelters.any { it.shelter.romnr == selected.shelter.romnr }
if (selected != null && !isSelectedAmongNearest) { if (selected != null && !isSelectedAmongNearest) {
// Only flag as "outside nearest" when there *is* a nearest list to // Only flag as "outside nearest" when there *is* a nearest list to
// contrast with - otherwise the selection is just the only entry. // contrast with - otherwise the selection is just the only entry.
@ -541,7 +546,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
shelterAdapter.submitList(items) shelterAdapter.submitList(items)
val selectedIdx = if (selected != null) { val selectedIdx = if (selected != null) {
items.indexOfFirst { it.swd.shelter.lokalId == selected.shelter.lokalId } items.indexOfFirst { it.swd.shelter.romnr == selected.shelter.romnr }
} else -1 } else -1
shelterAdapter.selectPosition(selectedIdx) shelterAdapter.selectPosition(selectedIdx)
@ -644,7 +649,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
binding.directionArrow.setNorthAngle(-deviceHeading) binding.directionArrow.setNorthAngle(-deviceHeading)
// Emphasize the selected marker on the map // Emphasize the selected marker on the map
highlightSelectedMarker(selected.shelter.lokalId) highlightSelectedMarker(selected.shelter.romnr)
// Only auto-zoom the map if the user hasn't manually panned/zoomed // Only auto-zoom the map if the user hasn't manually panned/zoomed
if (!isCompassMode && !userHasInteractedWithMap) { if (!isCompassMode && !userHasInteractedWithMap) {
@ -653,20 +658,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
} }
/** Swap marker icons so the selected shelter stands out. */ /** Swap marker icons so the selected shelter stands out. */
private fun highlightSelectedMarker(lokalId: String) { private fun highlightSelectedMarker(romnr: Int) {
if (lokalId == highlightedMarkerId) return if (romnr == highlightedMarkerRomnr) return
val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter) val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter)
val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected) val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected)
// Reset previous // Reset previous
highlightedMarkerId?.let { prevId -> highlightedMarkerRomnr?.let { prev ->
shelterMarkerMap[prevId]?.icon = normalIcon shelterMarkerMap[prev]?.icon = normalIcon
} }
// Highlight new // Highlight new
shelterMarkerMap[lokalId]?.icon = selectedIcon shelterMarkerMap[romnr]?.icon = selectedIcon
highlightedMarkerId = lokalId highlightedMarkerRomnr = romnr
binding.mapView.invalidate() binding.mapView.invalidate()
} }
@ -675,15 +680,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
// Remove old markers // Remove old markers
shelterMarkerMap.values.forEach { binding.mapView.overlays.remove(it) } shelterMarkerMap.values.forEach { binding.mapView.overlays.remove(it) }
shelterMarkerMap.clear() shelterMarkerMap.clear()
highlightedMarkerId = null highlightedMarkerRomnr = null
val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter) val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter)
val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected) val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected)
val currentSelectedId = selectedShelter?.shelter?.lokalId val currentSelectedRomnr = selectedShelter?.shelter?.romnr
// Add markers for all shelters — tapping any marker selects it // Add markers for all shelters — tapping any marker selects it
allShelters.forEach { shelter -> allShelters.forEach { shelter ->
val isSelected = shelter.lokalId == currentSelectedId val isSelected = shelter.romnr == currentSelectedRomnr
val marker = Marker(binding.mapView).apply { val marker = Marker(binding.mapView).apply {
position = GeoPoint(shelter.latitude, shelter.longitude) position = GeoPoint(shelter.latitude, shelter.longitude)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
@ -696,8 +701,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener {
true true
} }
} }
if (isSelected) highlightedMarkerId = shelter.lokalId if (isSelected) highlightedMarkerRomnr = shelter.romnr
shelterMarkerMap[shelter.lokalId] = marker shelterMarkerMap[shelter.romnr] = marker
binding.mapView.overlays.add(marker) binding.mapView.overlays.add(marker)
} }

View file

@ -6,12 +6,18 @@ import androidx.room.PrimaryKey
/** /**
* A public shelter (offentlig tilfluktsrom). * A public shelter (offentlig tilfluktsrom).
* Coordinates are stored in WGS84 (EPSG:4326) after conversion from UTM33N. * Coordinates are stored in WGS84 (EPSG:4326) after conversion from UTM33N.
*
* Identity is [romnr] DSB's room number, the authoritative shelter ID
* (product decision 2026-08-17, Forgejo #15). The upstream Geonorge export
* re-rolls [lokalId] on every publication, so it is kept only as an
* informational field and must never be used for equality, map keys or
* persistence. See ARCHITECTURE.md "Shelter identity".
*/ */
@Entity(tableName = "shelters") @Entity(tableName = "shelters")
data class Shelter( data class Shelter(
@PrimaryKey @PrimaryKey
val lokalId: String,
val romnr: Int, val romnr: Int,
val lokalId: String,
val plasser: Int, val plasser: Int,
val adresse: String, val adresse: String,
val latitude: Double, val latitude: Double,

View file

@ -5,7 +5,15 @@ import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@Database(entities = [Shelter::class], version = 1, exportSchema = false) /**
* Version history:
* 1 primary key lokalId (upstream UUID)
* 2 primary key romnr (Forgejo #15). The table is a pure cache that is
* re-seeded from the bundled asset (and refreshed from Geonorge) whenever
* it is empty, so a destructive migration is safe and simpler than a
* hand-written one.
*/
@Database(entities = [Shelter::class], version = 2, exportSchema = false)
abstract class ShelterDatabase : RoomDatabase() { abstract class ShelterDatabase : RoomDatabase() {
abstract fun shelterDao(): ShelterDao abstract fun shelterDao(): ShelterDao
@ -20,7 +28,10 @@ abstract class ShelterDatabase : RoomDatabase() {
context.applicationContext, context.applicationContext,
ShelterDatabase::class.java, ShelterDatabase::class.java,
"shelters.db" "shelters.db"
).build().also { INSTANCE = it } )
// See version history above: cache-only table, re-seeded on empty.
.fallbackToDestructiveMigration()
.build().also { INSTANCE = it }
} }
} }
} }

View file

@ -91,13 +91,16 @@ object ShelterGeoJsonParser {
continue continue
} }
// Require a valid primary key — without it shelters can collide in the DB // romnr is the primary key and the authoritative shelter ID —
val lokalId = properties.optString("lokalId", null) // without it the row can't be identified or deep-linked.
if (lokalId.isNullOrBlank()) { // lokalId is informational only (re-rolled upstream, see Shelter).
Log.w(TAG, "Skipping shelter at index $i: missing lokalId") val romnr = properties.optInt("romnr", 0)
if (romnr <= 0) {
Log.w(TAG, "Skipping shelter at index $i: missing romnr")
skipped++ skipped++
continue continue
} }
val lokalId = properties.optString("lokalId", "")
val plasser = properties.optInt("plasser", 0) val plasser = properties.optInt("plasser", 0)
if (plasser < 0) { if (plasser < 0) {
@ -108,8 +111,8 @@ object ShelterGeoJsonParser {
shelters.add( shelters.add(
Shelter( Shelter(
romnr = romnr,
lokalId = lokalId, lokalId = lokalId,
romnr = properties.optInt("romnr", 0),
plasser = plasser, plasser = plasser,
adresse = properties.optString("adresse", ""), adresse = properties.optString("adresse", ""),
latitude = latLon.latitude, latitude = latLon.latitude,

View file

@ -152,13 +152,15 @@ class ShelterRepository(private val context: Context) {
for (i in 0 until array.length()) { for (i in 0 until array.length()) {
val obj = array.getJSONObject(i) val obj = array.getJSONObject(i)
val lokalId: String? = obj.optString("lokalId", null) // Same rule as ShelterGeoJsonParser: romnr is the identity, lokalId
if (lokalId.isNullOrBlank()) continue // is optional/informational.
val romnr = obj.optInt("romnr", 0)
if (romnr <= 0) continue
shelters.add( shelters.add(
Shelter( Shelter(
lokalId = lokalId, romnr = romnr,
romnr = obj.optInt("romnr", 0), lokalId = obj.optString("lokalId", ""),
plasser = obj.optInt("plasser", 0), plasser = obj.optInt("plasser", 0),
adresse = obj.optString("adresse", ""), adresse = obj.optString("adresse", ""),
latitude = obj.getDouble("latitude"), latitude = obj.getDouble("latitude"),

View file

@ -100,7 +100,7 @@ class ShelterListAdapter(
companion object { companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ShelterListItem>() { private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ShelterListItem>() {
override fun areItemsTheSame(a: ShelterListItem, b: ShelterListItem) = override fun areItemsTheSame(a: ShelterListItem, b: ShelterListItem) =
a.swd.shelter.lokalId == b.swd.shelter.lokalId a.swd.shelter.romnr == b.swd.shelter.romnr
override fun areContentsTheSame(a: ShelterListItem, b: ShelterListItem) = override fun areContentsTheSame(a: ShelterListItem, b: ShelterListItem) =
a == b a == b

View file

@ -175,9 +175,17 @@ function parseGeoJson(json: string): Shelter[] {
const northing = coords[1]; const northing = coords[1];
const latLon = utm33nToWgs84(easting, northing); const latLon = utm33nToWgs84(easting, northing);
// romnr is the primary key / authoritative ID — a record without it
// cannot be identified or deep-linked, so skip it (mirrors the Android
// ShelterGeoJsonParser rule). lokalId is informational only.
if (!props.romnr || props.romnr <= 0) {
console.warn(`Skipping feature ${i}: missing romnr`);
continue;
}
shelters.push({ shelters.push({
lokalId: props.lokalId ?? `unknown-${i}`, romnr: props.romnr,
romnr: props.romnr ?? 0, lokalId: props.lokalId ?? '',
plasser: props.plasser ?? 0, plasser: props.plasser ?? 0,
adresse: props.adresse ?? '', adresse: props.adresse ?? '',
latitude: Math.round(latLon.latitude * 1e6) / 1e6, latitude: Math.round(latLon.latitude * 1e6) / 1e6,

View file

@ -45,7 +45,7 @@ let userSelectedShelter = false;
// updates that recompute nearestShelters (deep links, marker taps to a // updates that recompute nearestShelters (deep links, marker taps to a
// far-away shelter), and *also* survives a forceRefresh() that replaces // far-away shelter), and *also* survives a forceRefresh() that replaces
// every lokalId in the dataset. Romnr is the stable upstream business key; // every lokalId in the dataset. Romnr is the stable upstream business key;
// see ARCHITECTURE.md → "Deep link identifier" for rationale. // see ARCHITECTURE.md → "Shelter identity" for rationale.
let selectedRomnr: number | null = null; let selectedRomnr: number | null = null;
export async function init(): Promise<void> { export async function init(): Promise<void> {

View file

@ -7,7 +7,12 @@ import { openDB, type IDBPDatabase } from 'idb';
import type { Shelter } from '../types'; import type { Shelter } from '../types';
const DB_NAME = 'tilfluktsrom'; const DB_NAME = 'tilfluktsrom';
const DB_VERSION = 1; // v1: shelters keyed on lokalId (upstream UUID)
// v2: shelters keyed on romnr — DSB's room number is the authoritative
// shelter ID (Forgejo #15); upstream re-rolls lokalId on every export.
// The store is a pure cache re-fetched from /data/shelters.json when
// empty, so the upgrade simply drops and recreates it.
const DB_VERSION = 2;
const SHELTER_STORE = 'shelters'; const SHELTER_STORE = 'shelters';
const META_STORE = 'metadata'; const META_STORE = 'metadata';
@ -20,9 +25,13 @@ let dbPromise: Promise<TilfluktsromDB> | null = null;
function getDb(): Promise<TilfluktsromDB> { function getDb(): Promise<TilfluktsromDB> {
if (!dbPromise) { if (!dbPromise) {
dbPromise = openDB(DB_NAME, DB_VERSION, { dbPromise = openDB(DB_NAME, DB_VERSION, {
upgrade(db) { upgrade(db, oldVersion) {
if (oldVersion < 2 && db.objectStoreNames.contains(SHELTER_STORE)) {
// keyPath change (lokalId → romnr) requires recreating the store.
db.deleteObjectStore(SHELTER_STORE);
}
if (!db.objectStoreNames.contains(SHELTER_STORE)) { if (!db.objectStoreNames.contains(SHELTER_STORE)) {
db.createObjectStore(SHELTER_STORE, { keyPath: 'lokalId' }); db.createObjectStore(SHELTER_STORE, { keyPath: 'romnr' });
} }
if (!db.objectStoreNames.contains(META_STORE)) { if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE); db.createObjectStore(META_STORE);

View file

@ -1,7 +1,13 @@
/** A public shelter (tilfluktsrom) with WGS84 coordinates. */ /**
* A public shelter (tilfluktsrom) with WGS84 coordinates.
*
* Identity is `romnr` DSB's room number, the authoritative shelter ID
* (Forgejo #15). `lokalId` is re-rolled by Geonorge on every export and is
* kept only as informational data; never compare or key on it.
*/
export interface Shelter { export interface Shelter {
lokalId: string;
romnr: number; romnr: number;
lokalId: string;
plasser: number; plasser: number;
adresse: string; adresse: string;
latitude: number; latitude: number;

View file

@ -49,7 +49,7 @@ const selectedIcon = L.divIcon({
let map: L.Map | null = null; let map: L.Map | null = null;
let userMarker: L.CircleMarker | null = null; let userMarker: L.CircleMarker | null = null;
let shelterMarkers: L.Marker[] = []; let shelterMarkers: L.Marker[] = [];
let selectedMarkerId: string | null = null; let selectedMarkerId: number | null = null;
// Track whether user has manually interacted with the map // Track whether user has manually interacted with the map
let userHasInteracted = false; let userHasInteracted = false;
@ -135,9 +135,10 @@ export function updateShelterMarkers(shelters: Shelter[]): void {
}); });
marker.addTo(map); marker.addTo(map);
// Store shelter ID on the marker for highlighting // Store shelter ID (romnr — stable across data refreshes) on the marker
(marker as L.Marker & { _shelterLokalId: string })._shelterLokalId = // for highlighting.
shelter.lokalId; (marker as L.Marker & { _shelterRomnr: number })._shelterRomnr =
shelter.romnr;
shelterMarkers.push(marker); shelterMarkers.push(marker);
} }
} }
@ -151,8 +152,8 @@ export function selectShelter(
// Update marker icons // Update marker icons
for (const m of shelterMarkers) { for (const m of shelterMarkers) {
const mid = (m as L.Marker & { _shelterLokalId: string })._shelterLokalId; const mid = (m as L.Marker & { _shelterRomnr: number })._shelterRomnr;
if (mid === selected.shelter.lokalId) { if (mid === selected.shelter.romnr) {
m.setIcon(selectedIcon); m.setIcon(selectedIcon);
selectedMarkerId = mid; selectedMarkerId = mid;
} else if (mid === selectedMarkerId || selectedMarkerId === null) { } else if (mid === selectedMarkerId || selectedMarkerId === null) {