diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 94da6baebd..8232791cc9 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) @@ -165,6 +167,28 @@ final class Repository: ObservableObject { computedDeviceId == canonicalComputedId ? [computedDeviceId] : [computedDeviceId, canonicalComputedId] } + /// 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 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] { + 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 /// 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 @@ -731,18 +755,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)) @@ -858,16 +887,93 @@ 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)) + Set(sessions.filter(\.userEdited).map(sleepEndDayKey)) + } + + /// 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 } + // 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: midpointOffsetSec) + ) + } + let habitualMidsleepSec = SleepStageTotals.habitualMidsleepSec(habitualBlocks, 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 { + 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) } - return days } /// Daily rows tagged with the source that supplied them, for the source-aware vital-sign cards. @@ -894,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. @@ -1148,16 +1251,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 +1296,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 +1351,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 } @@ -2823,4 +2922,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/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 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..71922e3f8f 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,22 @@ 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, 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 // 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 +397,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))