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
13 changes: 13 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/TrainingSessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,19 @@ extension WhoopStore {
}
}

/// Removes the links for exactly these component keys and nothing else. Used when the component
/// itself is deleted, so a link cannot outlive the row it pointed at. Returns the rows removed.
@discardableResult
public func deleteTrainingSessionLinks(componentKeys: [String]) async throws -> Int {
guard !componentKeys.isEmpty else { return 0 }
return try syncWrite { db in
try db.execute(sql: """
DELETE FROM trainingSessionLink WHERE componentKey IN (\(databaseQuestionMarks(count: componentKeys.count)))
""", arguments: StatementArguments(componentKeys))
return db.changesCount
}
}

public func trainingSessionLinks() async throws -> [TrainingSessionLinkRow] {
try syncRead { db in
try Row.fetchAll(db, sql: "SELECT * FROM trainingSessionLink").map { row in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ final class TrainingSessionStoreTests: XCTestCase {
XCTAssertEqual(readPreferences, [preference])
}

func testDeletingLinksIsScopedToTheNamedComponents() async throws {
let store = try await WhoopStore.inMemory()
let links = ["native-training|1|strength", "manual|1|strength", "apple|2"].map {
TrainingSessionLinkRow(componentKey: $0, sessionId: "session|1", origin: "native-lifecycle",
updatedAtTs: 2_000)
}
try await store.upsertTrainingSessionLinks(links)
let removed = try await store.deleteTrainingSessionLinks(componentKeys: ["native-training|1|strength",
"missing"])
XCTAssertEqual(removed, 1)
let remaining = try await store.trainingSessionLinks().map(\.componentKey).sorted()
XCTAssertEqual(remaining, ["apple|2", "manual|1|strength"])
let none = try await store.deleteTrainingSessionLinks(componentKeys: [])
XCTAssertEqual(none, 0)
}

func testReplacingHeartRateBucketsIsScopedToOneComponent() async throws {
let store = try await WhoopStore.inMemory()
let metadata = ["a", "b"].map { key in
Expand Down
15 changes: 15 additions & 0 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,8 @@ final class IntelligenceEngine: ObservableObject {
/// `AppModel` wires it to `live.append(log:domain:)`. Each line is a concise, counts-only summary,
/// optionally tagged with the TestDomain so the Sleep/Battery emitters land under their profile tag.
var diagnosticSink: ((String, TestDomain?) -> Void)?
/// Sessions already reported by the "sleep motion: none persisted" line this launch.
private var loggedMissingMotionStarts: Set<Int> = []

init(repo: Repository, profile: ProfileStore, deviceId: String) {
self.repo = repo; self.profile = profile; self.deviceId = deviceId
Expand Down Expand Up @@ -3399,6 +3401,19 @@ final class IntelligenceEngine: ObservableObject {
for (start, motion) in motionByStart {
_ = try? await store.persistSessionMotion(deviceId: computedId, sessionStart: start, motionEpochs: motion)
}
// Always-on, rare-event evidence: a kept session that staged but got NO per-epoch motion shows
// "No movement detail" on the Sleep tab, and nothing in a report said why. States only what this
// pass observed (the engine's motion grid came back empty for that window) plus the two settings a
// reporter might suspect, and attributes no cause. Silent when every kept session has motion, and once
// per session per launch, so a re-score every few minutes does not repeat it.
let motionAwareWakeOn = PuffinExperiment.motionAwareWakeEnabled
for session in cachedSleepKept where motionByStart[session.startTs] == nil
&& loggedMissingMotionStarts.insert(session.startTs).inserted {
let sparse = session.stagingSparse.map { $0 ? "yes" : "no" } ?? "unknown"
diagnosticSink?("sleep motion: none persisted for session start=\(session.startTs) "
+ "dur=\((session.endTs - session.startTs) / 60)m (motion grid empty for its window) "
+ "sparse=\(sparse) motionAwareWake=\(motionAwareWakeOn ? "on" : "off")", nil)
}
// ── Persist per-epoch BAND sleep_state (#175) beside each kept session's stagesJSON ──────────────
// This is the source `sessionSleepStateJSON` lacked (v7.7.0 finding: the write path had no producer
// because the raw stream was dropped at extraction). Now analyzeDay grids the RAW `sleepStateSample`
Expand Down
58 changes: 53 additions & 5 deletions Strand/Data/Repository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3507,14 +3507,22 @@ final class Repository: ObservableObject {
return rows.filter { row in
guard !row.source.hasPrefix("native-training"),
WorkoutSource.sportKey(row.sport) == strength else { return true }
return !natives.contains { native in
let overlap = min(row.endTs, native.endTs) - max(row.startTs, native.startTs)
let shorter = min(row.endTs - row.startTs, native.endTs - native.startTs)
return shorter > 0 && overlap * 2 >= shorter
}
return !natives.contains { isLegacyStrengthTwin(row, of: $0) }
}
}

/// True when `recording` is the legacy manual twin of `native`: the two overlap by at least half of
/// the shorter one. A ZERO-LENGTH side (a start/stop inside the same second, listed as "0m") has no
/// half to overlap, so it counts as a twin when its instant falls inside the other span. Without that
/// fallback the twin of an accidental start was never hidden, the list showed the session twice, and
/// deleting one copy left the other on screen (#2278). Sport is the caller's check.
nonisolated static func isLegacyStrengthTwin(_ recording: WorkoutRow, of native: WorkoutRow) -> Bool {
let overlap = min(recording.endTs, native.endTs) - max(recording.startTs, native.startTs)
let shorter = min(recording.endTs - recording.startTs, native.endTs - native.startTs)
if shorter > 0 { return overlap * 2 >= shorter }
return overlap >= 0
}

private func pagedWorkoutRows(store: WhoopStore, deviceId: String,
from: Int, to: Int) async -> [WorkoutRow] {
let pageSize = 500
Expand Down Expand Up @@ -3814,6 +3822,10 @@ final class Repository: ObservableObject {
func deleteWorkout(_ row: WorkoutRow) async {
if WorkoutSource.classify(row.source) == .detected { await dismissDetected(row); return }
guard let store = await ensureStore() else { return }
if row.source.hasPrefix("native-training") {
await deleteNativeWorkoutRow(row, store: store)
return
}
// Sweep every STRAP namespace, not just the active one. A manual row banked under a retained
// strap or a computed sibling is shown by `workoutRows` and was previously undeletable: the
// delete touched one namespace, the reload re-read the row from another, and it reappeared
Expand All @@ -3832,6 +3844,42 @@ final class Repository: ObservableObject {
}
}

/// Delete a native strength session shown in the Workouts list.
///
/// A native row is a READ-TIME projection of `trainingWorkoutNative`; no `workout` row backs it, so the
/// natural-key sweep in `deleteWorkout` matched nothing and the session reappeared on reload. Classified
/// `.manual`, it was offered Delete all the same, so every native session was visible but undeletable.
///
/// Removes, in order: the native workout (its exercises and sets cascade), the session link that
/// pointed at it, and the legacy manual "Strength Training" recording the list was hiding as its twin.
/// The last one matters: `hidingLegacyStrengthRecordings` only hides a twin while its native row exists,
/// so deleting the native alone would uncover the recording and the delete would again look ignored.
private func deleteNativeWorkoutRow(_ row: WorkoutRow, store: WhoopStore) async {
let natives = ((try? await store.nativeWorkouts(from: row.startTs, to: row.startTs)) ?? [])
.filter { NativeTrainingProjection.workoutRow($0).source == row.source }
guard !natives.isEmpty else { return }
let strength = WorkoutSource.sportKey("Strength Training")
let namespaces = Self.deletableWorkoutNamespaces(rawIds: rawPhysiologyReadIds(store: store))
var linkKeys: [String] = []
for native in natives {
let projected = NativeTrainingProjection.workoutRow(native)
linkKeys.append("\(projected.source)|\(projected.startTs)|\(WorkoutSource.sportKey(projected.sport))")
do { try await store.deleteNativeWorkout(id: native.id) } catch { continue }
for id in namespaces {
let candidates = (try? await store.workouts(deviceId: id, from: projected.startTs - 86_400,
to: projected.endTs, limit: 500)) ?? []
for twin in candidates where WorkoutSource.sportKey(twin.sport) == strength
&& WorkoutSource.classify(twin.source) == .manual
&& Self.isLegacyStrengthTwin(twin, of: projected) {
RouteStore.remove(startTs: twin.startTs, sport: twin.sport)
_ = try? await store.deleteWorkouts(deviceId: id, sport: twin.sport,
from: twin.startTs, to: twin.startTs)
}
}
}
_ = try? await store.deleteTrainingSessionLinks(componentKeys: linkKeys)
}

/// #64: merge two-or-more overlapping / adjacent MANUAL or DETECTED sessions into ONE manual session
/// (`merged`, built by the pure `WorkoutMerge.merge`), then retire the originals. Imported history is
/// NEVER passed here (the caller gates on `WorkoutMerge.canMerge`, and this only writes the manual-row
Expand Down
64 changes: 64 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -435589,6 +435589,70 @@
}
}
}
},
"Movement data was patchy this night, so the stages are a rough estimate." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Die Bewegungsdaten waren diese Nacht lückenhaft, daher sind die Phasen nur grob geschätzt."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Movement data was patchy this night, so the stages are a rough estimate."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Los datos de movimiento fueron irregulares esta noche, así que las fases son una estimación aproximada."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les données de mouvement étaient incomplètes cette nuit, les phases sont donc une estimation approximative."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "I dati di movimento sono stati discontinui questa notte, quindi le fasi sono una stima approssimativa."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dane o ruchu tej nocy były niepełne, więc fazy snu są jedynie przybliżonym oszacowaniem."
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "translated",
"value" : "Os dados de movimento estiveram incompletos esta noite, pelo que as fases são uma estimativa aproximada."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Данные о движении в эту ночь были неполными, поэтому стадии сна — лишь приблизительная оценка."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "这一晚的体动数据不完整,因此睡眠阶段只是粗略估计。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "這一晚的體動資料不完整,因此睡眠階段只是粗略估計。"
}
}
}
}
},
"version" : "1.0"
Expand Down
21 changes: 20 additions & 1 deletion Strand/Screens/SleepModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,22 @@ extension SleepModel {
return SleepView.isPreOnsetAwakeStub(spanMin: spanMin, asleepMin: asleepMin, refAsleepMin: refAsleepMin)
}

/// A fragment's persisted motion, trimmed to the window the night now shows.
///
/// `motionJSON` is gridded from the DETECTED start at the time of staging, and a sleep edit keeps it
/// as-is while moving `effectiveStartTs` / `endTs`. Appended untrimmed, a later onset or an earlier wake
/// left the trace wider than the stage timeline above it, so restless bursts drew under the wrong
/// stages. Drops the epochs before the corrected onset and after the corrected wake. An onset moved
/// EARLIER is left unpadded: there is no motion for that time, and zeros would draw a still sleeper.
nonisolated static func alignedMotion(_ epochs: [Double], detectedStartTs: Int, effectiveStartTs: Int,
endTs: Int) -> [Double] {
let epochS = Int(SleepStager.epochS)
let lead = max(0, effectiveStartTs - detectedStartTs) / epochS
guard lead < epochs.count, endTs > effectiveStartTs else { return [] }
let span = Int((Double(endTs - max(effectiveStartTs, detectedStartTs)) / Double(epochS)).rounded(.up))
return Array(epochs[lead ..< min(epochs.count, lead + span)])
}

/// Build the hero `Night` for a day around its MAIN-night GROUP, bridged the way
/// `AnalyticsEngine.analyzeDay` bridges it. Mirrors the former `SleepView.mergeDay`. Returns nil
/// if the group decodes to no usable stages. (#170, #318, #518, #555, #561, #736, #364, #407)
Expand All @@ -344,7 +360,10 @@ extension SleepModel {
stages.awake += st.awake; stages.light += st.light
stages.deep += st.deep; stages.rem += st.rem
}
if let m = motionByStart[frag.startTs] { motion.append(contentsOf: m) }
if let m = motionByStart[frag.startTs] {
motion.append(contentsOf: alignedMotion(m, detectedStartTs: frag.startTs,
effectiveStartTs: frag.effectiveStartTs, endTs: frag.endTs))
}
}
let orderedFrags = Array(group)
for (prev, next) in zip(orderedFrags, orderedFrags.dropFirst()) {
Expand Down
Loading
Loading