diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0c52392..0979143 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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-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":"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-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)","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."} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c31d5c6..b858b83 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,8 +67,8 @@ Geonorge ZIP (EPSG:25833 UTM33N) | Field | Description | |------------|--------------------------------------| -| `lokalId` | Unique identifier (UUID) | -| `romnr` | Shelter room number | +| `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 | | `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: lokalId not blank, plasser ≥ 0, within Norway bounds + → Validate: romnr > 0, plasser ≥ 0, within Norway bounds → 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. -#### 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. 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 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. -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. **Schema:** -- Object store `shelters` (keyPath: `lokalId`) — full shelter records +- Object store `shelters` (keyPath: `romnr`, DB v2) — full shelter records - Object store `metadata` — `lastUpdate` timestamp **Loading strategy:** diff --git a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt index b2feae9..6408ec8 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/MainActivity.kt @@ -86,9 +86,11 @@ 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 lokalId to its map marker, for icon swapping on selection - private var shelterMarkerMap: MutableMap = mutableMapOf() - private var highlightedMarkerId: String? = null + // 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 // Whether a compass sensor is available on this device private var hasCompassSensor = false @@ -99,7 +101,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 → "Deep link identifier". + // See ARCHITECTURE.md → "Shelter identity". private var pendingDeepLinkRomnr: Int? = null // 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) } .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.lokalId == selected.shelter.lokalId } + nearestShelters.any { it.shelter.romnr == selected.shelter.romnr } 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. @@ -541,7 +546,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener { shelterAdapter.submitList(items) 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 shelterAdapter.selectPosition(selectedIdx) @@ -644,7 +649,7 @@ class MainActivity : AppCompatActivity(), SensorEventListener { binding.directionArrow.setNorthAngle(-deviceHeading) // 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 if (!isCompassMode && !userHasInteractedWithMap) { @@ -653,20 +658,20 @@ class MainActivity : AppCompatActivity(), SensorEventListener { } /** Swap marker icons so the selected shelter stands out. */ - private fun highlightSelectedMarker(lokalId: String) { - if (lokalId == highlightedMarkerId) return + private fun highlightSelectedMarker(romnr: Int) { + if (romnr == highlightedMarkerRomnr) return val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter) val selectedIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter_selected) // Reset previous - highlightedMarkerId?.let { prevId -> - shelterMarkerMap[prevId]?.icon = normalIcon + highlightedMarkerRomnr?.let { prev -> + shelterMarkerMap[prev]?.icon = normalIcon } // Highlight new - shelterMarkerMap[lokalId]?.icon = selectedIcon - highlightedMarkerId = lokalId + shelterMarkerMap[romnr]?.icon = selectedIcon + highlightedMarkerRomnr = romnr binding.mapView.invalidate() } @@ -675,15 +680,15 @@ class MainActivity : AppCompatActivity(), SensorEventListener { // Remove old markers shelterMarkerMap.values.forEach { binding.mapView.overlays.remove(it) } shelterMarkerMap.clear() - highlightedMarkerId = null + highlightedMarkerRomnr = null val normalIcon = ContextCompat.getDrawable(this, R.drawable.ic_shelter) 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 allShelters.forEach { shelter -> - val isSelected = shelter.lokalId == currentSelectedId + val isSelected = shelter.romnr == currentSelectedRomnr val marker = Marker(binding.mapView).apply { position = GeoPoint(shelter.latitude, shelter.longitude) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) @@ -696,8 +701,8 @@ class MainActivity : AppCompatActivity(), SensorEventListener { true } } - if (isSelected) highlightedMarkerId = shelter.lokalId - shelterMarkerMap[shelter.lokalId] = marker + if (isSelected) highlightedMarkerRomnr = shelter.romnr + shelterMarkerMap[shelter.romnr] = 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 e3e9107..616faa2 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/Shelter.kt @@ -6,12 +6,18 @@ 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 lokalId: String, val romnr: Int, + val lokalId: String, 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 8babea4..1c4020b 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterDatabase.kt @@ -5,7 +5,15 @@ import androidx.room.Database import androidx.room.Room 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 fun shelterDao(): ShelterDao @@ -20,7 +28,10 @@ abstract class ShelterDatabase : RoomDatabase() { context.applicationContext, ShelterDatabase::class.java, "shelters.db" - ).build().also { INSTANCE = it } + ) + // See version history above: cache-only table, re-seeded on empty. + .fallbackToDestructiveMigration() + .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 1002912..51e1572 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterGeoJsonParser.kt @@ -91,13 +91,16 @@ object ShelterGeoJsonParser { continue } - // 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") + // 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") skipped++ continue } + val lokalId = properties.optString("lokalId", "") val plasser = properties.optInt("plasser", 0) if (plasser < 0) { @@ -108,8 +111,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 01c791e..7b92c7c 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/data/ShelterRepository.kt @@ -152,13 +152,15 @@ class ShelterRepository(private val context: Context) { for (i in 0 until array.length()) { val obj = array.getJSONObject(i) - val lokalId: String? = obj.optString("lokalId", null) - if (lokalId.isNullOrBlank()) continue + // Same rule as ShelterGeoJsonParser: romnr is the identity, lokalId + // is optional/informational. + val romnr = obj.optInt("romnr", 0) + if (romnr <= 0) continue shelters.add( Shelter( - lokalId = lokalId, - romnr = obj.optInt("romnr", 0), + romnr = romnr, + lokalId = obj.optString("lokalId", ""), 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 e33eb85..fb153ba 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.lokalId == b.swd.shelter.lokalId + a.swd.shelter.romnr == b.swd.shelter.romnr override fun areContentsTheSame(a: ShelterListItem, b: ShelterListItem) = a == b diff --git a/pwa/scripts/fetch-shelters.ts b/pwa/scripts/fetch-shelters.ts index df94a76..a330970 100644 --- a/pwa/scripts/fetch-shelters.ts +++ b/pwa/scripts/fetch-shelters.ts @@ -175,9 +175,17 @@ 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({ - lokalId: props.lokalId ?? `unknown-${i}`, - romnr: props.romnr ?? 0, + romnr: props.romnr, + lokalId: props.lokalId ?? '', 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 cc31fd6..09afd8e 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 → "Deep link identifier" for rationale. +// see ARCHITECTURE.md → "Shelter identity" 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 03c4f92..5663fe4 100644 --- a/pwa/src/data/shelter-db.ts +++ b/pwa/src/data/shelter-db.ts @@ -7,7 +7,12 @@ import { openDB, type IDBPDatabase } from 'idb'; import type { Shelter } from '../types'; 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 META_STORE = 'metadata'; @@ -20,9 +25,13 @@ let dbPromise: Promise | null = null; function getDb(): Promise { if (!dbPromise) { 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)) { - db.createObjectStore(SHELTER_STORE, { keyPath: 'lokalId' }); + db.createObjectStore(SHELTER_STORE, { keyPath: 'romnr' }); } if (!db.objectStoreNames.contains(META_STORE)) { db.createObjectStore(META_STORE); diff --git a/pwa/src/types.ts b/pwa/src/types.ts index 0f0757a..2b08abe 100644 --- a/pwa/src/types.ts +++ b/pwa/src/types.ts @@ -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 { - lokalId: string; romnr: number; + lokalId: string; plasser: number; adresse: string; latitude: number; diff --git a/pwa/src/ui/map-view.ts b/pwa/src/ui/map-view.ts index a7d99e1..e9ccc42 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: string | null = null; +let selectedMarkerId: number | null = null; // Track whether user has manually interacted with the map let userHasInteracted = false; @@ -135,9 +135,10 @@ export function updateShelterMarkers(shelters: Shelter[]): void { }); marker.addTo(map); - // Store shelter ID on the marker for highlighting - (marker as L.Marker & { _shelterLokalId: string })._shelterLokalId = - shelter.lokalId; + // Store shelter ID (romnr — stable across data refreshes) on the marker + // for highlighting. + (marker as L.Marker & { _shelterRomnr: number })._shelterRomnr = + shelter.romnr; shelterMarkers.push(marker); } } @@ -151,8 +152,8 @@ export function selectShelter( // Update marker icons for (const m of shelterMarkers) { - const mid = (m as L.Marker & { _shelterLokalId: string })._shelterLokalId; - if (mid === selected.shelter.lokalId) { + const mid = (m as L.Marker & { _shelterRomnr: number })._shelterRomnr; + if (mid === selected.shelter.romnr) { m.setIcon(selectedIcon); selectedMarkerId = mid; } else if (mid === selectedMarkerId || selectedMarkerId === null) {