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

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

View file

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

View file

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

View file

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

View file

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

View file

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