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

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

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

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

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

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

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

Forgejo: #15

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

View file

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

View file

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

View file

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

View file

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

View file

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