diff --git a/Packages/WhoopStore/Sources/WhoopStore/TrainingSessionStore.swift b/Packages/WhoopStore/Sources/WhoopStore/TrainingSessionStore.swift index 920a30bddb..98a4e73e10 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/TrainingSessionStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/TrainingSessionStore.swift @@ -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 diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/TrainingSessionStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/TrainingSessionStoreTests.swift index 28362d97e8..18e2cd9685 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/TrainingSessionStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/TrainingSessionStoreTests.swift @@ -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 diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index c309532d90..8afff85e81 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -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 = [] init(repo: Repository, profile: ProfileStore, deviceId: String) { self.repo = repo; self.profile = profile; self.deviceId = deviceId @@ -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` diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 29f9fbbdb6..e92d681ddc 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -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 @@ -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 @@ -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 diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 234266e1e1..52e900ed58 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -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" diff --git a/Strand/Screens/SleepModel.swift b/Strand/Screens/SleepModel.swift index cc80292557..eb57c7751c 100644 --- a/Strand/Screens/SleepModel.swift +++ b/Strand/Screens/SleepModel.swift @@ -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) @@ -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()) { diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index 876e3b1009..5a7b54accb 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -49,6 +49,12 @@ struct SleepView: View { /// The Sleep tab's stage-chart shape (Settings → Appearance → Sleep chart). Display-only; Filled/Ribbon /// draw the WHOOP-style stepped hypnogram, Classic keeps the per-stage rows. Mirrors Android. (#sleep-chart-style) @AppStorage(SleepChartStyle.storageKey) private var sleepChartStyleRaw = SleepChartStyle.classic.rawValue + /// The Today dashboard style. Only `.liquid` keeps the liquid sleep gauge; every other style draws the + /// same ring Classic Today does, so turning Liquid off is not undone by the Sleep tab. + @AppStorage(TodayDashboardStyle.storageKey) private var todayDashboardStyleRaw = TodayDashboardStyle.liquid.rawValue + private var heroUsesLiquidStyle: Bool { + (TodayDashboardStyle.resolve(todayDashboardStyleRaw) ?? .liquid) == .liquid + } /// Which night the hero hypnogram shows: 0 = last night, N = N sleep-sessions back. /// Snaps back to 0 whenever the data key changes — a stale offset would silently point /// at a different session after a sync. The memoized trend `model` stays cached since @@ -461,26 +467,28 @@ struct SleepView: View { // The sleep score is named ONCE here ("Sleep performance"); the old trailing "Rest" chip and the // duplicate "Rest" night-detail tile showed the same number under a second name (redesign bug §1). SectionHeader("Sleep performance", overline: nightRelativeLabel) - // A subtle night atmosphere sits behind the sleep hero ONLY (the Rest world's whisper: - // faint indigo wash + crescent moon over the near-black canvas, no glow), clipped to the - // card. Replaces the now-flat ScenicHeroBackground here. - VStack(spacing: NoopMetrics.space4) { + let content = VStack(spacing: NoopMetrics.space4) { if let score { - // The signature liquid gauge: a filling vessel tinted Rest, with the 0–100 score - // counting up over it and a short state word beneath — the same `LiquidScoreGauge` - // Today's HeroScoreCell draws, so both heroes fill and roll up identically. The - // gauge drives its own count-up, which is why the screen no longer keeps a separate - // `heroFraction` animation state. VStack(spacing: NoopMetrics.space3) { - LiquidScoreGauge( - score: score, - tint: StrandPalette.restColor, - diameter: 184, - animated: true, - captionText: String(localized: "of 100"), - numberColor: StrandPalette.textPrimary, - captionColor: StrandPalette.textSecondary - ) + if heroUsesLiquidStyle { + // The signature liquid gauge: a filling vessel tinted Rest, with the 0–100 + // score counting up over it — the same `LiquidScoreGauge` Liquid Today's + // HeroScoreCell draws, so both heroes fill and roll up identically. + LiquidScoreGauge( + score: score, + tint: StrandPalette.restColor, + diameter: 184, + animated: true, + captionText: String(localized: "of 100"), + numberColor: StrandPalette.textPrimary, + captionColor: StrandPalette.textSecondary + ) + } else { + // Classic, Trends and Overview draw Today's ring, not the liquid vessel: the + // hero follows the one dashboard-style setting like every other screen. + GlowRing(fraction: score / 100, value: score, format: { "\(Int($0.rounded()))" }, + color: StrandPalette.restColor, diameter: 172, lineWidth: 17.2) + } Text(sleepScoreWord(score)) .font(StrandFont.subhead.weight(.semibold)) .foregroundStyle(StrandPalette.restColor) @@ -507,10 +515,19 @@ struct SleepView: View { } SourceBadge(score != nil ? heroSource(for: night) : (repo.activeDeviceIsOura ? "Oura" : "On-device"), tint: StrandPalette.restColor) } - .padding(NoopMetrics.cardInnerPadding + NoopMetrics.space1) - .frame(maxWidth: .infinity) - .timeOfDayBackground(.night) - .clipShape(RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous)) + if heroUsesLiquidStyle { + // A subtle night atmosphere sits behind the liquid hero ONLY (faint indigo wash + crescent + // moon over the near-black canvas, no glow), clipped to the card. + content + .padding(NoopMetrics.cardInnerPadding + NoopMetrics.space1) + .frame(maxWidth: .infinity) + .timeOfDayBackground(.night) + .clipShape(RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous)) + } else { + NoopCard(padding: NoopMetrics.cardInnerPadding, tint: StrandPalette.restColor) { + content.frame(maxWidth: .infinity) + } + } } } @@ -741,7 +758,8 @@ struct SleepView: View { // (≥2-segment) hypnogram so the strip aligns with a genuine timeline; the proportional stage-bar // fallback has no timeline to anchor to. Placed OUTSIDE the fixed-height ChartCard so it doesn't // clip the hypnogram. Honest empty state inside `motionStrip` when no group fragment has motion. - if intervals.count >= 2 { + if intervals.count >= 2, + Self.showsMotionStrip(motionEpochCount: night.motionEpochs.count, blocks: night.sourceBlocks) { motionStrip(night) } // H9 — when the engine's Rest confidence flags this night's staging as low-confidence (a @@ -754,17 +772,25 @@ struct SleepView: View { } // #345 follow-up: when a night was staged on SPARSE motion coverage it can UNDER-detect — the // gravity-only spine fragments and the sub-60-min pieces are dropped, so a real ~8h night can - // collapse to a fraction ("slept 8h, app shows 1h"). Say so honestly so the short total isn't - // read as fact. Distinct from the H9 note above (a plausible-duration night with an off split). - if stageStagingIsSparse(night) { - stageIncompleteNote + // collapse to a fraction ("slept 8h, app shows 1h"). Sparse coverage alone is common, though, and + // most such nights come out at a normal length, so the warning is only prominent when the total + // actually reads short; otherwise a quiet footnote says the stages are rough. Distinct from the + // H9 note above (a plausible-duration night with an off split). + let coverage = stageCoverage(night) + let partialShown = coverage.map { $0 < HypnogramCoverage.minCoverage } ?? false + switch Self.sparseStagingNote(sparse: stageStagingIsSparse(night), asleepMin: s.asleep, + typicalAsleepMin: Self.typicalAsleepMin(repo.days), + partialTimelineShown: partialShown) { + case .prominent: stageIncompleteNote + case .subtle: stageSparseFootnote + case .none: EmptyView() } // #1716 — a device-provided hypnogram assembled from records that never all arrived leaves a // HOLE in the timeline while the session still spans the whole night, so a night we saw a // fraction of renders as a complete one. Say which fraction. This is the only place the // coverage guard becomes visible: the engine's matching Rest downgrade lands in a transient // `DayResult` field no screen reads, so the gate was otherwise correct and inert. - if let coverage = stageCoverage(night), coverage < HypnogramCoverage.minCoverage { + if let coverage, partialShown { stagePartialNote(coverage) } // For an Oura-provided night, say plainly that this split is the ring's RAW on-device @@ -909,6 +935,45 @@ struct SleepView: View { return HypnogramCoverage.groupFraction(group.isEmpty ? night.sourceBlocks : group) } + /// How loudly to caption a night staged on sparse motion coverage. + enum SparseStagingNote: Equatable { case none, subtle, prominent } + + /// A sparse night reads as possibly cut short only below this share of the wearer's typical sleep. + static let sparseShortFraction = 0.70 + /// With no typical yet (fewer than five scored nights), a sparse night under this many minutes asleep. + static let sparseShortFloorMin = 240.0 + + /// Pure gate for the sparse-coverage caveat. `stagingSparse` says the night was staged on patchy motion, + /// which can shorten a night but usually does not: on one wearer's history 35 of 56 computed nights were + /// sparse and 33 of those were a normal length. So the warning is PROMINENT only when the total really + /// reads short (below `sparseShortFraction` of the typical, or below `sparseShortFloorMin` without one), + /// and a quiet footnote otherwise. When the partial-timeline note is already on screen it states the + /// stronger, measured fact, so this steps down to the footnote rather than stacking a second warning. + nonisolated static func sparseStagingNote(sparse: Bool, asleepMin: Double, typicalAsleepMin: Double?, + partialTimelineShown: Bool) -> SparseStagingNote { + guard sparse else { return .none } + if partialTimelineShown { return .subtle } + let short = typicalAsleepMin.map { asleepMin < sparseShortFraction * $0 } + ?? (asleepMin < sparseShortFloorMin) + return short ? .prominent : .subtle + } + + /// The typical minutes asleep the stage card prints as "typically …": the mean over the last 30 days + /// with a scored night, nil below five of them. Shared by both hosts so their warnings agree. + nonisolated static func typicalAsleepMin(_ days: [DailyMetric]) -> Double? { + let values = days.suffix(30).compactMap(\.totalSleepMin).filter { $0 > 0 } + guard values.count >= 5 else { return nil } + return values.reduce(0, +) / Double(values.count) + } + + /// Whether the "Move" strip belongs under this night at all. A night NOOP staged itself always has an + /// answer about movement, so it shows the trace or says none was captured. An imported night (every + /// block has a nil `stagingSparse`) never carried per-epoch motion, and a permanent "no movement + /// detail" line under each one reads like a fault rather than a fact about where the night came from. + nonisolated static func showsMotionStrip(motionEpochCount: Int, blocks: [CachedSleepSession]) -> Bool { + motionEpochCount >= 2 || blocks.contains { $0.stagingSparse != nil } + } + /// Pure H9 gate (unit-testable without a live view) — true when a night's staging is low-confidence: /// a high-efficiency night whose deep+REM share is below the restorative floor. Built on the engine's /// own `ScoreConfidence.rest(...)` so the UI flag and the persisted Rest confidence agree. `asleepMin`, @@ -968,6 +1033,17 @@ struct SleepView: View { .accessibilityElement(children: .combine) } + /// The quiet form of the sparse-coverage caveat: the night was staged on patchy motion but its total + /// reads at a normal length, so nothing suggests it was cut short. No badge, no call to action; it only + /// says the stage split is a rough estimate. See `sparseStagingNote`. + private var stageSparseFootnote: some View { + Text("Movement data was patchy this night, so the stages are a rough estimate.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 2) + } + /// The PARTIAL-TIMELINE caveat (#1716): this night's stage segments account for less than /// `HypnogramCoverage.minCoverage` of the window the session claims, so the stage totals describe only /// the part of the night the timeline accounts for. Distinct from BOTH notes above — H9 doubts the diff --git a/Strand/Screens/StagesCard.swift b/Strand/Screens/StagesCard.swift index f22b284cbb..56a0dbbba1 100644 --- a/Strand/Screens/StagesCard.swift +++ b/Strand/Screens/StagesCard.swift @@ -105,7 +105,8 @@ struct StageDetailView: View { // (≥2-segment) hypnogram so the strip aligns with a genuine timeline; the proportional stage-bar // fallback has no timeline to anchor to. Placed OUTSIDE the fixed-height ChartCard so it doesn't // clip the hypnogram. Honest empty state inside `motionStrip` when no group fragment has motion. - if intervals.count >= 2 { + if intervals.count >= 2, + SleepView.showsMotionStrip(motionEpochCount: night.motionEpochs.count, blocks: night.sourceBlocks) { motionStrip(night) } // H9 — when the engine's Rest confidence flags this night's staging as low-confidence (a @@ -116,17 +117,22 @@ struct StageDetailView: View { if stageStagingIsLowConfidence(night) { stageLowConfidenceNote } - // #345 follow-up: when a night was staged on SPARSE motion coverage it can UNDER-detect — the - // gravity-only spine fragments and the sub-60-min pieces are dropped, so a real ~8h night can - // collapse to a fraction ("slept 8h, app shows 1h"). Say so honestly so the short total isn't - // read as fact. Distinct from the H9 note above (a plausible-duration night with an off split). - if stageStagingIsSparse(night) { - stageIncompleteNote + // #345 follow-up: a night staged on SPARSE motion can UNDER-detect, but usually does not. Same + // tiering as the Sleep tab (`SleepView.sparseStagingNote`): prominent only when the total reads + // short, a quiet footnote otherwise, and never a second warning beside the partial-timeline one. + let coverage = stageCoverage(night) + let partialShown = coverage.map { $0 < HypnogramCoverage.minCoverage } ?? false + switch SleepView.sparseStagingNote(sparse: stageStagingIsSparse(night), asleepMin: night.stages.asleep, + typicalAsleepMin: SleepView.typicalAsleepMin(repo.days), + partialTimelineShown: partialShown) { + case .prominent: stageIncompleteNote + case .subtle: stageSparseFootnote + case .none: EmptyView() } // #1716 — a device-provided hypnogram whose records never all arrived leaves a HOLE in the // timeline while the session still spans the whole night, so a night we saw a fraction of // renders as a complete one. Say which fraction, exactly as the Sleep tab does. - if let coverage = stageCoverage(night), coverage < HypnogramCoverage.minCoverage { + if let coverage, partialShown { stagePartialNote(coverage) } // For an Oura-provided night, say plainly that this split is the ring's RAW on-device @@ -294,6 +300,15 @@ struct StageDetailView: View { .accessibilityElement(children: .combine) } + /// Twin of `SleepView.stageSparseFootnote`: the quiet caveat for a sparse night of normal length. + private var stageSparseFootnote: some View { + Text("Movement data was patchy this night, so the stages are a rough estimate.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 2) + } + /// The PARTIAL-TIMELINE caveat (#1716) — twin of `SleepView.stagePartialNote(_:)`, same copy and same /// floored percentage. Says that part of the night is MISSING, which is a different claim from the two /// notes above (a doubted split, and a night staged on thin motion). Changes no number. diff --git a/StrandTests/ActiveSessionControllerTests.swift b/StrandTests/ActiveSessionControllerTests.swift index f6a00c727d..6dff6a5046 100644 --- a/StrandTests/ActiveSessionControllerTests.swift +++ b/StrandTests/ActiveSessionControllerTests.swift @@ -111,4 +111,25 @@ final class ActiveSessionControllerTests: XCTestCase { XCTAssertFalse(visible.contains(twin)) XCTAssertEqual(Repository.hidingLegacyStrengthRecordings([native, twin], links: []).count, 2) } + + /// #2278: an accidental start/stop lands as a zero-length row ("0m"). Half of zero overlaps nothing, + /// so its twin used to stay visible and a delete of one copy left the other on screen. + func testAZeroLengthStrengthRecordingIsStillRecognisedAsTheTwin() { + func row(_ source: String, _ start: Int, _ end: Int) -> WorkoutRow { + WorkoutRow(startTs: start, endTs: end, sport: "Strength Training", source: source, + durationS: Double(end - start), energyKcal: nil, avgHr: nil, maxHr: nil, + strain: nil, distanceM: nil, zonesJSON: nil, notes: nil, steps: nil) + } + let native = row("native-training", 1_000, 1_000) + XCTAssertTrue(Repository.isLegacyStrengthTwin(row("manual", 1_000, 1_000), of: native)) + XCTAssertTrue(Repository.isLegacyStrengthTwin(row("manual", 990, 1_040), of: native), + "a zero-length native inside the recording's span is its twin") + XCTAssertFalse(Repository.isLegacyStrengthTwin(row("manual", 1_001, 1_001), of: native), + "a different instant is a different session") + XCTAssertFalse(Repository.isLegacyStrengthTwin(row("manual", 2_000, 2_600), of: native)) + // The non-degenerate rule is unchanged: half of the shorter span must overlap. + let long = row("native-training", 1_000, 2_000) + XCTAssertTrue(Repository.isLegacyStrengthTwin(row("manual", 1_400, 2_400), of: long)) + XCTAssertFalse(Repository.isLegacyStrengthTwin(row("manual", 1_600, 2_600), of: long)) + } } diff --git a/StrandTests/SleepModelAlignedMotionTests.swift b/StrandTests/SleepModelAlignedMotionTests.swift new file mode 100644 index 0000000000..70673b7da3 --- /dev/null +++ b/StrandTests/SleepModelAlignedMotionTests.swift @@ -0,0 +1,66 @@ +import XCTest +import StrandAnalytics +@testable import Strand + +/// `SleepModel.alignedMotion` — trims a fragment's persisted motion (gridded from its DETECTED start) to +/// the window the night now shows after a hand edit moved `effectiveStartTs` / `endTs`. +/// +/// Without this, a corrected onset or wake left the motion strip wider than the stage timeline above it: +/// the trace kept drawing epochs for time the night no longer claims, so restless bursts landed under the +/// wrong stages. (Grilling-session Q8b.) +final class SleepModelAlignedMotionTests: XCTestCase { + private let epochS = Int(SleepStager.epochS) // 30 + + func testUneditedWindowIsUnchanged() { + let epochs = (0..<10).map(Double.init) + let aligned = SleepModel.alignedMotion(epochs, detectedStartTs: 0, effectiveStartTs: 0, + endTs: 10 * 30) + XCTAssertEqual(aligned, epochs) + } + + func testOnsetMovedLaterTrimsTheLeadingEpochs() { + // Detected start 0, corrected onset 2 epochs (60s) later: the first 2 epochs describe time before + // the night now claims to have started, and must be dropped. + let epochs = (0..<10).map(Double.init) + let aligned = SleepModel.alignedMotion(epochs, detectedStartTs: 0, effectiveStartTs: 2 * epochS, + endTs: 10 * epochS) + XCTAssertEqual(aligned, Array(epochs[2...])) + } + + func testWakeMovedEarlierTrimsTheTrailingEpochs() { + let epochs = (0..<10).map(Double.init) + let aligned = SleepModel.alignedMotion(epochs, detectedStartTs: 0, effectiveStartTs: 0, + endTs: 6 * epochS) + XCTAssertEqual(aligned, Array(epochs[0..<6])) + } + + func testOnsetMovedEarlierIsNotPaddedWithFabricatedZeros() { + // There is no real motion sample for time before the DETECTED start, so an onset moved earlier + // than detection must not backfill with zeros (which would draw a fabricated "still sleeper"). + let epochs = (0..<10).map(Double.init) + let aligned = SleepModel.alignedMotion(epochs, detectedStartTs: 5 * epochS, effectiveStartTs: 0, + endTs: 15 * epochS) + XCTAssertEqual(aligned, epochs, "no epochs to trim from the front, and none fabricated") + } + + func testBothEdgesMovedInTrimsBothSides() { + let epochs = (0..<10).map(Double.init) + let aligned = SleepModel.alignedMotion(epochs, detectedStartTs: 0, effectiveStartTs: 2 * epochS, + endTs: 8 * epochS) + XCTAssertEqual(aligned, Array(epochs[2..<8])) + } + + func testDegenerateWindowReturnsEmpty() { + let epochs = (0..<10).map(Double.init) + XCTAssertEqual(SleepModel.alignedMotion(epochs, detectedStartTs: 0, effectiveStartTs: 100 * epochS, + endTs: 50 * epochS), []) + XCTAssertEqual(SleepModel.alignedMotion([], detectedStartTs: 0, effectiveStartTs: 0, + endTs: 10 * epochS), []) + } + + func testLeadBeyondEpochCountReturnsEmptyRatherThanCrashing() { + let epochs = (0..<3).map(Double.init) + XCTAssertEqual(SleepModel.alignedMotion(epochs, detectedStartTs: 0, effectiveStartTs: 50 * epochS, + endTs: 60 * epochS), []) + } +} diff --git a/StrandTests/SleepSparseStagingNoteTests.swift b/StrandTests/SleepSparseStagingNoteTests.swift new file mode 100644 index 0000000000..3bd9060e42 --- /dev/null +++ b/StrandTests/SleepSparseStagingNoteTests.swift @@ -0,0 +1,122 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// The sparse-coverage caveat's tiering gate (`SleepView.sparseStagingNote`), and its two supporting pure +/// helpers `typicalAsleepMin` and `showsMotionStrip`. +/// +/// Grilling-session ground truth: on one wearer's 92-night history, `stagingSparse` was set on 57 of 92 +/// computed nights, including nights of 10.5h, 12h and 12.75h — the flag alone says nothing about whether +/// a night actually read short. These tests pin the two-tier gate that keeps the badge for the nights that +/// really do, and steps every other sparse night down to a quiet footnote. +final class SleepSparseStagingNoteTests: XCTestCase { + + // MARK: - sparseStagingNote + + func testNotSparseIsNeverNoted() { + XCTAssertEqual(SleepView.sparseStagingNote(sparse: false, asleepMin: 60, typicalAsleepMin: 400, + partialTimelineShown: false), .none) + // Even a short night with no sparse flag says nothing here — the H9/partial-timeline notes own that. + XCTAssertEqual(SleepView.sparseStagingNote(sparse: false, asleepMin: 60, typicalAsleepMin: nil, + partialTimelineShown: false), .none) + } + + func testSparseAndNormalLengthIsSubtle() { + // 90% of a 440-min typical — comfortably above the 70% floor. + XCTAssertEqual(SleepView.sparseStagingNote(sparse: true, asleepMin: 396, typicalAsleepMin: 440, + partialTimelineShown: false), .subtle) + } + + func testSparseAndShortIsProminent() { + // 50% of a 440-min typical — below the 70% floor. + XCTAssertEqual(SleepView.sparseStagingNote(sparse: true, asleepMin: 220, typicalAsleepMin: 440, + partialTimelineShown: false), .prominent) + } + + func testExactlyAtTheFractionBoundaryIsNotYetShort() { + // asleepMin == fraction * typical is NOT "< ", so it stays subtle at the boundary. + let typical = 400.0 + let boundary = SleepView.sparseShortFraction * typical + XCTAssertEqual(SleepView.sparseStagingNote(sparse: true, asleepMin: boundary, typicalAsleepMin: typical, + partialTimelineShown: false), .subtle) + XCTAssertEqual(SleepView.sparseStagingNote(sparse: true, asleepMin: boundary - 1, typicalAsleepMin: typical, + partialTimelineShown: false), .prominent) + } + + func testNoTypicalFallsBackToAFixedFloor() { + // Fewer than 5 scored nights: no typical yet, so the fixed floor decides. + XCTAssertEqual(SleepView.sparseStagingNote(sparse: true, asleepMin: SleepView.sparseShortFloorMin - 1, + typicalAsleepMin: nil, partialTimelineShown: false), .prominent) + XCTAssertEqual(SleepView.sparseStagingNote(sparse: true, asleepMin: SleepView.sparseShortFloorMin, + typicalAsleepMin: nil, partialTimelineShown: false), .subtle) + } + + func testPartialTimelineAlreadyShownStepsDownToSubtleEvenWhenShort() { + // The partial-timeline note is the stronger, MEASURED claim (a real hole in the timeline); the + // sparse caveat must never stack a second warning beside it, even on a night that also reads short. + XCTAssertEqual(SleepView.sparseStagingNote(sparse: true, asleepMin: 60, typicalAsleepMin: 440, + partialTimelineShown: true), .subtle) + } + + // MARK: - typicalAsleepMin + + private func day(_ totalSleepMin: Double?) -> DailyMetric { + DailyMetric(day: "2026-01-01", totalSleepMin: totalSleepMin, efficiency: nil, deepMin: nil, + remMin: nil, lightMin: nil, disturbances: nil, restingHr: nil, avgHrv: nil, + recovery: nil, strain: nil, exerciseCount: nil) + } + + func testTypicalAsleepMinNeedsAtLeastFiveScoredNights() { + XCTAssertNil(SleepView.typicalAsleepMin([day(400), day(420), day(380), day(nil)])) + XCTAssertNotNil(SleepView.typicalAsleepMin([day(400), day(420), day(380), day(440), day(410)])) + } + + func testTypicalAsleepMinIsTheMeanOfTheLast30ScoredDays() { + let days = [day(300), day(500), day(400), day(400), day(400)] + XCTAssertEqual(SleepView.typicalAsleepMin(days) ?? -1, 400, accuracy: 0.001) + } + + func testTypicalAsleepMinIgnoresDaysOlderThanTheTrailing30() { + // 5 old days with a value that would skew the mean if counted, then exactly 30 days of 400 — + // only the trailing 30 (all 400) may enter the mean. + let days = Array(repeating: day(9_999), count: 5) + Array(repeating: day(400), count: 30) + XCTAssertEqual(SleepView.typicalAsleepMin(days) ?? -1, 400, accuracy: 0.001) + } + + func testTypicalAsleepMinSkipsZeroAndNilEntriesWhenCountingTheFive() { + // Zero/nil entries within the window are not scored nights and must not count toward the ≥5 floor: + // 4 real values plus 2 junk entries stays below the floor. + let fourReal = [day(0), day(nil), day(400), day(420), day(380), day(440)] + XCTAssertNil(SleepView.typicalAsleepMin(fourReal), "only 4 real nights, below the floor of 5") + XCTAssertNotNil(SleepView.typicalAsleepMin(fourReal + [day(410)]), "a 5th real night clears the floor") + } + + // MARK: - showsMotionStrip + + private func block(stagingSparse: Bool?) -> CachedSleepSession { + CachedSleepSession(startTs: 0, endTs: 100, efficiency: nil, restingHr: nil, avgHrv: nil, + stagesJSON: nil, stagingSparse: stagingSparse) + } + + func testMotionStripShownWhenEpochsPresent() { + XCTAssertTrue(SleepView.showsMotionStrip(motionEpochCount: 2, blocks: [block(stagingSparse: nil)])) + } + + func testMotionStripShownForAComputedNightWithNoMotion() { + // A NOOP-computed night always has an answer about movement (sparse true or false) even when the + // motion grid came back empty for it — so it gets the honest empty state, not silence. + XCTAssertTrue(SleepView.showsMotionStrip(motionEpochCount: 0, blocks: [block(stagingSparse: false)])) + XCTAssertTrue(SleepView.showsMotionStrip(motionEpochCount: 0, blocks: [block(stagingSparse: true)])) + } + + func testMotionStripHiddenForAnImportedNightWithNoMotion() { + // Every block nil `stagingSparse` = never staged by NOOP (imported / pre-migration) = never carried + // per-epoch motion in the first place. A permanent "no movement detail" line there is noise. + XCTAssertFalse(SleepView.showsMotionStrip(motionEpochCount: 0, blocks: [block(stagingSparse: nil)])) + } + + func testMotionStripShownWhenAnyBlockInAMixedGroupWasComputed() { + XCTAssertTrue(SleepView.showsMotionStrip(motionEpochCount: 0, + blocks: [block(stagingSparse: nil), block(stagingSparse: false)])) + } +} diff --git a/Tools/translations/de.json b/Tools/translations/de.json index 6a4d9ec727..bd2fad410c 100644 --- a/Tools/translations/de.json +++ b/Tools/translations/de.json @@ -1414,5 +1414,6 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP verwendet die folgenden Inhalte Dritter unter deren eigener Lizenz. Eine Lizenz in einem Bereich (Code, Daten, Medien) gilt nicht automatisch auch für einen anderen.", "Third-party content NOOP uses under its own licence.": "Inhalte Dritter, die NOOP unter deren eigener Lizenz verwendet.", "Workout title": "Workout-Titel", - "Workout title (optional)": "Workout-Titel (optional)" + "Workout title (optional)": "Workout-Titel (optional)", + "Movement data was patchy this night, so the stages are a rough estimate.": "Die Bewegungsdaten waren diese Nacht lückenhaft, daher sind die Phasen nur grob geschätzt." } diff --git a/Tools/translations/es.json b/Tools/translations/es.json index e94d318cda..13e5bcf7a2 100644 --- a/Tools/translations/es.json +++ b/Tools/translations/es.json @@ -1415,5 +1415,6 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utiliza el siguiente contenido de terceros bajo su propia licencia. Una licencia en un ámbito (código, datos, medios) no se considera una licencia en otro.", "Third-party content NOOP uses under its own licence.": "Contenido de terceros que NOOP utiliza bajo su propia licencia.", "Workout title": "Título del entrenamiento", - "Workout title (optional)": "Título del entrenamiento (opcional)" + "Workout title (optional)": "Título del entrenamiento (opcional)", + "Movement data was patchy this night, so the stages are a rough estimate.": "Los datos de movimiento fueron irregulares esta noche, así que las fases son una estimación aproximada." } diff --git a/Tools/translations/fr.json b/Tools/translations/fr.json index 58be4340b6..be83ec2f06 100644 --- a/Tools/translations/fr.json +++ b/Tools/translations/fr.json @@ -1416,5 +1416,6 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utilise le contenu tiers suivant sous sa propre licence. Une licence dans un domaine (code, données, médias) n’est pas considérée comme une licence dans un autre.", "Third-party content NOOP uses under its own licence.": "Contenu tiers que NOOP utilise sous sa propre licence.", "Workout title": "Titre de l’entraînement", - "Workout title (optional)": "Titre de l’entraînement (facultatif)" + "Workout title (optional)": "Titre de l’entraînement (facultatif)", + "Movement data was patchy this night, so the stages are a rough estimate.": "Les données de mouvement étaient incomplètes cette nuit, les phases sont donc une estimation approximative." } diff --git a/Tools/translations/it.json b/Tools/translations/it.json index f5913484ff..f0fd0804a5 100644 --- a/Tools/translations/it.json +++ b/Tools/translations/it.json @@ -1509,5 +1509,6 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utilizza i seguenti contenuti di terze parti secondo la loro licenza. Una licenza in un ambito (codice, dati, media) non vale automaticamente anche per un altro.", "Third-party content NOOP uses under its own licence.": "Contenuti di terze parti che NOOP utilizza secondo la loro licenza.", "Workout title": "Titolo dell’allenamento", - "Workout title (optional)": "Titolo dell’allenamento (facoltativo)" + "Workout title (optional)": "Titolo dell’allenamento (facoltativo)", + "Movement data was patchy this night, so the stages are a rough estimate.": "I dati di movimento sono stati discontinui questa notte, quindi le fasi sono una stima approssimativa." } diff --git a/Tools/translations/pl.json b/Tools/translations/pl.json index ef83ca4116..9ae4d3ca78 100644 --- a/Tools/translations/pl.json +++ b/Tools/translations/pl.json @@ -2551,5 +2551,6 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP korzysta z poniższych treści innych firm na podstawie ich własnej licencji. Licencja w jednym obszarze (kod, dane, multimedia) nie jest traktowana jako licencja w innym.", "Third-party content NOOP uses under its own licence.": "Treści innych firm, z których NOOP korzysta na podstawie ich własnej licencji.", "Workout title": "Tytuł treningu", - "Workout title (optional)": "Tytuł treningu (opcjonalnie)" + "Workout title (optional)": "Tytuł treningu (opcjonalnie)", + "Movement data was patchy this night, so the stages are a rough estimate.": "Dane o ruchu tej nocy były niepełne, więc fazy snu są jedynie przybliżonym oszacowaniem." } diff --git a/Tools/translations/pt-PT.json b/Tools/translations/pt-PT.json index ba3ed531e8..374a37f9d7 100644 --- a/Tools/translations/pt-PT.json +++ b/Tools/translations/pt-PT.json @@ -1415,5 +1415,6 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "A NOOP utiliza o seguinte conteúdo de terceiros ao abrigo da sua própria licença. Uma licença num domínio (código, dados, multimédia) não é considerada uma licença noutro.", "Third-party content NOOP uses under its own licence.": "Conteúdo de terceiros que a NOOP utiliza ao abrigo da sua própria licença.", "Workout title": "Título do treino", - "Workout title (optional)": "Título do treino (opcional)" + "Workout title (optional)": "Título do treino (opcional)", + "Movement data was patchy this night, so the stages are a rough estimate.": "Os dados de movimento estiveram incompletos esta noite, pelo que as fases são uma estimativa aproximada." } diff --git a/Tools/translations/ru.json b/Tools/translations/ru.json index 9992fb4b02..c5460f4a1d 100644 --- a/Tools/translations/ru.json +++ b/Tools/translations/ru.json @@ -1392,5 +1392,6 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP использует следующий сторонний контент на условиях его собственной лицензии. Лицензия в одной области (код, данные, медиа) не считается лицензией в другой.", "Third-party content NOOP uses under its own licence.": "Сторонний контент, который NOOP использует на условиях его собственной лицензии.", "Workout title": "Название тренировки", - "Workout title (optional)": "Название тренировки (необязательно)" + "Workout title (optional)": "Название тренировки (необязательно)", + "Movement data was patchy this night, so the stages are a rough estimate.": "Данные о движении в эту ночь были неполными, поэтому стадии сна — лишь приблизительная оценка." } diff --git a/Tools/translations/zh-Hans.json b/Tools/translations/zh-Hans.json index c3195e54eb..33d219c01f 100644 --- a/Tools/translations/zh-Hans.json +++ b/Tools/translations/zh-Hans.json @@ -1516,5 +1516,6 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP 在其自身许可下使用以下第三方内容。一个领域(代码、数据、媒体)的许可并不等同于另一个领域的许可。", "Third-party content NOOP uses under its own licence.": "NOOP 在其自身许可下使用的第三方内容。", "Workout title": "训练标题", - "Workout title (optional)": "训练标题(可选)" + "Workout title (optional)": "训练标题(可选)", + "Movement data was patchy this night, so the stages are a rough estimate.": "这一晚的体动数据不完整,因此睡眠阶段只是粗略估计。" } diff --git a/Tools/translations/zh-Hant.json b/Tools/translations/zh-Hant.json index 13a74fb020..b461c1e77a 100644 --- a/Tools/translations/zh-Hant.json +++ b/Tools/translations/zh-Hant.json @@ -1570,5 +1570,6 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP 在其自身授權下使用以下第三方內容。一個領域(程式碼、資料、媒體)的授權並不代表其他領域的授權。", "Third-party content NOOP uses under its own licence.": "NOOP 在其自身授權下使用的第三方內容。", "Workout title": "訓練標題", - "Workout title (optional)": "訓練標題(選填)" + "Workout title (optional)": "訓練標題(選填)", + "Movement data was patchy this night, so the stages are a rough estimate.": "這一晚的體動資料不完整,因此睡眠階段只是粗略估計。" }