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:
parent
5b463f0ca6
commit
c3ab73c915
13 changed files with 108 additions and 54 deletions
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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<String, Marker> = 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<Int, Marker> = 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ class ShelterListAdapter(
|
|||
companion object {
|
||||
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<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) =
|
||||
a == b
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
|
|||
|
|
@ -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<TilfluktsromDB> | null = null;
|
|||
function getDb(): Promise<TilfluktsromDB> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue