From 8e3974bfac1d576459f33f08d8c21677ae05416e Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:50:36 +0200 Subject: [PATCH 1/3] fix(sleep): resolve a sleep edit's owner from the union the reads use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing or deleting a night wrote against two ids only — the active computed source, then the active imported one — while the Sleep tab reads from the active AND the canonical id of both namespaces. After a strap re-add, a historical night owned by a canonical source therefore rendered as editable, but the write matched no row and the correction was silently gone on the next refresh. CachedSleepSession carries no device id by design, so a write that starts from a merged Sleep-tab row has nothing to resolve its owner from. Walk the same active-plus-canonical union the reads use and stop at the first namespace that actually matched; stopping early also keeps a coincidental same-startTs row in another source from taking a second edit, which the previous unconditional fallback could do. Covered by ReadSpineActiveDeviceTests: a canonical-owned night stays edited across a re-add, and imported history under the canonical id survives one. --- Strand/Data/Repository.swift | 47 +++++++++++--------- StrandTests/ReadSpineActiveDeviceTests.swift | 27 +++++++++++ 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 94da6baebd..097f10e520 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -165,6 +165,17 @@ final class Repository: ObservableObject { computedDeviceId == canonicalComputedId ? [computedDeviceId] : [computedDeviceId, canonicalComputedId] } + /// Every namespace that can own a sleep row currently visible in the app. Computed rows keep their + /// established precedence over imported rows; within each side the active source comes first. + /// + /// `CachedSleepSession` intentionally has no device id, so a write that starts from a merged Sleep-tab + /// row must resolve its owner against this same active-plus-canonical union used for reads. Otherwise a + /// historical night under the canonical source after a strap re-add looks editable but its save is a + /// no-op. + private var sleepOwnerIds: [String] { + computedReadIds + importedReadIds + } + /// True when the ACTIVE strap is an Oura ring, resolved from its registry id prefix against the canonical /// brand table (`DeviceBrandCatalog.idPrefix`) rather than an ad-hoc "oura" literal. The device registry /// mints every non-WHOOP id as "-", so the stored id's prefix IS its brand key. Read/UI @@ -1148,16 +1159,13 @@ final class Repository: ObservableObject { let stagesJSON = await restageFromRaw(start: safeStartTs, end: safeEndTs) ?? SleepWindowReclip.reclip(stagesJSON: storedStagesJSON, sessionStart: detectedStartTs, oldEnd: oldEndTs, newStart: safeStartTs, newEnd: safeEndTs) - // Apply to the source that actually OWNS this block. Try the computed source first; only fall - // back to the imported source when no computed row matched , so we never edit a coincidental - // same-startTs row in the other namespace (which the old unconditional double-write could do). - let computedChanged = (try? await store.applySleepEdit( - deviceId: computedDeviceId, detectedStartTs: detectedStartTs, - newStartTs: safeStartTs, newEndTs: safeEndTs, stagesJSON: stagesJSON)) ?? 0 - if computedChanged == 0 { - _ = try? await store.applySleepEdit( - deviceId: deviceId, detectedStartTs: detectedStartTs, - newStartTs: safeStartTs, newEndTs: safeEndTs, stagesJSON: stagesJSON) + // Resolve the actual owner from every namespace the Sleep tab can display. Stop at the first + // match so duplicate detected keys in another source cannot receive a second edit. + for ownerDeviceId in sleepOwnerIds { + let changed = (try? await store.applySleepEdit( + deviceId: ownerDeviceId, detectedStartTs: detectedStartTs, + newStartTs: safeStartTs, newEndTs: safeEndTs, stagesJSON: stagesJSON)) ?? 0 + if changed > 0 { break } } await refresh() } @@ -1196,11 +1204,11 @@ final class Repository: ObservableObject { dismissedSleepSpans = DismissedSleepSpans.adding(startTs: detectedStartTs, endTs: endTs, to: dismissedSleepSpans) } - // Delete from the namespace that actually owns the row: computed first, imported as a fallback. - let computedDeleted = (try? await store.deleteSleepSession( - deviceId: computedDeviceId, startTs: detectedStartTs)) ?? 0 - if computedDeleted == 0 { - _ = try? await store.deleteSleepSession(deviceId: deviceId, startTs: detectedStartTs) + // Delete from the same union of possible owners that the Sleep tab reads from. + for ownerDeviceId in sleepOwnerIds { + let deleted = (try? await store.deleteSleepSession( + deviceId: ownerDeviceId, startTs: detectedStartTs)) ?? 0 + if deleted > 0 { break } } await refresh() return snapshot @@ -1251,11 +1259,10 @@ final class Repository: ObservableObject { return SleepDeletionSnapshot(session: session, ownerDeviceId: owner, endTs: session.endTs, motion: motion ?? nil, sleepState: sleepState ?? nil) } - if let computed = await row(computedDeviceId) { - return await snapshot(computed, owner: computedDeviceId) - } - if let imported = await row(deviceId) { - return await snapshot(imported, owner: deviceId) + for ownerDeviceId in sleepOwnerIds { + if let session = await row(ownerDeviceId) { + return await snapshot(session, owner: ownerDeviceId) + } } return nil } diff --git a/StrandTests/ReadSpineActiveDeviceTests.swift b/StrandTests/ReadSpineActiveDeviceTests.swift index 632f311d93..67e4b087ca 100644 --- a/StrandTests/ReadSpineActiveDeviceTests.swift +++ b/StrandTests/ReadSpineActiveDeviceTests.swift @@ -122,6 +122,33 @@ final class ReadSpineActiveDeviceTests: XCTestCase { "the canonical computed history must NOT be orphaned by the re-add") } + /// A historical sleep can remain visible under the canonical source after a strap re-add. The editor + /// receives an id-free CachedSleepSession, so its write must resolve that canonical owner instead of + /// trying only the active strap's two namespaces and silently doing nothing. + @MainActor + func testEditingCanonicalSleepAfterReAddPersistsTheVisibleNight() async throws { + let store = try await WhoopStore.inMemory() + try await store.upsertDevice(id: canonicalId, mac: nil, name: "WHOOP") + try await store.upsertDevice(id: newId, mac: nil, name: "WHOOP") + try await store.upsertSleepSessions( + [CachedSleepSession(startTs: 1_000, endTs: 5_000, efficiency: 0.9, + restingHr: 52, avgHrv: 60, stagesJSON: nil)], + deviceId: canonicalId) + + let repo = Repository(deviceId: canonicalId) + repo.setStoreForTesting(store) + repo.adoptActiveDeviceId(newId) + + await repo.editSleepTimes(detectedStartTs: 1_000, oldEndTs: 5_000, + storedStagesJSON: nil, newStartTs: 1_200, newEndTs: 4_800) + + let rows = try await store.sleepSessions(deviceId: canonicalId, from: 0, to: 10_000, limit: 10) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0].effectiveStartTs, 1_200) + XCTAssertEqual(rows[0].endTs, 4_800) + XCTAssertTrue(rows[0].userEdited) + } + /// THE union-model regression (#814 follow-up): import history under the CANONICAL "my-whoop", THEN /// re-add a strap so the active id becomes "whoop-uuid" and write LIVE HR under it. Assert (i) the /// imported canonical days STILL surface in refresh(), (ii) the new live HR under the re-added id also From c77a630a9f1930d0e5e020dbe0876ffcc51eadcd Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:11:00 +0200 Subject: [PATCH 2/3] fix(sleep): keep the exported debt as the baseline across a sleep edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate problems made a hand-corrected night report the wrong sleep debt. First, the daily sleep fields were rebuilt only from a computed daily row, and IntelligenceEngine cannot produce one for a historical import that has no raw streams behind it. The Sleep tab, the debt ledger and the dashboard then disagreed about the same night. Merge the sessions first and overlay the affected daily sleep fields from the stored, re-clipped stages, so the corrected duration is authoritative no matter which namespace owns the night. Fields that stages cannot truthfully recompute — disturbances, overnight vitals — are retained from the existing row. Second, the debt series still read the export's sleep_debt_min verbatim, so an edited night kept the debt of the duration it no longer had. Recomputing it as need − asleep would throw away WHOOP's own baseline, which is more than a per-night subtraction. Retain the exported debt and apply only the delta in asleep minutes: max(0, debt + originalSleepMin − editedSleepMin) Imports predating the retained original duration keep the previous need-minus-asleep approximation. Android carries the same stale-debt bug in SleepModelLogic, in the debt series and again in trendDebtHours, and gets the same treatment. The owner resolution in the preceding commit stays Swift-only on purpose: Android's updateSleepSessionTimes takes a SleepSession that already carries its deviceId, so that path cannot lose the row owner. Covered by SleepDebtEditTests (delta applied, and the fallback for imports without an original duration), EditMergePrecedenceTests (edited imported night rebuilds its daily sleep fields) and SleepImportedFiguresTest on Android. --- Strand/Data/Repository.swift | 112 ++++++++++++++++-- Strand/Screens/SleepView.swift | 15 ++- StrandTests/EditMergePrecedenceTests.swift | 28 +++++ StrandTests/SleepDebtEditTests.swift | 31 +++++ .../java/com/noop/data/WhoopRepository.kt | 19 +++ .../main/java/com/noop/ui/SleepModelLogic.kt | 45 ++++++- .../src/main/java/com/noop/ui/SleepModels.kt | 2 + .../src/main/java/com/noop/ui/SleepScreen.kt | 26 +++- .../com/noop/ui/SleepImportedFiguresTest.kt | 21 ++++ 9 files changed, 277 insertions(+), 22 deletions(-) create mode 100644 StrandTests/SleepDebtEditTests.swift diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 097f10e520..46b225c7ff 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -13,6 +13,8 @@ struct ImportedSleepFigures: Equatable { var consistencyPct: Double? // "sleep_consistency", 0–100 var needMin: Double? // "sleep_need_min", minutes var debtMin: Double? // "sleep_debt_min", minutes + /// Original export duration, retained when an edited daily row replaces its sleep fields. + var originalSleepMin: Double? } // MARK: - Cross-source resolver model (PR#196 , fresher live charts/metrics) @@ -742,18 +744,23 @@ final class Repository: ObservableObject { for p in cons { fig[p.day, default: ImportedSleepFigures()].consistencyPct = p.value } for p in need { fig[p.day, default: ImportedSleepFigures()].needMin = p.value } for p in debt { fig[p.day, default: ImportedSleepFigures()].debtMin = p.value } - // H5 (#509): a night the user hand-edited (userEdited) must keep its corrected sleep figures even - // when a WHOOP/Apple import also covers that day. The computed ("-noop") session carries the edit, - // and IntelligenceEngine re-keys the computed DAILY row from it; collect those edited days so the - // merge lets the computed row's SLEEP fields win there (imports still win on every un-edited day). - let editedDays = Self.userEditedDays(compSleep) + for day in imported where (day.totalSleepMin ?? 0) > 0 { + fig[day.day, default: ImportedSleepFigures()].originalSleepMin = day.totalSleepMin + } + // A user correction can live in either the computed or imported namespace. Merge the displayed + // sessions first, then use their corrected stages to overlay the affected daily sleep fields. + // This keeps the Sleep tab, its debt ledger, and the dashboard on the same edited night even when + // no raw data exists to produce a computed daily row (for example a historical WHOOP import). + let mergedSleeps = Self.mergeSleep(imported: impSleep, computed: compSleep) + let editedDays = Self.userEditedDays(mergedSleeps) + let mergedDays = Self.mergeDaily(imported: imported, computed: computed, userEditedDays: editedDays) return MergedCaches( importedSleep: fig, days: Self.mergeActivityFileSteps( - into: Self.mergeDaily(imported: imported, computed: computed, userEditedDays: editedDays), + into: Self.applyingEditedSleepSessions(mergedSleeps, to: mergedDays), activityFile ), - sleeps: Self.mergeSleep(imported: impSleep, computed: compSleep), + sleeps: mergedSleeps, vitalRows: Self.sourceRows(imported: imported, computed: computed, apple: apple), freshness: Self.computeFreshness(imported: imported, computed: computed, apple: apple, importedSleeps: impSleep, computedSleeps: compSleep)) @@ -881,6 +888,71 @@ final class Repository: ObservableObject { return days } + /// Resolves a night's debt after a manual edit. The exported debt remains the baseline, adjusted by + /// the exact change in asleep minutes. Old imports without an original duration keep the existing + /// need-minus-asleep approximation. + nonisolated static func resolvedSleepDebtMinutes(imported: ImportedSleepFigures?, + actualSleepMin: Double?, + fallbackNeedMin: Double, + isUserEdited: Bool) -> Double? { + if let debt = imported?.debtMin, !isUserEdited { return debt } + guard let actualSleepMin, actualSleepMin > 0, fallbackNeedMin > 0 else { return nil } + if isUserEdited, + let debt = imported?.debtMin, + let originalSleepMin = imported?.originalSleepMin, + originalSleepMin > 0 { + return Swift.max(0, debt + originalSleepMin - actualSleepMin) + } + return Swift.max(0, fallbackNeedMin - actualSleepMin) + } + + /// Rebuild the sleep-only fields for days with a manually corrected session. Historical imports have no + /// raw streams, so `IntelligenceEngine` cannot always create a computed daily row after an edit; deriving + /// this overlay from the stored, re-clipped stages makes the edited duration immediately authoritative. + nonisolated static func applyingEditedSleepSessions(_ sessions: [CachedSleepSession], + to days: [DailyMetric]) -> [DailyMetric] { + guard sessions.contains(where: \.userEdited) else { return days } + let offsetSec = TimeZone.current.secondsFromGMT() + let habitualBlocks = sessions.compactMap { session -> SleepStageTotals.HistoryBlock? in + let start = session.effectiveStartTs, end = session.endTs + guard end > start else { return nil } + let midpoint = start + (end - start) / 2 + return SleepStageTotals.HistoryBlock( + start: start, + end: end, + dayKey: AnalyticsEngine.dayString(midpoint, offsetSec: offsetSec) + ) + } + let habitualMidsleepSec = SleepStageTotals.habitualMidsleepSec(habitualBlocks, offsetSec: offsetSec) + let sessionsByDay = Dictionary(grouping: sessions) { session in + AnalyticsEngine.dayString(session.endTs, offsetSec: offsetSec) + } + + return days.map { daily in + guard let daySessions = sessionsByDay[daily.day], daySessions.contains(where: \.userEdited) else { + return daily + } + let editedStages = Dictionary( + daySessions.filter(\.userEdited).map { ($0.startTs, $0.stagesJSON) }, + uniquingKeysWith: { first, _ in first } + ) + let onsets = Dictionary( + daySessions.map { ($0.startTs, $0.effectiveStartTs) }, + uniquingKeysWith: { first, _ in first } + ) + guard let aggregate = SleepStageTotals.dailyAggregateHonoringEdits( + detected: daySessions.map { (startTs: $0.startTs, stagesJSON: $0.stagesJSON) }, + edited: editedStages, + onsetByStart: onsets, + offsetSec: offsetSec, + habitualMidsleepSec: habitualMidsleepSec + ), aggregate.editApplied else { + return daily + } + return daily.takingEditedSleepFields(from: aggregate.sleep) + } + } + /// Daily rows tagged with the source that supplied them, for the source-aware vital-sign cards. /// One entry per (source, day) , the consumer resolves precedence per metric (imported > computed /// > Apple); ordered by day, then source priority, so a stable list reaches the UI. @@ -2830,4 +2902,30 @@ private extension DailyMetric { spo2Ir: spo2Ir ) } + + /// Applies the stage totals re-derived from a user-edited session while retaining fields that stages + /// cannot truthfully recompute, such as disturbances and overnight vitals. + func takingEditedSleepFields(from source: SleepStageTotals.DailySleep) -> DailyMetric { + DailyMetric( + day: day, + totalSleepMin: source.totalSleepMin, + efficiency: source.efficiency, + deepMin: source.deepMin, + remMin: source.remMin, + lightMin: source.lightMin, + disturbances: disturbances, + restingHr: restingHr, + avgHrv: avgHrv, + recovery: recovery, + strain: strain, + exerciseCount: exerciseCount, + spo2Pct: spo2Pct, + skinTempDevC: skinTempDevC, + respRateBpm: respRateBpm, + steps: steps, + activeKcalEst: activeKcalEst, + spo2Red: spo2Red, + spo2Ir: spo2Ir + ) + } } diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index 751dda4069..5ca96ee6d9 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -2319,15 +2319,20 @@ struct SleepView: View { metric { $0.respRateBpm } } - /// Sleep debt (minutes): the imported sleep_debt_min when the export carried it; else - /// the APPROXIMATE per-night need − asleep, floored at 0 (no "credit"). + /// Sleep debt (minutes): the imported sleep_debt_min when the export carried it. A manually corrected + /// night keeps that baseline and adjusts it by the exact change in asleep minutes; imports without an + /// original duration fall back to the APPROXIMATE per-night need − asleep, floored at 0 (no "credit"). private var sleepDebtSeries: Metric { let imported = repo.importedSleep let need = sleepNeedMin + let editedDays = Repository.userEditedDays(repo.sleeps) let series = repo.days.compactMap { d -> Double? in - if let debt = imported[d.day]?.debtMin { return debt } // minutes, export-verbatim - guard let asleep = d.totalSleepMin, asleep > 0, need > 0 else { return nil } - return Swift.max(0, need - asleep) // APPROXIMATE fallback + Repository.resolvedSleepDebtMinutes( + imported: imported[d.day], + actualSleepMin: d.totalSleepMin, + fallbackNeedMin: need, + isUserEdited: editedDays.contains(d.day) + ) } return (series.last, mean(series), series) } diff --git a/StrandTests/EditMergePrecedenceTests.swift b/StrandTests/EditMergePrecedenceTests.swift index b72bab0775..1b4967c7a7 100644 --- a/StrandTests/EditMergePrecedenceTests.swift +++ b/StrandTests/EditMergePrecedenceTests.swift @@ -79,6 +79,34 @@ final class EditMergePrecedenceTests: XCTestCase { XCTAssertEqual(days, [expectedDay]) } + /// A historical import has no raw streams, so editing its cached sleep must still rebuild the daily + /// duration. Otherwise the debt ledger continues to read the original import's total after the edit. + func testEditedImportedSleepRebuildsDailySleepFields() { + let endTs = 1_780_000_000 + let offsetSec = TimeZone.current.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(endTs))) + let day = AnalyticsEngine.dayString(endTs, offsetSec: offsetSec) + let imported = full(day: day, totalSleepMin: 480, deepMin: 90, remMin: 110, + lightMin: 280, efficiency: 0.92, recovery: 80, strain: 9.0) + let corrected = CachedSleepSession( + startTs: endTs - 360 * 60, + endTs: endTs, + efficiency: 0.85, + restingHr: 52, + avgHrv: 70, + stagesJSON: #"{"awake":20,"light":150,"deep":60,"rem":70}"#, + userEdited: true + ) + + let rebuilt = Repository.applyingEditedSleepSessions([corrected], to: [imported]) + + XCTAssertEqual(rebuilt[0].totalSleepMin, 280) + XCTAssertEqual(rebuilt[0].deepMin, 60) + XCTAssertEqual(rebuilt[0].remMin, 70) + XCTAssertEqual(rebuilt[0].lightMin, 150) + XCTAssertEqual(try XCTUnwrap(rebuilt[0].efficiency), 280.0 / 300.0, accuracy: 0.0001) + XCTAssertEqual(rebuilt[0].recovery, 80, "non-sleep import fields must remain intact") + } + // MARK: - sleep_performance daily-column derivation (#614) // // The resolver derives the Rest composite from a banked DailyMetric's sleep totals when no diff --git a/StrandTests/SleepDebtEditTests.swift b/StrandTests/SleepDebtEditTests.swift new file mode 100644 index 0000000000..acd05c5acf --- /dev/null +++ b/StrandTests/SleepDebtEditTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import Strand + +final class SleepDebtEditTests: XCTestCase { + func testEditedNightAdjustsImportedDebtBySleepDurationDelta() { + let imported = ImportedSleepFigures( + debtMin: 60, + originalSleepMin: 480 + ) + + let debt = Repository.resolvedSleepDebtMinutes( + imported: imported, + actualSleepMin: 410, + fallbackNeedMin: 450, + isUserEdited: true + ) + + XCTAssertEqual(debt, 130) + } + + func testEditedNightWithoutOriginalDurationUsesExistingFallback() { + let debt = Repository.resolvedSleepDebtMinutes( + imported: ImportedSleepFigures(debtMin: 60), + actualSleepMin: 410, + fallbackNeedMin: 450, + isUserEdited: true + ) + + XCTAssertEqual(debt, 40) + } +} 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 22dc2ce58c..4cce72179c 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1204,6 +1204,21 @@ class WhoopRepository(private val dao: WhoopDao) { suspend fun metricSeries(deviceId: String, key: String, from: String, to: String) = dao.metricSeries(deviceId, key, from, to) + /** + * Imported [key] series across the active-strap and canonical import union. Active rows win on a + * shared day, matching [importedDailyMetricsUnion] and the Swift import resolver. + */ + suspend fun metricSeriesImportedUnion( + activeStrapId: String, + key: String, + from: String, + to: String, + ): List { + val ids = importedSourceIds(activeStrapId) + if (ids.size == 1) return metricSeries(ids[0], key, from, to) + return mergeComputedSeriesUnion(ids.map { metricSeries(it, key, from, to) }) + } + /** * Computed ("-noop") [key] series across the active-strap UNION (the active strap's own computed * sibling + the canonical "my-whoop-noop"), deduped per day with the active strap winning. This is @@ -1547,6 +1562,10 @@ class WhoopRepository(private val dao: WhoopDao) { suspend fun dailyMetrics(deviceId: String, from: String, to: String): List = dao.dailyMetricsRange(deviceId, from, to) + /** Imported daily metrics across the active-strap and canonical import union, active rows first. */ + suspend fun importedDailyMetricsUnion(deviceId: String, from: String, to: String): List = + unionByDay(importedSourceIds(deviceId).map { dao.dailyMetricsRange(it, from, to) }) + // MARK: - Cross-source resolver (PR#196 , freshest-wins charts/metrics) // // Product surfaces (Compare/Insights/Stress/Explore/Today) historically read rows under the EXACT diff --git a/android/app/src/main/java/com/noop/ui/SleepModelLogic.kt b/android/app/src/main/java/com/noop/ui/SleepModelLogic.kt index b1a362f64a..56be0739af 100644 --- a/android/app/src/main/java/com/noop/ui/SleepModelLogic.kt +++ b/android/app/src/main/java/com/noop/ui/SleepModelLogic.kt @@ -95,6 +95,8 @@ internal fun buildSleepModel( days: List, session: SleepSession?, imported: ImportedSleepSeries = ImportedSleepSeries(), + // A manually corrected night adjusts the export's original debt by its sleep-duration delta. + editedDays: Set = emptySet(), selectedDay: String? = null, // The bridged main-night GROUP's summed stage minutes + full-night segments (#561), threaded from // selectNight so a biphasic night's hero shows the WHOLE night, not one fragment (#555). Null for a @@ -191,9 +193,13 @@ internal fun buildSleepModel( val respiratory = metric(days) { it.respRateBpm } val sleepDebt = run { val series = days.mapNotNull { d -> - imported.debtMin[d.day] // minutes, export-verbatim - ?: d.totalSleepMin?.takeIf { it > 0.0 && needMin > 0.0 } - ?.let { max(0.0, needMin - it) } // APPROXIMATE fallback + resolvedSleepDebtMinutes( + day = d.day, + actualSleepMin = d.totalSleepMin, + fallbackNeedMin = needMin, + imported = imported, + isEdited = d.day in editedDays, + ) } Metric(series.lastOrNull(), mean(series), series) } @@ -204,9 +210,14 @@ internal fun buildSleepModel( val trendHours = trendRows.mapNotNull { it.totalSleepMin?.let { minutes -> minutes / 60.0 } } val trendNeedHours = trendRows.map { row -> ((imported.needMin[row.day] ?: needMin) / 60.0) } val trendDebtHours = trendRows.map { row -> - val sleptMin = row.totalSleepMin ?: 0.0 val neededMin = imported.needMin[row.day] ?: needMin - ((imported.debtMin[row.day] ?: max(0.0, neededMin - sleptMin)) / 60.0) + (resolvedSleepDebtMinutes( + day = row.day, + actualSleepMin = row.totalSleepMin, + fallbackNeedMin = neededMin, + imported = imported, + isEdited = row.day in editedDays, + ) ?: 0.0) / 60.0 } val trendDates = trendRows.map { it.day } @@ -258,6 +269,27 @@ internal fun buildSleepModel( ) } +/** Keeps export-derived debt accurate after a hand edit without discarding its prior history. */ +internal fun resolvedSleepDebtMinutes( + day: String, + actualSleepMin: Double?, + fallbackNeedMin: Double, + imported: ImportedSleepSeries, + isEdited: Boolean, +): Double? { + val debt = imported.debtMin[day] + if (debt != null && !isEdited) return debt + + val actual = actualSleepMin?.takeIf { it > 0.0 } ?: return null + if (isEdited) { + val original = imported.originalSleepMin[day] + if (debt != null && original != null && original > 0.0) { + return max(0.0, debt + original - actual) + } + } + return fallbackNeedMin.takeIf { it > 0.0 }?.let { max(0.0, it - actual) } +} + /** * #940 no-blank fallback: one impossible/stage-less SELECTED day (typically the newest, after a bad * hand-edit staged it all-awake) must not hide the whole tab's full-history surfaces. Re-anchor the @@ -269,11 +301,12 @@ internal fun buildSleepModel( internal fun fallbackSleepModel( days: List, imported: ImportedSleepSeries = ImportedSleepSeries(), + editedDays: Set = emptySet(), ): SleepModel? { val anchorDay = days.lastOrNull { (it.deepMin ?: 0.0) + (it.remMin ?: 0.0) + (it.lightMin ?: 0.0) > 0.0 }?.day ?: return null - return buildSleepModel(days, null, imported, selectedDay = anchorDay) + return buildSleepModel(days, null, imported, editedDays, selectedDay = anchorDay) } /** Build a metric from a per-day transform, keeping only finite values. */ diff --git a/android/app/src/main/java/com/noop/ui/SleepModels.kt b/android/app/src/main/java/com/noop/ui/SleepModels.kt index 9658a042f5..076ecb2e40 100644 --- a/android/app/src/main/java/com/noop/ui/SleepModels.kt +++ b/android/app/src/main/java/com/noop/ui/SleepModels.kt @@ -30,6 +30,8 @@ internal data class ImportedSleepSeries( val consistency: Map = emptyMap(), // sleep_consistency, 0–100 val needMin: Map = emptyMap(), // sleep_need_min, minutes val debtMin: Map = emptyMap(), // sleep_debt_min, minutes + // Original export duration, retained when an edited daily row replaces its sleep fields. + val originalSleepMin: Map = emptyMap(), ) /** Everything the screen renders, derived once per data change. */ diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index a15bb82bad..fe9ce51d77 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -247,13 +247,19 @@ fun SleepScreen( var imported by remember { mutableStateOf(ImportedSleepSeries()) } LaunchedEffect(days) { suspend fun load(key: String) = runCatching { - vm.repo.metricSeries("my-whoop", key, "0000-00-00", "9999-99-99") + vm.repo.metricSeriesImportedUnion(vm.activeStrapId, key, "0000-00-00", "9999-99-99") }.getOrDefault(emptyList()).associate { it.day to it.value } + val originalSleepMin = runCatching { + vm.repo.importedDailyMetricsUnion(vm.activeStrapId, "0000-00-00", "9999-99-99") + }.getOrDefault(emptyList()).mapNotNull { day -> + day.totalSleepMin?.takeIf { it > 0.0 }?.let { day.day to it } + }.toMap() imported = ImportedSleepSeries( performance = load("sleep_performance"), consistency = load("sleep_consistency"), needMin = load("sleep_need_min"), debtMin = load("sleep_debt_min"), + originalSleepMin = originalSleepMin, ) } @@ -364,13 +370,23 @@ fun SleepScreen( val night = remember(nightOffset, navDays, days, habitualMidsleep, motionByStart) { selectNight(navDays, days, nightOffset, habitualMidsleep, motionByStart) } + // The read-side union may hold an imported sleep under the canonical owner and an edited one + // under the active strap. Resolve every edited session against the same UTC/local day keys the + // hero uses, so its exported debt never masks the corrected duration. + val editedDays = remember(sleeps, days) { + sleeps.asSequence().filter { it.userEdited }.map { sleep -> + val utcDay = AnalyticsEngine.dayString(sleep.endTs) + val localDay = localDayString(sleep.endTs) + listOf(utcDay, localDay).firstOrNull { candidate -> days.any { it.day == candidate } } ?: utcDay + }.toSet() + } // The HERO follows the selected night (its stage breakdown comes from that day's row); the // at-a-glance TILES, the debt ledger, the personal need and the trend stay full-history / // latest-anchored, matching iOS SleepView. `selectedDay` re-points only the hero. Model is null // when the selected day has no stage minutes. (#5) - val model = remember(days, night, imported) { - buildSleepModel(days, night?.session, imported, selectedDay = night?.dayKey, + val model = remember(days, night, imported, editedDays) { + buildSleepModel(days, night?.session, imported, editedDays, selectedDay = night?.dayKey, heroStages = night?.groupStages, heroSegments = night?.groupSegments) } val display = remember(model, night) { heroDisplay(model, night) } @@ -382,7 +398,9 @@ fun SleepScreen( // newest stage-bearing day instead of vanishing. The HERO stays on `model`/`display` (an // honest no-stage-data fallback for the bad day, edit pencil reachable). Null only when NO day // has stage data: the true first-run empty state. - val tilesModel = remember(model, days, imported) { model ?: fallbackSleepModel(days, imported) } + val tilesModel = remember(model, days, imported, editedDays) { + model ?: fallbackSleepModel(days, imported, editedDays) + } // Jump straight to a night by its (local) wake-day — the center date block opens a picker. // navDays is newest-day-first, so the day's index IS its offset (0 = last night). (#160, #59) diff --git a/android/app/src/test/java/com/noop/ui/SleepImportedFiguresTest.kt b/android/app/src/test/java/com/noop/ui/SleepImportedFiguresTest.kt index 0d16454928..cd446ebab8 100644 --- a/android/app/src/test/java/com/noop/ui/SleepImportedFiguresTest.kt +++ b/android/app/src/test/java/com/noop/ui/SleepImportedFiguresTest.kt @@ -38,6 +38,27 @@ class SleepImportedFiguresTest { assertEquals(60.0, m.sleepDebt.latest!!, 1e-9) } + /** A hand-edited night adjusts the export's debt by its exact asleep-minute delta. */ + @Test + fun editedNightAdjustsImportedDebtBySleepDurationDelta() { + val days = listOf(day("2026-06-01", 420.0), day("2026-06-02", 410.0)) + val imported = ImportedSleepSeries( + debtMin = mapOf("2026-06-01" to 30.0, "2026-06-02" to 60.0), + originalSleepMin = mapOf("2026-06-02" to 480.0), + ) + + val m = buildSleepModel( + days, + session = null, + imported = imported, + editedDays = setOf("2026-06-02"), + )!! + + // Original 60 min debt plus 70 min less sleep than the 480 min export = 130 min. + assertEquals(130.0, m.sleepDebt.latest!!, 1e-9) + assertEquals(listOf(0.5, 130.0 / 60.0), m.trendDebtHours) + } + @Test fun hoursVsNeededUsesImportedNeedPerDay() { val days = listOf(day("2026-06-01", 400.0)) From 3f7f31ac9a4e2eca4ed08455b52e8af410865124 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:45:55 +0200 Subject: [PATCH 3/3] fix(sleep): key edited wake-days through one resolver, probe owners additively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three correctness follow-ups to the reviewed revision. Day keying. applyingEditedSleepSessions grouped its sessions with a single current-zone offset applied across all history, while userEditedDays — which decides where the debt correction lands — resolves the offset at each session's own end instant. The two could therefore disagree about which day a night belongs to across a DST boundary, leaving the rebuilt sleep fields on one day and the debt correction on another. The keyer existed in three copies, one of them wrong; it is now Repository.sleepEndDayKey and userEditedDays, mergeSleep and the edited-day overlay all go through it. This is the #406 (Swift) / #304 (Android) local wake-day rule that MergeSleepLocalDayTest pins. Owner probe order. sleepOwnerIds was computedReadIds + importedReadIds, putting the CANONICAL computed namespace ahead of the ACTIVE imported one. That demotes the active imported source relative to the previous computed-then-imported chain and lets a coincidental same-detectedStartTs row capture an edit — the failure the single fallback was written to avoid. The order is now strictly additive over the pre-union behaviour: the active strap's two namespaces keep their exact previous order, and the canonical pair is appended as a fallback that was not probed at all before, so no case that worked before can regress. The doc comment also claimed computed rows outrank imported ones, which SleepMerge.merge documents the other way round; corrected. Android edited-day set. It tried a UTC key first and fell back to UTC, which re-opens #304 for a UTC+ user who wakes after local midnight but before UTC midnight. It now uses localDayString, the helper this same file already uses eight lines above to group sleeps by local day. --- Strand/Data/Repository.swift | 56 +++++++++++++------ .../src/main/java/com/noop/ui/SleepScreen.kt | 17 +++--- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 46b225c7ff..8232791cc9 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -167,15 +167,26 @@ final class Repository: ObservableObject { computedDeviceId == canonicalComputedId ? [computedDeviceId] : [computedDeviceId, canonicalComputedId] } - /// Every namespace that can own a sleep row currently visible in the app. Computed rows keep their - /// established precedence over imported rows; within each side the active source comes first. + /// Every namespace that can own a sleep row currently visible in the app, in the order a write probes + /// them. /// /// `CachedSleepSession` intentionally has no device id, so a write that starts from a merged Sleep-tab - /// row must resolve its owner against this same active-plus-canonical union used for reads. Otherwise a + /// row must resolve its owner across the same active-plus-canonical union the reads use. Otherwise a /// historical night under the canonical source after a strap re-add looks editable but its save is a /// no-op. + /// + /// Order is STRICTLY ADDITIVE over the pre-union behaviour: the ACTIVE strap's two namespaces keep the + /// exact order they were probed in before (computed, then imported), and the canonical pair is appended + /// only as a fallback that previously was not probed at all. Probing a canonical namespace ahead of the + /// active imported one would demote a live row below a historical twin and could redirect an edit to a + /// coincidental same-`detectedStartTs` row — the failure the single-fallback chain was written to avoid. private var sleepOwnerIds: [String] { - computedReadIds + importedReadIds + var ids: [String] = [] + for id in [computedDeviceId, deviceId, canonicalComputedId, canonicalDeviceId] + where !ids.contains(id) { + ids.append(id) + } + return ids } /// True when the ACTIVE strap is an Oura ring, resolved from its registry id prefix against the canonical @@ -876,16 +887,23 @@ final class Repository: ObservableObject { return byDay.values.sorted { $0.day < $1.day } } + /// The LOCAL wake-day a sleep session belongs to, keyed exactly as `DailyMetric.day` is. + /// + /// The zone offset is resolved AT the session's own end instant, never at "now": a single current-zone + /// offset applied across history mis-keys any night recorded under the other side of a DST switch, which + /// is the mis-attribution the local-day keying fixed (the Swift half of #406; mirrors the Android #304 + /// fix pinned by MergeSleepLocalDayTest). One definition so every consumer , `userEditedDays`, + /// `mergeSleep` and the edited-day overlay , keys identically and cannot drift apart. + nonisolated static func sleepEndDayKey(_ s: CachedSleepSession) -> String { + let offsetSec = TimeZone.current.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(s.endTs))) + return AnalyticsEngine.dayString(s.endTs, offsetSec: offsetSec) + } + /// The set of LOCAL wake-days that carry a user-edited sleep session , keyed exactly as /// `DailyMetric.day` is (the engine's cached-offset local-day keyer, matching `mergeSleep.endDay`). /// Drives the H5 edit-merge precedence in `mergeDaily`. nonisolated static func userEditedDays(_ sessions: [CachedSleepSession]) -> Set { - var days = Set() - for s in sessions where s.userEdited { - let offsetSec = TimeZone.current.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(s.endTs))) - days.insert(AnalyticsEngine.dayString(s.endTs, offsetSec: offsetSec)) - } - return days + Set(sessions.filter(\.userEdited).map(sleepEndDayKey)) } /// Resolves a night's debt after a manual edit. The exported debt remains the baseline, adjusted by @@ -912,21 +930,26 @@ final class Repository: ObservableObject { nonisolated static func applyingEditedSleepSessions(_ sessions: [CachedSleepSession], to days: [DailyMetric]) -> [DailyMetric] { guard sessions.contains(where: \.userEdited) else { return days } + // Scalar current-zone offset for the ANALYTICS calls only (the main-night pick and the habitual + // learner read a local time-of-day, matching every other caller of these APIs). Day KEYS must not + // use it , see `sleepEndDayKey`. let offsetSec = TimeZone.current.secondsFromGMT() let habitualBlocks = sessions.compactMap { session -> SleepStageTotals.HistoryBlock? in let start = session.effectiveStartTs, end = session.endTs guard end > start else { return nil } let midpoint = start + (end - start) / 2 + let midpointOffsetSec = TimeZone.current.secondsFromGMT( + for: Date(timeIntervalSince1970: TimeInterval(midpoint))) return SleepStageTotals.HistoryBlock( start: start, end: end, - dayKey: AnalyticsEngine.dayString(midpoint, offsetSec: offsetSec) + dayKey: AnalyticsEngine.dayString(midpoint, offsetSec: midpointOffsetSec) ) } let habitualMidsleepSec = SleepStageTotals.habitualMidsleepSec(habitualBlocks, offsetSec: offsetSec) - let sessionsByDay = Dictionary(grouping: sessions) { session in - AnalyticsEngine.dayString(session.endTs, offsetSec: offsetSec) - } + // Grouped through the SHARED keyer so this overlay lands on the same `DailyMetric.day` that + // `userEditedDays` (which decides where the debt correction applies) and `mergeSleep` resolved. + let sessionsByDay = Dictionary(grouping: sessions, by: sleepEndDayKey) return days.map { daily in guard let daySessions = sessionsByDay[daily.day], daySessions.contains(where: \.userEdited) else { @@ -977,10 +1000,7 @@ final class Repository: ObservableObject { /// across a midnight boundary for non-UTC users (the Swift half of #406; mirrors the Android #304 fix /// pinned by MergeSleepLocalDayTest). nonisolated private static func mergeSleep(imported: [CachedSleepSession], computed: [CachedSleepSession]) -> [CachedSleepSession] { - func endDay(_ s: CachedSleepSession) -> String { - let offsetSec = TimeZone.current.secondsFromGMT(for: Date(timeIntervalSince1970: TimeInterval(s.endTs))) - return AnalyticsEngine.dayString(s.endTs, offsetSec: offsetSec) - } + let endDay = sleepEndDayKey // #715, preserve EVERY session (a day with a main night + a nap must keep both); imported still // wins per end-day. Shared, unit-tested grouping (WhoopStore.SleepMerge / SleepMergeTests) replaces // the old per-day dictionary that silently dropped a second same-day session. diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index fe9ce51d77..71922e3f8f 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -370,15 +370,14 @@ fun SleepScreen( val night = remember(nightOffset, navDays, days, habitualMidsleep, motionByStart) { selectNight(navDays, days, nightOffset, habitualMidsleep, motionByStart) } - // The read-side union may hold an imported sleep under the canonical owner and an edited one - // under the active strap. Resolve every edited session against the same UTC/local day keys the - // hero uses, so its exported debt never masks the corrected duration. - val editedDays = remember(sleeps, days) { - sleeps.asSequence().filter { it.userEdited }.map { sleep -> - val utcDay = AnalyticsEngine.dayString(sleep.endTs) - val localDay = localDayString(sleep.endTs) - listOf(utcDay, localDay).firstOrNull { candidate -> days.any { it.day == candidate } } ?: utcDay - }.toSet() + // The read-side union may hold an imported sleep under the canonical owner and an edited one under + // the active strap, so the edited night must resolve to the SAME day key the daily rows carry or its + // exported debt masks the corrected duration. Keyed by LOCAL wake-day with the offset taken at the + // session's own end instant , the canonical `WhoopRepository.mergeSleep.endDay` rule (#304, pinned by + // MergeSleepLocalDayTest) and the twin of Swift `Repository.sleepEndDayKey`. A UTC key would re-open + // #304 for every UTC+ user who wakes after local midnight but before UTC midnight. + val editedDays = remember(sleeps) { + sleeps.asSequence().filter { it.userEdited }.map { localDayString(it.endTs) }.toSet() } // The HERO follows the selected night (its stage breakdown comes from that day's row); the