Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,20 @@ 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
self.value = value
self.unit = unit
self.isCustomMarker = isCustomMarker
self.note = note
self.legacyMarkerKey = legacyMarkerKey
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

}
38 changes: 37 additions & 1 deletion Packages/WhoopStore/Sources/WhoopStore/LabMarkerStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<DayCell> = []
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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Strand/Screens/LabBookView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
9 changes: 9 additions & 0 deletions Tools/parity_dispositions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
14 changes: 7 additions & 7 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}
Expand Down
Loading
Loading