diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index bf4b274..465273b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -7,12 +7,10 @@ {"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-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":"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-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-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":"open","priority":3,"issue_type":"feature","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:51Z","created_by":"Ole-Morten Duesund","updated_at":"2026-04-29T13:56:51Z","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-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-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)","notes":"Utsatt på ubestemt tid (2026-08-17) etter beslutning fra eier. Ingen dato — gjenopptas kun på eksplisitt initiativ.","status":"deferred","priority":4,"issue_type":"task","owner":"olemd@glemt.net","created_at":"2026-04-29T13:56:40Z","created_by":"Ole-Morten Duesund","updated_at":"2026-08-17T11:39:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"memory","key":"produktbeslutning-2026-08-17-15-romnr-er-den","value":"Produktbeslutning (2026-08-17, #15): romnr er den autoritative tilfluktsrom-IDen — Room-PK, IndexedDB-keyPath, markørkart, DiffUtil og dyplenker bruker romnr. lokalId er kun informasjonsfelt (Geonorge regenererer den per eksport); aldri sammenlign eller nøkle på lokalId."} -{"_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."} +{"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} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b858b83..c31d5c6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,8 +67,8 @@ Geonorge ZIP (EPSG:25833 UTM33N) | Field | Description | |------------|--------------------------------------| -| `romnr` | Shelter room number — **authoritative ID / primary key** (see [Shelter identity](#shelter-identity--romnr-is-authoritative)) | -| `lokalId` | Upstream UUID, re-rolled on every export — informational only | +| `lokalId` | Unique identifier (UUID) | +| `romnr` | Shelter room number | | `plasser` | Capacity (number of people) | | `adresse` | Street address | | `latitude` | WGS84 latitude (converted from UTM) | @@ -135,7 +135,7 @@ OkHttp response stream → Per feature: → Extract UTM33N coordinates → CoordinateConverter.utm33nToWgs84() - → Validate: romnr > 0, plasser ≥ 0, within Norway bounds + → Validate: lokalId not blank, plasser ≥ 0, within Norway bounds → Create Shelter entity (skip malformed features with warning) ``` @@ -207,21 +207,19 @@ 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. -#### Shelter identity — `romnr` is authoritative +#### Deep link identifier — why `romnr`, not `lokalId` -**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. +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. **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: - 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 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. +- 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. `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. -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. +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. --- @@ -294,7 +292,7 @@ bun run build # TypeScript check + Vite build + SW generation **Storage:** IndexedDB via the `idb` library. **Schema:** -- Object store `shelters` (keyPath: `romnr`, DB v2) — full shelter records +- Object store `shelters` (keyPath: `lokalId`) — full shelter records - Object store `metadata` — `lastUpdate` timestamp **Loading strategy:** diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d0693d..fdf6f0e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "no.naiv.tilfluktsrom" minSdk = 26 targetSdk = 35 - versionCode = 19 - versionName = "1.11.1" + versionCode = 18 + versionName = "1.11.0" // Deep link domain — single source of truth for manifest + Kotlin code val deepLinkDomain = "tilfluktsrom.naiv.no" diff --git a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt index 6408ec8..b2feae9 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt @@ -86,11 +86,9 @@ class MainActivity : AppCompatActivity(), SensorEventListener { // guard, every location update re-checks hasCacheForLocation and re-shows // the prompt if the user previously chose "Skip". private var mapCachePromptPending = true - // Map from shelter romnr to its map marker, for icon swapping on selection. - // Keyed on romnr (not lokalId): the selection survives a data refresh - // that re-rolls every lokalId, so the highlight must too. - private var shelterMarkerMap: MutableMap = mutableMapOf() - private var highlightedMarkerRomnr: Int? = null + // Map from shelter lokalId to its map marker, for icon swapping on selection + private var shelterMarkerMap: MutableMap = mutableMapOf() + private var highlightedMarkerId: String? = null // Whether a compass sensor is available on this device private var hasCompassSensor = false @@ -101,7 +99,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener { // devices that fetched at different times have different lokalIds for // the same physical shelter, breaking cross-device share links. // Romnr is the actual DSB business key and is stable across exports. - // See ARCHITECTURE.md → "Shelter identity". + // See ARCHITECTURE.md → "Deep link identifier". private var pendingDeepLinkRomnr: Int? = null // The currently selected shelter — can be any shelter, not just one from nearestShelters @@ -526,12 +524,9 @@ class MainActivity : AppCompatActivity(), SensorEventListener { .map { ShelterListItem(it, isOutsideNearest = false) } .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 isSelectedAmongNearest = selected != null && - nearestShelters.any { it.shelter.romnr == selected.shelter.romnr } + nearestShelters.any { it.shelter.lokalId == selected.shelter.lokalId } if (selected != null && !isSelectedAmongNearest) { // Only flag as "outside nearest" when there *is* a nearest list to // contrast with - otherwise the selection is just the only entry. @@ -546,7 +541,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener { shelterAdapter.submitList(items) val selectedIdx = if (selected != null) { - items.indexOfFirst { it.swd.shelter.romnr == selected.shelter.romnr } + items.indexOfFirst { it.swd.shelter.lokalId == selected.shelter.lokalId } } else -1 shelterAdapter.selectPosition(selectedIdx) @@ -649,7 +644,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener { binding.directionArrow.setNorthAngle(-deviceHeading) // Emphasize the selected marker on the map - highlightSelectedMarker(selected.shelter.romnr) + highlightSelectedMarker(selected.shelter.lokalId) // Only auto-zoom the map if the user hasn't manually panned/zoomed if (!isCompassMode && !userHasInteractedWithMap) { @@ -658,20 +653,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener { } /** Swap marker icons so the selected shelter stands out. */ - private fun highlightSelectedMarker(romnr: Int) { - if (romnr == highlightedMarkerRomnr) return + private fun highlightSelectedMarker(lokalId: String) { + if (lokalId == highlightedMarkerId) return val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter) val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected) // Reset previous - highlightedMarkerRomnr?.let { prev -> - shelterMarkerMap[prev]?.icon = normalIcon + highlightedMarkerId?.let { prevId -> + shelterMarkerMap[prevId]?.icon = normalIcon } // Highlight new - shelterMarkerMap[romnr]?.icon = selectedIcon - highlightedMarkerRomnr = romnr + shelterMarkerMap[lokalId]?.icon = selectedIcon + highlightedMarkerId = lokalId binding.mapView.invalidate() } @@ -680,15 +675,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener { // Remove old markers shelterMarkerMap.values.forEach { binding.mapView.overlays.remove(it) } shelterMarkerMap.clear() - highlightedMarkerRomnr = null + highlightedMarkerId = null val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter) val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected) - val currentSelectedRomnr = selectedShelter?.shelter?.romnr + val currentSelectedId = selectedShelter?.shelter?.lokalId // Add markers for all shelters — tapping any marker selects it allShelters.forEach { shelter -> - val isSelected = shelter.romnr == currentSelectedRomnr + val isSelected = shelter.lokalId == currentSelectedId val marker = Marker(binding.mapView).apply { position = GeoPoint(shelter.latitude, shelter.longitude) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) @@ -701,8 +696,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener { true } } - if (isSelected) highlightedMarkerRomnr = shelter.romnr - shelterMarkerMap[shelter.romnr] = marker + if (isSelected) highlightedMarkerId = shelter.lokalId + shelterMarkerMap[shelter.lokalId] = marker binding.mapView.overlays.add(marker) } diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt index 616faa2..e3e9107 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt @@ -6,18 +6,12 @@ import androidx.room.PrimaryKey /** * A public shelter (offentlig tilfluktsrom). * 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") data class Shelter( @PrimaryKey - val romnr: Int, val lokalId: String, + val romnr: Int, val plasser: Int, val adresse: String, val latitude: Double, diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt index 1c4020b..8babea4 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt @@ -5,15 +5,7 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase -/** - * 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) +@Database(entities = [Shelter::class], version = 1, exportSchema = false) abstract class ShelterDatabase : RoomDatabase() { abstract fun shelterDao(): ShelterDao @@ -28,10 +20,7 @@ abstract class ShelterDatabase : RoomDatabase() { context.applicationContext, ShelterDatabase::class.java, "shelters.db" - ) - // See version history above: cache-only table, re-seeded on empty. - .fallbackToDestructiveMigration() - .build().also { INSTANCE = it } + ).build().also { INSTANCE = it } } } } diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt index 51e1572..1002912 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt @@ -91,16 +91,13 @@ object ShelterGeoJsonParser { continue } - // romnr is the primary key and the authoritative shelter ID — - // without it the row can't be identified or deep-linked. - // lokalId is informational only (re-rolled upstream, see Shelter). - val romnr = properties.optInt("romnr", 0) - if (romnr <= 0) { - Log.w(TAG, "Skipping shelter at index $i: missing romnr") + // Require a valid primary key — without it shelters can collide in the DB + val lokalId = properties.optString("lokalId", null) + if (lokalId.isNullOrBlank()) { + Log.w(TAG, "Skipping shelter at index $i: missing lokalId") skipped++ continue } - val lokalId = properties.optString("lokalId", "") val plasser = properties.optInt("plasser", 0) if (plasser < 0) { @@ -111,8 +108,8 @@ object ShelterGeoJsonParser { shelters.add( Shelter( - romnr = romnr, lokalId = lokalId, + romnr = properties.optInt("romnr", 0), plasser = plasser, adresse = properties.optString("adresse", ""), latitude = latLon.latitude, diff --git a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt index 28d6b7b..01c791e 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt @@ -46,7 +46,7 @@ class ShelterRepository(private val context: Context) { .readTimeout(60, TimeUnit.SECONDS) .addInterceptor(Interceptor { chain -> chain.proceed(chain.request().newBuilder() - .header("User-Agent", "Tilfluktsrom/1.11.1") + .header("User-Agent", "Tilfluktsrom/1.11.0") .build()) }) .build() @@ -152,15 +152,13 @@ class ShelterRepository(private val context: Context) { for (i in 0 until array.length()) { val obj = array.getJSONObject(i) - // Same rule as ShelterGeoJsonParser: romnr is the identity, lokalId - // is optional/informational. - val romnr = obj.optInt("romnr", 0) - if (romnr <= 0) continue + val lokalId: String? = obj.optString("lokalId", null) + if (lokalId.isNullOrBlank()) continue shelters.add( Shelter( - romnr = romnr, - lokalId = obj.optString("lokalId", ""), + lokalId = lokalId, + romnr = obj.optInt("romnr", 0), plasser = obj.optInt("plasser", 0), adresse = obj.optString("adresse", ""), latitude = obj.getDouble("latitude"), diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt index fb153ba..e33eb85 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/ShelterListAdapter.kt @@ -100,7 +100,7 @@ class ShelterListAdapter( companion object { private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(a: ShelterListItem, b: ShelterListItem) = - a.swd.shelter.romnr == b.swd.shelter.romnr + a.swd.shelter.lokalId == b.swd.shelter.lokalId override fun areContentsTheSame(a: ShelterListItem, b: ShelterListItem) = a == b diff --git a/fastlane/metadata/android/en-US/changelogs/19.txt b/fastlane/metadata/android/en-US/changelogs/19.txt deleted file mode 100644 index 4320a5a..0000000 --- a/fastlane/metadata/android/en-US/changelogs/19.txt +++ /dev/null @@ -1,2 +0,0 @@ -- Fixed: after "Refresh data" the selected shelter could appear twice in the list (with an "outside nearest" badge) and lose its map highlight -- Shelters are now identified by their DSB room number throughout the app; the local shelter cache is rebuilt automatically on first start diff --git a/fastlane/metadata/android/nb-NO/changelogs/19.txt b/fastlane/metadata/android/nb-NO/changelogs/19.txt deleted file mode 100644 index 1080625..0000000 --- a/fastlane/metadata/android/nb-NO/changelogs/19.txt +++ /dev/null @@ -1,2 +0,0 @@ -- Fikset: etter «Oppdater data» kunne valgt tilfluktsrom vises to ganger i lista (med «utenfor nærområdet»-merke) og miste markeringen i kartet -- Tilfluktsrom identifiseres nå med DSBs romnummer i hele appen; den lokale databufferen bygges automatisk opp på nytt ved første oppstart diff --git a/fastlane/metadata/android/nn-NO/changelogs/19.txt b/fastlane/metadata/android/nn-NO/changelogs/19.txt deleted file mode 100644 index 3b1d3dc..0000000 --- a/fastlane/metadata/android/nn-NO/changelogs/19.txt +++ /dev/null @@ -1,2 +0,0 @@ -- Fiksa: etter «Oppdater data» kunne valt tilfluktsrom visast to gonger i lista (med «utanfor nærområdet»-merke) og miste markeringa i kartet -- Tilfluktsrom vert no identifiserte med DSB sitt romnummer i heile appen; den lokale databufferen vert automatisk bygd opp att ved fyrste oppstart diff --git a/pwa/scripts/fetch-shelters.ts b/pwa/scripts/fetch-shelters.ts index a330970..df94a76 100644 --- a/pwa/scripts/fetch-shelters.ts +++ b/pwa/scripts/fetch-shelters.ts @@ -175,17 +175,9 @@ function parseGeoJson(json: string): Shelter[] { const northing = coords[1]; 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({ - romnr: props.romnr, - lokalId: props.lokalId ?? '', + lokalId: props.lokalId ?? `unknown-${i}`, + romnr: props.romnr ?? 0, plasser: props.plasser ?? 0, adresse: props.adresse ?? '', latitude: Math.round(latLon.latitude * 1e6) / 1e6, diff --git a/pwa/src/app.ts b/pwa/src/app.ts index 09afd8e..cc31fd6 100644 --- a/pwa/src/app.ts +++ b/pwa/src/app.ts @@ -45,7 +45,7 @@ let userSelectedShelter = false; // updates that recompute nearestShelters (deep links, marker taps to a // far-away shelter), and *also* survives a forceRefresh() that replaces // every lokalId in the dataset. Romnr is the stable upstream business key; -// see ARCHITECTURE.md → "Shelter identity" for rationale. +// see ARCHITECTURE.md → "Deep link identifier" for rationale. let selectedRomnr: number | null = null; export async function init(): Promise { diff --git a/pwa/src/data/shelter-db.ts b/pwa/src/data/shelter-db.ts index 5663fe4..03c4f92 100644 --- a/pwa/src/data/shelter-db.ts +++ b/pwa/src/data/shelter-db.ts @@ -7,12 +7,7 @@ import { openDB, type IDBPDatabase } from 'idb'; import type { Shelter } from '../types'; const DB_NAME = 'tilfluktsrom'; -// 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 DB_VERSION = 1; const SHELTER_STORE = 'shelters'; const META_STORE = 'metadata'; @@ -25,13 +20,9 @@ let dbPromise: Promise | null = null; function getDb(): Promise { if (!dbPromise) { dbPromise = openDB(DB_NAME, DB_VERSION, { - upgrade(db, oldVersion) { - if (oldVersion < 2 && db.objectStoreNames.contains(SHELTER_STORE)) { - // keyPath change (lokalId → romnr) requires recreating the store. - db.deleteObjectStore(SHELTER_STORE); - } + upgrade(db) { if (!db.objectStoreNames.contains(SHELTER_STORE)) { - db.createObjectStore(SHELTER_STORE, { keyPath: 'romnr' }); + db.createObjectStore(SHELTER_STORE, { keyPath: 'lokalId' }); } if (!db.objectStoreNames.contains(META_STORE)) { db.createObjectStore(META_STORE); diff --git a/pwa/src/types.ts b/pwa/src/types.ts index 2b08abe..0f0757a 100644 --- a/pwa/src/types.ts +++ b/pwa/src/types.ts @@ -1,13 +1,7 @@ -/** - * 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. - */ +/** A public shelter (tilfluktsrom) with WGS84 coordinates. */ export interface Shelter { - romnr: number; lokalId: string; + romnr: number; plasser: number; adresse: string; latitude: number; diff --git a/pwa/src/ui/map-view.ts b/pwa/src/ui/map-view.ts index e9ccc42..a7d99e1 100644 --- a/pwa/src/ui/map-view.ts +++ b/pwa/src/ui/map-view.ts @@ -49,7 +49,7 @@ const selectedIcon = L.divIcon({ let map: L.Map | null = null; let userMarker: L.CircleMarker | null = null; let shelterMarkers: L.Marker[] = []; -let selectedMarkerId: number | null = null; +let selectedMarkerId: string | null = null; // Track whether user has manually interacted with the map let userHasInteracted = false; @@ -135,10 +135,9 @@ export function updateShelterMarkers(shelters: Shelter[]): void { }); marker.addTo(map); - // Store shelter ID (romnr — stable across data refreshes) on the marker - // for highlighting. - (marker as L.Marker & { _shelterRomnr: number })._shelterRomnr = - shelter.romnr; + // Store shelter ID on the marker for highlighting + (marker as L.Marker & { _shelterLokalId: string })._shelterLokalId = + shelter.lokalId; shelterMarkers.push(marker); } } @@ -152,8 +151,8 @@ export function selectShelter( // Update marker icons for (const m of shelterMarkers) { - const mid = (m as L.Marker & { _shelterRomnr: number })._shelterRomnr; - if (mid === selected.shelter.romnr) { + const mid = (m as L.Marker & { _shelterLokalId: string })._shelterLokalId; + if (mid === selected.shelter.lokalId) { m.setIcon(selectedIcon); selectedMarkerId = mid; } else if (mid === selectedMarkerId || selectedMarkerId === null) {