From 9d63db452da0615a7c1c33fdd2a6546f11267088 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:51:10 +0200 Subject: [PATCH] fix(sleep,workouts): delete native strength sessions, honor Liquid setting on the Sleep hero, and tier the "may be incomplete" warning by how short the night actually reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three unrelated reports from grilling this fork's outstanding issues, bundled onto one branch because they touch the same review pass and none needed its own migration. **Native strength sessions were visible but undeletable (#2278-adjacent).** `deleteWorkout` swept strap namespaces by natural key, but a native row is a READ-TIME projection of `trainingWorkoutNative` with no `workout` row behind it — the sweep matched nothing, and the session reappeared on reload every time. `deleteNativeWorkoutRow` now removes, in order: the native workout (exercises/sets cascade via the FK), its `trainingSessionLink`, and the legacy manual "Strength Training" recording `hidingLegacyStrengthRecordings` was hiding as its twin — deleting the native alone would have uncovered that recording and made the delete look ignored again. `isLegacyStrengthTwin` also gained a zero-length fallback: a start/stop inside the same second has no "half" to overlap under the existing ratio test, so its twin was never recognized and stayed on screen after one copy was deleted. Everything else DRATH reported turned out to already be fixed by today's upstream sync (#2286 deletes from every strap namespace the list reads; #2287 discards sub-minute live sessions at save and floors manual entry the same way). The strength-specific gap above is what neither covers: a native session isn't in any `workout` namespace at all. Confirmed no duration floor was missing on the two strength-save paths either — `NativeWorkoutEngine.complete` and `LiftSessionView.save` already refuse to write anything with zero completed sets, regardless of elapsed time, so an accidental instant start already saves nothing to delete. **Sleep hero stayed on the liquid gauge with Liquid Design off.** `SleepView.restHero` drew `LiquidScoreGauge` unconditionally; every other screen already read `TodayDashboardStyle.storageKey` to fall back to Classic. The hero now reads the same setting: `.liquid` keeps the vessel gauge and night scene, every other style draws `GlowRing` on a plain `NoopCard` like Classic Today's ring, exactly as the rest of the Sleep tab already does. **"May be incomplete" fired on nights that weren't.** The badge was driven by `stagingSparse` alone, which flags thin MOTION coverage, not a short night — on a sample history 57 of 92 computed nights were sparse, including 10.5h/12h/12.75h nights. `sparseStagingNote` now tiers it: prominent (badge + the original copy) only when the night reads under 70% of the wearer's 30-day typical (or under 4h with fewer than 5 scored nights to average), a quiet footnote otherwise. When the partial-timeline note (#1716, a MEASURED hole in the timeline) is already shown, sparse steps down to the footnote rather than stacking a second warning beside the stronger, attributable one. `showsMotionStrip` also stops captioning "No movement detail" on imported/pre-migration nights (`stagingSparse == nil` on every block) — those never carried per-epoch motion to begin with, so the line was noise, not a fact about the night. Two smaller fixes surfaced by the same pass: `SleepModel.alignedMotion` trims a fragment's persisted motion trace to the window a hand-edited night now shows (it was gridded from the DETECTED start and drawn unclipped, so a moved onset/wake left restless bursts under the wrong stage bands) — no padding on an onset moved earlier, since there's no real sample for that time and zeros would draw a fabricated still sleeper. And a new always-on diagnostic line (once per session per launch) logs when a kept session staged with no per-epoch motion at all, naming duration, `stagingSparse` and whether Motion-aware Wake was on, without asserting a cause — the reporter's zero-motion night couldn't be explained from the code alone and needs a Test Centre log to triage further. Mirrored in both hosts that render this note (`SleepView`, `StagesCard`'s read-only `StageDetailView`) off the same shared static helpers so the two screens can't disagree about a night's tier. Verified: `swift test` green in WhoopStore (755, incl. the new `deleteTrainingSessionLinks` scoping test) and StrandDesign (147, 5 pre-existing locale-dependent failures unrelated to this diff — confirmed present on the merge-commit baseline before these changes too); `xcodebuild test` green for StrandTests (3405 tests, 0 failures) on both this diff and after a hard reset to confirm nothing was lost mid-session; `xcodebuild build` green for NOOPiOS (this platform's default CI does not build app targets, see CLAUDE.md). `Tools/i18n_audit.py --ci main` exits 0 for the one new string (the sparse footnote, identical in both hosts, added to all nine languages in the catalog and `Tools/translations/`). `doc_comment_lint` clean. Not tested on a real strap: nothing here touches the BLE/CoreBluetooth path. Analysis migration required: no — no scoring formula, analytics window, or persisted-value meaning changed; `stagingSparse` is read the same way it always was, only presented differently. Separately found and NOT part of this change: `Tools/tests/test_parity_governance_acceptance.py` and `Tools/tests/test_rr_legacy_preservation_contract.py` reference files under `android/`, which this fork removed on 2026-08-14 per docs/FORK_GUIDE.md, and the parity-governance baseline appears stale after today's upstream sync — both break the active `tools-python.yml` CI gate already on the sync/upstream-2026-09-17 merge commit, before any of this branch's changes. Flagging for a separate pass rather than bundling an unrelated fix here. --- .../WhoopStore/TrainingSessionStore.swift | 13 ++ .../TrainingSessionStoreTests.swift | 16 +++ Strand/Data/IntelligenceEngine.swift | 15 ++ Strand/Data/Repository.swift | 58 +++++++- Strand/Resources/Localizable.xcstrings | 64 +++++++++ Strand/Screens/SleepModel.swift | 21 ++- Strand/Screens/SleepView.swift | 132 ++++++++++++++---- Strand/Screens/StagesCard.swift | 31 ++-- .../ActiveSessionControllerTests.swift | 21 +++ .../SleepModelAlignedMotionTests.swift | 66 +++++++++ StrandTests/SleepSparseStagingNoteTests.swift | 122 ++++++++++++++++ Tools/translations/de.json | 3 +- Tools/translations/es.json | 3 +- Tools/translations/fr.json | 3 +- Tools/translations/it.json | 3 +- Tools/translations/pl.json | 3 +- Tools/translations/pt-PT.json | 3 +- Tools/translations/ru.json | 3 +- Tools/translations/zh-Hans.json | 3 +- Tools/translations/zh-Hant.json | 3 +- 20 files changed, 535 insertions(+), 51 deletions(-) create mode 100644 StrandTests/SleepModelAlignedMotionTests.swift create mode 100644 StrandTests/SleepSparseStagingNoteTests.swift 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.": "這一晚的體動資料不完整,因此睡眠階段只是粗略估計。" }