Skip to content
Closed
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
197 changes: 161 additions & 36 deletions Strand/Data/Repository.swift

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions Strand/Screens/SleepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
28 changes: 28 additions & 0 deletions StrandTests/EditMergePrecedenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions StrandTests/ReadSpineActiveDeviceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions StrandTests/SleepDebtEditTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 19 additions & 0 deletions android/app/src/main/java/com/noop/data/WhoopRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<MetricSeriesRow> {
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
Expand Down Expand Up @@ -1547,6 +1562,10 @@ class WhoopRepository(private val dao: WhoopDao) {
suspend fun dailyMetrics(deviceId: String, from: String, to: String): List<DailyMetric> =
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<DailyMetric> =
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
Expand Down
45 changes: 39 additions & 6 deletions android/app/src/main/java/com/noop/ui/SleepModelLogic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ internal fun buildSleepModel(
days: List<DailyMetric>,
session: SleepSession?,
imported: ImportedSleepSeries = ImportedSleepSeries(),
// A manually corrected night adjusts the export's original debt by its sleep-duration delta.
editedDays: Set<String> = 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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 }

Expand Down Expand Up @@ -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
Expand All @@ -269,11 +301,12 @@ internal fun buildSleepModel(
internal fun fallbackSleepModel(
days: List<DailyMetric>,
imported: ImportedSleepSeries = ImportedSleepSeries(),
editedDays: Set<String> = 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. */
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/java/com/noop/ui/SleepModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ internal data class ImportedSleepSeries(
val consistency: Map<String, Double> = emptyMap(), // sleep_consistency, 0–100
val needMin: Map<String, Double> = emptyMap(), // sleep_need_min, minutes
val debtMin: Map<String, Double> = emptyMap(), // sleep_debt_min, minutes
// Original export duration, retained when an edited daily row replaces its sleep fields.
val originalSleepMin: Map<String, Double> = emptyMap(),
)

/** Everything the screen renders, derived once per data change. */
Expand Down
25 changes: 21 additions & 4 deletions android/app/src/main/java/com/noop/ui/SleepScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand Down Expand Up @@ -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) }
Expand All @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions android/app/src/test/java/com/noop/ui/SleepImportedFiguresTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down