diff --git a/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift b/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift index 162b41619b..29fce03c47 100644 --- a/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift +++ b/Packages/StrandImport/Sources/StrandImport/LabMarkerCsvImport.swift @@ -45,9 +45,12 @@ public struct LabMarkerCsvRow: Sendable, Equatable { /// column, source-attributed). nil for the generic (date,marker,value,unit) importer, which /// never annotates. Maps to `LabMarker.note`. public var note: String? + /// Historical Android key derived from this input name; import-time repair metadata only. + // Kotlin twin: `LabMarkerCsvRow.legacyMarkerKey`. + public var legacyMarkerKey: String? public init(markerKey: String, category: LabMarkerCategory, day: String, - value: Double, unit: String, isCustomMarker: Bool, note: String? = nil) { + value: Double, unit: String, isCustomMarker: Bool, note: String? = nil, legacyMarkerKey: String? = nil) { self.markerKey = markerKey self.category = category self.day = day @@ -55,6 +58,7 @@ public struct LabMarkerCsvRow: Sendable, Equatable { self.unit = unit self.isCustomMarker = isCustomMarker self.note = note + self.legacyMarkerKey = legacyMarkerKey } } @@ -94,6 +98,23 @@ public struct LabMarkerCsvResult: Sendable, Equatable { self.notMeasured = notMeasured } + /// Only unambiguous aliases from names in this file may repair a stored legacy key. + // Kotlin twin: `LabMarkerCsvResult.legacyKeyChanges`. + public var legacyKeyChanges: [String: String] { + let currentKeys = Set(rows.map(\.markerKey)) + let candidates = rows.compactMap { row -> (String, String)? in + guard row.isCustomMarker, let old = row.legacyMarkerKey, !old.isEmpty, + old != row.markerKey, !currentKeys.contains(old) else { return nil } + return (old, row.markerKey) + } + var changes: [String: String] = [:] + for (old, aliases) in Dictionary(grouping: candidates, by: { $0.0 }) { + let targets = Set(aliases.map { $0.1 }) + if targets.count == 1 { changes[old] = targets.first } + } + return changes + } + /// Number of readings imported. public var importedReadings: Int { rows.count } /// Number of distinct markers the readings cover. @@ -203,9 +224,11 @@ public enum LabMarkerCsvImport { unit = rawUnit ?? "" customKeys.insert(key) } + let legacy = legacyCustomKey(rawName) store(&byCell, LabMarkerCsvRow(markerKey: key, category: category, day: day, value: value, unit: unit, - isCustomMarker: MarkerCatalog.definition(for: key) == nil)) + isCustomMarker: MarkerCatalog.definition(for: key) == nil, + legacyMarkerKey: MarkerCatalog.definition(for: key) == nil && !legacy.isEmpty && legacy != key ? legacy : nil)) } let rows = byCell.values.sorted { @@ -374,6 +397,38 @@ public enum LabMarkerCsvImport { return "custom_" + trimmed } + /// Historical Android UTF-16 letter/decimal-digit key, used only while a reimport still has the marker name. + /// An empty result cannot identify any legacy history and must never be re-keyed. + // Kotlin twin: `LabMarkerCsvImport.legacyCustomKey`. + static func legacyCustomKey(_ name: String) -> String { + let lowered = Array(name.precomposedStringWithCanonicalMapping + .trimmingCharacters(in: .whitespacesAndNewlines).lowercased().utf16) + // Kotlin twin: `LabMarkerCsvImport.isLegacyWordUnit`. + func isLegacyWordUnit(_ unit: UInt16) -> Bool { + guard let scalar = UnicodeScalar(UInt32(unit)) else { return false } + switch scalar.properties.generalCategory { + case .uppercaseLetter, .lowercaseLetter, .titlecaseLetter, .modifierLetter, + .otherLetter, .decimalNumber: return true + default: return false + } + } + var mapped = "" + for (index, unit) in lowered.enumerated() { + if unit == 37 { + if index > 0, isLegacyWordUnit(lowered[index - 1]) { mapped += "_" } + mapped += "pct" + if index + 1 < lowered.count, isLegacyWordUnit(lowered[index + 1]) { mapped += "_" } + } else if isLegacyWordUnit(unit), let scalar = UnicodeScalar(UInt32(unit)) { + mapped.unicodeScalars.append(scalar) + } else { + mapped += "_" + } + } + let trimmed = mapped.replacingOccurrences(of: "__", with: "_") + .trimmingCharacters(in: CharacterSet(charactersIn: "_")) + return trimmed.isEmpty ? "" : "custom_" + trimmed + } + // MARK: - Value parsing (decimal commas welcome, nothing guessed) /// Parse a value cell as a number. Handles plain decimals, a European decimal comma diff --git a/Packages/StrandImport/Sources/StrandImport/WhoopBiomarkerExportParser.swift b/Packages/StrandImport/Sources/StrandImport/WhoopBiomarkerExportParser.swift index b594bc9ed9..10dd3fc64c 100644 --- a/Packages/StrandImport/Sources/StrandImport/WhoopBiomarkerExportParser.swift +++ b/Packages/StrandImport/Sources/StrandImport/WhoopBiomarkerExportParser.swift @@ -126,9 +126,11 @@ public enum WhoopBiomarkerExportParser { !s.isEmpty, !isNoData(s) else { return nil } return "WHOOP: " + s }() + let legacy = LabMarkerCsvImport.legacyCustomKey(rawName) store(LabMarkerCsvRow(markerKey: key, category: category, day: day, value: value, unit: unit, isCustomMarker: MarkerCatalog.definition(for: key) == nil, - note: note)) + note: note, + legacyMarkerKey: MarkerCatalog.definition(for: key) == nil && !legacy.isEmpty && legacy != key ? legacy : nil)) } let rows = byCell.values.sorted { diff --git a/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift b/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift index f34d110c9c..31814c8af7 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/LabMarkerCsvImportTests.swift @@ -337,4 +337,40 @@ final class LabMarkerCsvImportTests: XCTestCase { ] XCTAssertEqual(names.map(LabMarkerCsvImport.customKey), expected) } + func testLegacyKeyMatchesAndroidHistory() { + let names = ["eGFR mL/min/1.73m²", "Ⅻ Marker", "²%", "𐄇 Marker", "!!!", "²", "Apo B", "Café Marker", "q̇ Marker", "🧪αⅫ²%", "A___B"] + let actual = names.enumerated().map { "\($0.offset):\(LabMarkerCsvImport.legacyCustomKey($0.element))" }.joined(separator: "\n") + XCTAssertEqual(actual, """ + 0:custom_egfr_ml_min_1_73m + 1:custom_marker + 2:custom_pct + 3:custom_marker + 4: + 5: + 6:custom_apo_b + 7:custom_café_marker + 8:custom_q_marker + 9:custom_α_pct + 10:custom_a__b + """) + } + + func testImportCarriesOnlyUnambiguousNonemptyLegacyAliases() { + let single = LabMarkerCsvImport.parse(text: "date,marker,value\n2026-01-01,eGFR mL/min/1.73m²,90") + XCTAssertEqual(single.rows.first?.legacyMarkerKey, "custom_egfr_ml_min_1_73m") + XCTAssertEqual(single.legacyKeyChanges, ["custom_egfr_ml_min_1_73m": "custom_egfr_ml_min_1_73m²"]) + for names in [["Ⅻ Marker", "𐄇 Marker"], ["𐄇 Marker", "Marker"]] { + let csv = "date,marker,value\n" + names.map { "2026-01-01,\($0),90" }.joined(separator: "\n") + let parsed = LabMarkerCsvImport.parse(text: csv) + XCTAssertEqual(parsed.rows.count, names.count) + XCTAssertTrue(parsed.legacyKeyChanges.isEmpty) + } + let empty = LabMarkerCsvImport.parse(text: "date,marker,value\n2026-01-01,²,90") + XCTAssertTrue(empty.legacyKeyChanges.isEmpty) + XCTAssertEqual(empty.skippedRows, 1) + let vendor = WhoopBiomarkerExportParser.parse(text: + "Biomarker Name,Value,Status,Recorded On Date\neGFR mL/min/1.73m²,90 unit,Original status,1/1/26") + XCTAssertEqual(vendor.legacyKeyChanges, single.legacyKeyChanges) + } + } diff --git a/Packages/WhoopStore/Sources/WhoopStore/LabMarkerStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LabMarkerStore.swift index ef6e26f18e..9305c7931c 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LabMarkerStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LabMarkerStore.swift @@ -107,14 +107,41 @@ extension WhoopStore { /// `LabBookProjection.project(.latest)`. Only NUMERIC readings project (a /// REAL-only `metricSeries` cell can't carry `valueText`). /// + /// `legacyKeys` comes from names in a reimport. Only an absent target may receive that device's + /// legacy history; re-keying, incoming writes and both projections commit or roll back together. /// Returns the number of marker rows written/updated. + // Kotlin twin: `WhoopDao.upsertLabMarkers`. @discardableResult - public func upsertLabMarkers(_ rows: [LabMarkerRow]) async throws -> Int { + public func upsertLabMarkers(_ rows: [LabMarkerRow], legacyKeys: [String: String] = [:]) async throws -> Int { guard !rows.isEmpty else { return 0 } return try syncWrite { db in var written = 0 // Track which (deviceId, markerKey, day) cells need re-projecting. var touched: Set = [] + for (deviceId, imported) in Dictionary(grouping: rows, by: \.deviceId) where !legacyKeys.isEmpty { + let currentKeys = Set(imported.map(\.markerKey)) + var presentKeys = Set(try String.fetchAll(db, sql: + "SELECT DISTINCT markerKey FROM labMarker WHERE deviceId = ?", arguments: [deviceId])) + for old in legacyKeys.keys.sorted() { + guard let new = legacyKeys[old], old != new, + old.hasPrefix("custom_"), old.count > 7, + new.hasPrefix("custom_"), new.count > 7, + !currentKeys.contains(old), currentKeys.contains(new), + presentKeys.contains(old), !presentKeys.contains(new) else { continue } + let days = try String.fetchAll(db, sql: + "SELECT DISTINCT day FROM labMarker WHERE deviceId = ? AND markerKey = ?", + arguments: [deviceId, old]) + try Self.rekeyLegacyLabMarkers(deviceId: deviceId, from: old, to: new, in: db) + if db.changesCount > 0 { + presentKeys.remove(old) + presentKeys.insert(new) + for day in days { + touched.insert(DayCell(deviceId: deviceId, markerKey: old, day: day)) + touched.insert(DayCell(deviceId: deviceId, markerKey: new, day: day)) + } + } + } + } for r in rows { // Upsert keyed on the natural index, NOT on the `id` PK: a re-import of the // "same reading" (same deviceId+markerKey+takenAt+source) must update, not @@ -144,6 +171,15 @@ extension WhoopStore { } } + // Kotlin twin: `WhoopDao.rekeyLegacyLabMarkers`. + private static func rekeyLegacyLabMarkers(deviceId: String, from: String, to: String, in db: Database) throws { + // The surrounding import transaction checked the target has no readings on any day/source. + // UPDATE preserves every reading field, including its stable id. + try db.execute(sql: """ + UPDATE labMarker SET markerKey = ? WHERE deviceId = ? AND markerKey = ? + """, arguments: [to, deviceId, from]) + } + // MARK: - Reads /// All readings in a category, oldest first (by takenAt). Served by diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LabMarkerStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LabMarkerStoreTests.swift index de19d4a9ba..0057ac71c4 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/LabMarkerStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LabMarkerStoreTests.swift @@ -57,6 +57,71 @@ final class LabMarkerStoreTests: XCTestCase { unit: unit, source: source, note: nil, referenceText: nil) } + func testImportRekeysHistoryAndBothProjectionsWithoutLosingFields() async throws { + let store = try await WhoopStore.inMemory() + let old = "custom_egfr_ml_min_1_73m", new = old + "²" + var history = [ + mk(id: "one", key: old, day: "2026-01-01", takenAt: 100, value: 90, source: "lab-csv"), + mk(id: "two", key: old, day: "2026-01-02", takenAt: 200, value: 92, source: "manual"), + mk(id: "text", key: old, day: "2026-01-03", takenAt: 300, value: nil, valueText: "original text") + ] + history[0].note = "original note" + history[0].referenceText = "user range" + var foreign = history[0] + foreign.id = "foreign"; foreign.deviceId = "other-device"; foreign.day = "2025-01-01" + try await store.upsertLabMarkers(history + [foreign]) + let imported = mk(id: "new", key: new, day: "2026-01-04", takenAt: 400, value: 95, source: "lab-csv") + try await store.upsertLabMarkers([imported], legacyKeys: [old: new]) + let migrated = try await store.labMarkers(deviceId: "my-whoop", markerKey: new) + XCTAssertEqual(migrated, history.map { row in var moved = row; moved.markerKey = new; return moved } + [imported]) + let remaining = try await store.labMarkers(deviceId: "my-whoop", markerKey: old) + let other = try await store.labMarkers(deviceId: "other-device", markerKey: old) + XCTAssertTrue(remaining.isEmpty) + XCTAssertEqual(other, [foreign]) + let oldProjection = try await store.metricSeries(deviceId: "lab-book", key: old, from: "2026-01-01", to: "2026-01-04") + let newProjection = try await store.metricSeries(deviceId: "lab-book", key: new, from: "2026-01-01", to: "2026-01-04") + XCTAssertTrue(oldProjection.isEmpty) + XCTAssertEqual(newProjection.map(\.value), [90, 92, 95]) + var repeated = imported; repeated.id = "reimport" + try await store.upsertLabMarkers([repeated], legacyKeys: [old: new]) + let afterRepeat = try await store.labMarkers(deviceId: "my-whoop", markerKey: new) + XCTAssertEqual(afterRepeat, migrated) + } + + func testImportKeepsLegacyHistoryWhenTargetExistsOrLegacyKeyIsInvalid() async throws { + for alias in ["custom_old", "", "custom_"] { + let store = try await WhoopStore.inMemory() + let old = mk(id: "old", key: alias, day: "2026-01-01", takenAt: 100, value: 10) + let target = mk(id: "existing", key: "custom_new", day: "2026-02-01", takenAt: 200, value: 20, source: "other-source") + let existing = alias == "custom_old" ? [target] : [] + try await store.upsertLabMarkers([old] + existing) + let imported = mk(id: "import", key: "custom_new", day: "2026-03-01", takenAt: 300, value: 30) + try await store.upsertLabMarkers([imported], legacyKeys: [alias: "custom_new"]) + let legacy = try await store.labMarkers(deviceId: "my-whoop", markerKey: alias) + let current = try await store.labMarkers(deviceId: "my-whoop", markerKey: "custom_new") + XCTAssertEqual(legacy, [old]) + XCTAssertEqual(current, existing + [imported]) + } + } + + func testImportRekeyRollsBackWhenIncomingWriteFails() async throws { + let store = try await WhoopStore.inMemory() + let old = mk(id: "old", key: "custom_old", day: "2026-01-01", takenAt: 100, value: 10) + try await store.upsertLabMarkers([old]) + try await store.syncWrite { db in + try db.execute(sql: "CREATE TRIGGER reject_lab_import BEFORE INSERT ON labMarker BEGIN SELECT RAISE(ABORT, 'test failure'); END") + } + do { + try await store.upsertLabMarkers([mk(id: "new", key: "custom_new", day: "2026-02-01", takenAt: 200, value: 20)], + legacyKeys: ["custom_old": "custom_new"]) + XCTFail("import must fail") + } catch { } + let legacy = try await store.labMarkers(deviceId: "my-whoop", markerKey: "custom_old") + let current = try await store.labMarkers(deviceId: "my-whoop", markerKey: "custom_new") + XCTAssertEqual(legacy, [old]) + XCTAssertTrue(current.isEmpty) + } + // MARK: - upsert + read by marker / category / keys present func testUpsertReadByMarkerAndCategory() async throws { diff --git a/Strand/Screens/LabBookView.swift b/Strand/Screens/LabBookView.swift index a474a1e734..ea403a54a0 100644 --- a/Strand/Screens/LabBookView.swift +++ b/Strand/Screens/LabBookView.swift @@ -276,7 +276,7 @@ struct LabBookView: View { referenceText: nil ) } - try await store.upsertLabMarkers(rows) + try await store.upsertLabMarkers(rows, legacyKeys: result.legacyKeyChanges) await repo.refresh() // re-resolves the lab-book projection into Compare/Explore/Coach await load() var msg = String(localized: "Imported \(result.importedReadings) readings (\(result.distinctMarkers) markers)") diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 9f2fc2cb89..90d2a599ce 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -436,6 +436,15 @@ "identity_sha256": "6aa52285ae46cf0ae75a690feee01765f9401825b6c9845c146163fdf5b2bb3e", "platform": "swift", "rationale": "A LOCAL binding inside the private v16 ECG decoder (#2847), not a static constant. The ledger's Swift rule treats any four-space `let` in a file-scope function as a candidate `static let`, which is how it finds file-scope constants without parsing, so a local in a top-level `private func` lands in the inventory. The Kotlin twin's equivalent is a local `val` in its own decodeWhoop5HistoricalV16, which the Kotlin rule never inventories because it matches `const val` only, and a Set-valued or object-valued local cannot be `const` in Kotlin at all. There is nothing to pair and nothing to change in either decoder." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopRepository.kt::upsertLabMarkers/2#1", + "identity_sha256": "5198540370e00422eccecff3684bafea749a2cb9b04c52881c51dcb4e08fbef0", + "platform": "kotlin", + "rationale": "Android repository facade forwards readings and name-derived legacy aliases to the Room transaction. Swift importMarkersCsv passes the same pair directly to the actor store from LabBookView, outside the Swift package scanner roots; the storage transactions are paired separately.", + "twin_path": "Strand/Screens/LabBookView.swift" } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 9a94e3543b..234e6ab5c5 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,15 +19,15 @@ }, "authority": { "files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"}, - "functions": {"count": 4571, "sha256": "ea0267f6ff16718fb08b7526558f27e525ed4fbebf6bbaf8ae199296094cc3a1"}, - "properties": {"count": 496, "sha256": "18c74d4787010d269ae31f0ca2a9898b60cd5e79591bb82194007038c625054c"}, + "functions": {"count": 4577, "sha256": "09259f5ede64de80cc1e197806a9625e48bb19faaffb034a1bd82735c0427e2d"}, + "properties": {"count": 498, "sha256": "39618728b66a07ba2816585ad36af55fe8adc21eec91c407bd6cea19244f6363"}, "constants": {"count": 2041, "sha256": "cd3d7c81c676d21d9bcc515f900ed585d68b8ba7e2440840b3fe52a702d4b8ef"}, - "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, - "function_pairs": {"count": 238, "sha256": "62681716d701dbd647daf6676dbbf32b6ae1e19f8a5b7ce177824619a61700bd"}, - "property_pairs": {"count": 167, "sha256": "a6877b76b4ad9d4f6705421de0df1e17a06881c47b1d693a5cdb4161067ae8dd"}, + "file_pairs": {"count": 82, "sha256": "5e03b57496fc4c854b56b86ab00bb24163b5c16d25ad8e52786d6731563648e0"}, + "function_pairs": {"count": 242, "sha256": "3716ce7cf8b2bb532ce2213213ba7fd7a3b7c036999e540a9953df5ca6bb83e4"}, + "property_pairs": {"count": 168, "sha256": "911b6b88dc3279c807d34f1cac944c6bf28e64668e159274cdfa99ad9ad7d8d3"}, "constant_pairs": {"count": 720, "sha256": "ac70a1ecc9b69f9d3b9a6f98ebbdf7ea3ab6a61c7ee03ba52b5d89f2ac3e6332"}, - "unpaired_files": {"count": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"}, - "unpaired_functions": {"count": 4108, "sha256": "4c548bc990ef3288f4934076863bdb4d727caf885387f88f89aa5019c8a124df"}, + "unpaired_files": {"count": 368, "sha256": "906039f0562d34aacaa1d5f7bf3d6b82ead5c39fa56d2a1ff3cd563bbcf3f2d7"}, + "unpaired_functions": {"count": 4106, "sha256": "0b41754b21d2985ab3ebc931dd7480a36a3c0834019dbf956f2953f367d588fa"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, "unpaired_constants": {"count": 601, "sha256": "13693d0d240b34610a5ab2c6b1dcea1d9702b9da5e9e3714b631ef3795e71a38"} } diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index bb9363c43c..0ea78c86a6 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -1148,19 +1148,53 @@ interface WhoopDao : DeviceRegistryDao { * under [LAB_BOOK_SOURCE_ID]. Idempotent by natural key; LATEST-numeric-per-day wins in the * projection (qualitative valueText-only readings never project a REAL cell). Atomic — the * marker write and the projection can't diverge. Byte-identical to Swift - * WhoopStore.upsertLabMarkers. + * WhoopStore.upsertLabMarkers. Import-time legacy aliases may move a device's history only into + * an absent target; that move, incoming writes and both projections share this transaction. */ + // Swift twin: `WhoopStore.upsertLabMarkers`. @Transaction - suspend fun upsertLabMarkers(rows: List) { + suspend fun upsertLabMarkers(rows: List, legacyKeys: Map = emptyMap()) { if (rows.isEmpty()) return - insertLabMarkersRaw(rows) + val cells = rows.map { Triple(it.deviceId, it.markerKey, it.day) }.toMutableSet() + for ((deviceId, imported) in if (legacyKeys.isEmpty()) emptyMap() else rows.groupBy { it.deviceId }) { + val currentKeys = imported.map { it.markerKey }.toSet() + val presentKeys = markerKeysPresent(deviceId).toMutableSet() + for ((old, new) in legacyKeys.toSortedMap()) { + if (old == new || !old.startsWith("custom_") || old.length <= 7 || + !new.startsWith("custom_") || new.length <= 7 || + old in currentKeys || new !in currentKeys || old !in presentKeys || new in presentKeys) continue + val days = labMarkersByKey(deviceId, old).map { it.day }.toSet() + if (rekeyLegacyLabMarkers(deviceId, old, new) > 0) { + presentKeys.remove(old) + presentKeys.add(new) + for (day in days) { + cells += Triple(deviceId, old, day) + cells += Triple(deviceId, new, day) + } + } + } + } + // Swift's natural-key upsert retains the old stable id on an overlapping reimport. + // Preserve it for these repair imports too; REPLACE alone would mint a different id. + val importedRows = if (legacyKeys.isEmpty()) rows else { + val ids = rows.map { it.deviceId to it.markerKey }.distinct().flatMap { (deviceId, key) -> + labMarkersByKey(deviceId, key) + }.associateBy { Triple(it.deviceId, it.markerKey, it.takenAt to it.source) } + rows.map { row -> + ids[Triple(row.deviceId, row.markerKey, row.takenAt to row.source)]?.let { row.copy(id = it.id) } ?: row + } + } + insertLabMarkersRaw(importedRows) // Distinct touched cells, in a deterministic order. - val cells = rows.map { Triple(it.deviceId, it.markerKey, it.day) }.toSet() for ((deviceId, markerKey, day) in cells) { reprojectCell(deviceId, markerKey, day) } } + // Swift twin: `WhoopStore.rekeyLegacyLabMarkers`. + @Query("UPDATE labMarker SET markerKey = :to WHERE deviceId = :deviceId AND markerKey = :from") + suspend fun rekeyLegacyLabMarkers(deviceId: String, from: String, to: String): Int + /** * Delete one reading by id; if it was the last numeric reading for its (markerKey, day) cell the * projected day is removed, otherwise the projection is recomputed from the remainder. Returns diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index e1831d9645..5a2283ccea 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1232,7 +1232,8 @@ class WhoopRepository( // MARK: - Lab Book markers (Swift labMarker, v17). Writing also projects the daily series into // metricSeries under WhoopDao.LAB_BOOK_SOURCE_ID, so Compare/Explore/Coach see markers unchanged. - suspend fun upsertLabMarkers(rows: List) = dao.upsertLabMarkers(rows) + suspend fun upsertLabMarkers(rows: List, legacyKeys: Map = emptyMap()) = + dao.upsertLabMarkers(rows, legacyKeys) suspend fun deleteLabMarker(id: String): Boolean = dao.deleteLabMarker(id) suspend fun labMarkersByKey(deviceId: String, markerKey: String) = dao.labMarkersByKey(deviceId, markerKey) suspend fun labMarkersByCategory(deviceId: String, category: String) = dao.labMarkersByCategory(deviceId, category) diff --git a/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt b/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt index ea48e12788..358c10bdb2 100644 --- a/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt +++ b/android/app/src/main/java/com/noop/ingest/LabMarkerCsvImport.kt @@ -67,6 +67,9 @@ object LabMarkerCsvImport { * status column, source-attributed). null for the generic (date,marker,value,unit) * importer, which never annotates. Maps to LabMarker.note. */ val note: String? = null, + /** Historical Android key derived from this input name; import-time repair metadata only. */ + // Swift twin: `LabMarkerCsvRow.legacyMarkerKey`. + val legacyMarkerKey: String? = null, ) /** Result of parsing a markers CSV. Mirrors the Swift LabMarkerCsvResult. */ @@ -88,6 +91,20 @@ object LabMarkerCsvImport { * export doesn't look broken. Always 0 for the generic importer. */ val notMeasured: Int = 0, ) { + /** Only unambiguous aliases from names in this file may repair a stored legacy key. */ + // Swift twin: `LabMarkerCsvResult.legacyKeyChanges`. + val legacyKeyChanges: Map get() { + val currentKeys = rows.map { it.markerKey }.toSet() + return rows.mapNotNull { row -> + val old = row.legacyMarkerKey + if (!row.isCustomMarker || old.isNullOrEmpty() || old == row.markerKey || old in currentKeys) null + else old to row.markerKey + }.groupBy { it.first }.mapNotNull { (old, aliases) -> + val targets = aliases.map { it.second }.toSet() + if (targets.size == 1) old to targets.single() else null + }.toMap() + } + val importedReadings: Int get() = rows.size val distinctMarkers: Int get() = rows.map { it.markerKey }.toHashSet().size } @@ -161,7 +178,7 @@ object LabMarkerCsvImport { referenceText = null, ) } - repo.upsertLabMarkers(markerRows) + repo.upsertLabMarkers(markerRows, legacyKeys = result.legacyKeyChanges) return ImportSummary( source = SOURCE_LABEL, @@ -306,7 +323,8 @@ object LabMarkerCsvImport { unit = rawUnit ?: "" customKeys.add(key) } - store(LabMarkerCsvRow(key, category, day, value, unit, isCustomMarker = def == null)) + store(LabMarkerCsvRow(key, category, day, value, unit, isCustomMarker = def == null, + legacyMarkerKey = if (def == null) legacyCustomKey(rawName).takeIf { it.isNotEmpty() && it != key } else null)) } val rows = byCell.values.sortedWith(compareBy({ it.day }, { it.markerKey })) @@ -481,6 +499,28 @@ object LabMarkerCsvImport { return if (collapsed.isEmpty()) "" else "custom_$collapsed" } + /** Historical Android UTF-16 letter/decimal-digit key, used only while a reimport still has the marker name. + * An empty result cannot identify any legacy history and must never be re-keyed. */ + // Swift twin: `LabMarkerCsvImport.legacyCustomKey`. + internal fun legacyCustomKey(name: String): String { + val lowered = Normalizer.normalize(name, Normalizer.Form.NFC).trim().lowercase() + // Swift twin: `LabMarkerCsvImport.isLegacyWordUnit`. + fun isLegacyWordUnit(unit: Char): Boolean = unit.isLetterOrDigit() + val mapped = buildString { + lowered.forEachIndexed { i, ch -> + if (ch == '%') { + if (i > 0 && isLegacyWordUnit(lowered[i - 1])) append('_') + append("pct") + if (i + 1 < lowered.length && isLegacyWordUnit(lowered[i + 1])) append('_') + } else { + append(if (isLegacyWordUnit(ch)) ch else '_') + } + } + } + val collapsed = mapped.replace("__", "_").trim('_') + return if (collapsed.isEmpty()) "" else "custom_$collapsed" + } + // MARK: - Value parsing (decimal commas welcome, nothing guessed) private val DECIMAL_COMMA = Regex("^[+-]?[0-9]+,[0-9]+$") diff --git a/android/app/src/main/java/com/noop/ingest/WhoopBiomarkerExportParser.kt b/android/app/src/main/java/com/noop/ingest/WhoopBiomarkerExportParser.kt index cd2e4083aa..b91e99775b 100644 --- a/android/app/src/main/java/com/noop/ingest/WhoopBiomarkerExportParser.kt +++ b/android/app/src/main/java/com/noop/ingest/WhoopBiomarkerExportParser.kt @@ -124,7 +124,9 @@ object WhoopBiomarkerExportParser { val note = rawStatus?.trim() ?.takeIf { it.isNotEmpty() && !isNoData(it) } ?.let { "WHOOP: $it" } - store(LabMarkerCsvRow(key, category, day, value, unit, isCustomMarker = def == null, note = note)) + store(LabMarkerCsvRow(key, category, day, value, unit, isCustomMarker = def == null, note = note, + legacyMarkerKey = if (def == null) LabMarkerCsvImport.legacyCustomKey(rawName) + .takeIf { it.isNotEmpty() && it != key } else null)) } val rows = byCell.values.sortedWith(compareBy({ it.day }, { it.markerKey })) diff --git a/android/app/src/test/java/com/noop/data/LabMarkerReimportTest.kt b/android/app/src/test/java/com/noop/data/LabMarkerReimportTest.kt new file mode 100644 index 0000000000..4bd6951ed5 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/LabMarkerReimportTest.kt @@ -0,0 +1,99 @@ +package com.noop.data + +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Proxy +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test + +/** Runs the production DAO orchestration with recorded primitive writes; Swift pins real SQLite rollback. */ +class LabMarkerReimportTest { + private class Fixture { + val rows = linkedMapOf() + val series = linkedMapOf, Double>() + val dao = Proxy.newProxyInstance(WhoopDao::class.java.classLoader, arrayOf(WhoopDao::class.java)) { proxy, method, args -> + val a = args!! + when (method.name) { + "upsertLabMarkers", "reprojectCell" -> { + val body = Class.forName("com.noop.data.WhoopDao\$DefaultImpls").methods.single { it.name == method.name } + try { body.invoke(null, proxy, *a) } + catch (error: InvocationTargetException) { throw error.targetException } + } + "markerKeysPresent" -> rows.values.filter { it.deviceId == a[0] }.map { it.markerKey }.distinct().sorted() + "labMarkersByKey" -> rows.values.filter { it.deviceId == a[0] && it.markerKey == a[1] }.sortedBy { it.takenAt } + "rekeyLegacyLabMarkers" -> { + val matching = rows.values.filter { it.deviceId == a[0] && it.markerKey == a[1] } + matching.forEach { rows[it.id] = it.copy(markerKey = a[2] as String) } + matching.size + } + "insertLabMarkersRaw" -> { + (a[0] as List<*>).forEach { item -> + val row = item as LabMarkerRow + rows.values.filter { it.deviceId == row.deviceId && it.markerKey == row.markerKey && + it.takenAt == row.takenAt && it.source == row.source }.forEach { rows.remove(it.id) } + rows[row.id] = row + } + Unit + } + "latestNumericForCell" -> rows.values.filter { + it.deviceId == a[0] && it.markerKey == a[1] && it.day == a[2] && it.value != null + }.maxByOrNull { it.takenAt }?.value + "upsertMetricSeries" -> { + (a[0] as List<*>).forEach { item -> + val row = item as MetricSeriesRow + series[Triple(row.deviceId, row.day, row.key)] = row.value + } + Unit + } + "deleteMetricSeriesPoint" -> { series.remove(Triple(a[0] as String, a[1] as String, a[2] as String)); Unit } + else -> error("Unexpected DAO call ${method.name}") + } + } as WhoopDao + } + + private fun row(id: String, key: String, day: Int, value: Double? = 90.0) = LabMarkerRow( + id, "my-whoop", key, "other", "2026-01-%02d".format(java.util.Locale.ROOT, day), day * 100L, + value, if (value == null) "original text" else null, "unit", "lab-csv", "original note", "user range", + ) + + @Test fun rekeyPreservesHistoryAndProjectionsAndRepeatImportIds() = runBlocking { + val f = Fixture() + val old = "custom_egfr_ml_min_1_73m"; val new = old + "²" + val history = listOf(row("one", old, 1), row("two", old, 2, 92.0).copy(source = "manual"), row("text", old, 3, null)) + val foreign = history[0].copy(id = "foreign", deviceId = "other-device", day = "2025-01-01") + f.dao.upsertLabMarkers(history + foreign) + val imported = row("new", new, 4, 95.0) + f.dao.upsertLabMarkers(listOf(imported), mapOf(old to new)) + assertEquals(history.map { it.copy(markerKey = new) } + imported, + f.rows.values.filter { it.deviceId == "my-whoop" }.sortedBy { it.takenAt }) + assertEquals(foreign, f.rows[foreign.id]) + assertFalse(f.series.keys.any { it.third == old && it.second.startsWith("2026-") }) + assertEquals(listOf(90.0, 92.0, 95.0), f.series.filterKeys { it.third == new }.toSortedMap(compareBy { it.second }).values.toList()) + val before = f.rows.toMap() + f.dao.upsertLabMarkers(listOf(imported.copy(id = "reimport")), mapOf(old to new)) + assertEquals(before, f.rows) + } + + @Test fun conflictingOrEmptyLegacyKeysDoNotMoveHistory() = runBlocking { + for (alias in listOf("custom_old", "", "custom_")) { + val f = Fixture() + val old = row("old", alias, 1) + val target = row("target", "custom_new", 2).copy(source = "other-source") + val existing = if (alias == "custom_old") listOf(target) else emptyList() + f.dao.upsertLabMarkers(listOf(old) + existing) + val imported = row("import", "custom_new", 3) + f.dao.upsertLabMarkers(listOf(imported), mapOf(alias to "custom_new")) + assertEquals(old, f.rows[old.id]) + assertEquals(existing + imported, f.rows.values.filter { it.markerKey == "custom_new" }.sortedBy { it.takenAt }) + } + } + + @Test fun anExplicitlyImportedLegacyKeyIsNotReassigned() = runBlocking { + val f = Fixture() + val old = row("old", "custom_old", 1) + f.dao.upsertLabMarkers(listOf(old)) + f.dao.upsertLabMarkers(listOf(row("new", "custom_new", 2), row("same", "custom_old", 3)), + mapOf("custom_old" to "custom_new")) + assertEquals(old, f.rows[old.id]) + } +} diff --git a/android/app/src/test/java/com/noop/ingest/LabMarkerCsvImportTest.kt b/android/app/src/test/java/com/noop/ingest/LabMarkerCsvImportTest.kt index 3d128247a2..1c80787fd7 100644 --- a/android/app/src/test/java/com/noop/ingest/LabMarkerCsvImportTest.kt +++ b/android/app/src/test/java/com/noop/ingest/LabMarkerCsvImportTest.kt @@ -358,4 +358,40 @@ class LabMarkerCsvImportTest { ) assertEquals(expected, names.map { LabMarkerCsvImport.customKey(it) }) } + @Test fun legacyKeyMatchesAndroidHistory() { + val names = listOf("eGFR mL/min/1.73m²", "Ⅻ Marker", "²%", "𐄇 Marker", "!!!", "²", "Apo B", "Café Marker", "q̇ Marker", "🧪αⅫ²%", "A___B") + val actual = names.mapIndexed { index, name -> "$index:${LabMarkerCsvImport.legacyCustomKey(name)}" }.joinToString("\n") + assertEquals(""" + 0:custom_egfr_ml_min_1_73m + 1:custom_marker + 2:custom_pct + 3:custom_marker + 4: + 5: + 6:custom_apo_b + 7:custom_café_marker + 8:custom_q_marker + 9:custom_α_pct + 10:custom_a__b + """.trimIndent(), actual) + } + + @Test fun importCarriesOnlyUnambiguousNonemptyLegacyAliases() { + val single = LabMarkerCsvImport.parse("date,marker,value\n2026-01-01,eGFR mL/min/1.73m²,90") + assertEquals("custom_egfr_ml_min_1_73m", single.rows.first().legacyMarkerKey) + assertEquals(mapOf("custom_egfr_ml_min_1_73m" to "custom_egfr_ml_min_1_73m²"), single.legacyKeyChanges) + for (names in listOf(listOf("Ⅻ Marker", "𐄇 Marker"), listOf("𐄇 Marker", "Marker"))) { + val csv = "date,marker,value\n" + names.joinToString("\n") { "2026-01-01,$it,90" } + val parsed = LabMarkerCsvImport.parse(csv) + assertEquals(names.size, parsed.rows.size) + assertTrue(parsed.legacyKeyChanges.isEmpty()) + } + val empty = LabMarkerCsvImport.parse("date,marker,value\n2026-01-01,²,90") + assertTrue(empty.legacyKeyChanges.isEmpty()) + assertEquals(1, empty.skippedRows) + val vendor = WhoopBiomarkerExportParser.parse( + "Biomarker Name,Value,Status,Recorded On Date\neGFR mL/min/1.73m²,90 unit,Original status,1/1/26") + assertEquals(single.legacyKeyChanges, vendor.legacyKeyChanges) + } + }