From e0defaf6f7d121afd2206f55df8bc806f0808ed4 Mon Sep 17 00:00:00 2001 From: LeonardSEO <130681849+LeonardSEO@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:30:53 +0200 Subject: [PATCH] Add a sleep planner card: tonight's target at three goals and the bedtime each implies Pure math in StrandAnalytics SleepPlanner with a Kotlin twin and mirrored fixtures. A new reorderable Sleep section on Android and Apple. Display-only. --- .../StrandAnalytics/SleepPlanner.swift | 191 ++++++++++++++++++ .../SleepPlannerTests.swift | 59 ++++++ Strand/Data/SleepLayoutPrefs.swift | 5 +- Strand/Resources/Localizable.xcstrings | 39 ++++ Strand/Screens/SleepCustomizationSheet.swift | 2 + Strand/Screens/SleepPlannerCard.swift | 155 ++++++++++++++ Strand/Screens/SleepView.swift | 3 + StrandTests/SleepLayoutPrefsTests.swift | 12 +- Tools/parity_ledger_baseline.json | 2 +- Tools/parity_twin_map.json | 16 +- .../java/com/noop/analytics/SleepPlanner.kt | 186 +++++++++++++++++ .../main/java/com/noop/ui/SleepLayoutPrefs.kt | 11 +- .../main/java/com/noop/ui/SleepPlannerCard.kt | 160 +++++++++++++++ .../src/main/java/com/noop/ui/SleepScreen.kt | 31 +++ .../app/src/main/res/values-de/strings.xml | 17 ++ .../app/src/main/res/values-es/strings.xml | 17 ++ .../app/src/main/res/values-fr/strings.xml | 17 ++ .../app/src/main/res/values-it/strings.xml | 17 ++ .../app/src/main/res/values-pl/strings.xml | 17 ++ .../src/main/res/values-pt-rPT/strings.xml | 17 ++ .../app/src/main/res/values-ru/strings.xml | 17 ++ .../app/src/main/res/values-zh/strings.xml | 17 ++ android/app/src/main/res/values/strings.xml | 17 ++ .../com/noop/analytics/SleepPlannerTest.kt | 92 +++++++++ .../java/com/noop/ui/SleepLayoutPrefsTest.kt | 11 +- .../com/noop/ui/SleepSectionReorderTest.kt | 5 +- 26 files changed, 1108 insertions(+), 25 deletions(-) create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/SleepPlanner.swift create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepPlannerTests.swift create mode 100644 Strand/Screens/SleepPlannerCard.swift create mode 100644 android/app/src/main/java/com/noop/analytics/SleepPlanner.kt create mode 100644 android/app/src/main/java/com/noop/ui/SleepPlannerCard.kt create mode 100644 android/app/src/test/java/com/noop/analytics/SleepPlannerTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepPlanner.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepPlanner.swift new file mode 100644 index 0000000000..bae7d3b185 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepPlanner.swift @@ -0,0 +1,191 @@ +import Foundation + +// SleepPlanner.swift — tonight's sleep target, time in bed and bedtime for a wake time. +// +// Pure, deterministic, DB-free. Given the night's personal sleep need, the current +// sleep-debt estimate (SleepDebt), today's nap minutes and the user's typical sleep +// efficiency, it returns one plan per sleep goal: how much sleep the goal asks for, +// how long to be in bed to get it, and the bedtime that implies for a wake time. +// +// The goal ladder (70 / 85 / 100 % of tonight's need) and the three-consecutive-night +// eligibility gate follow the shape of the WHOOP app's sleep planner as observed in +// its shipped resources (facts only, no WHOOP code). Everything else here is NOOP's +// own arithmetic and is documented below. +// +// HONEST by construction: +// - PRESENTATION ONLY. Nothing here feeds Rest, recovery, debt or any stored value. +// - Tonight's need = base need + current debt − today's naps, where naps only repay +// debt: a nap never lowers tonight's need below the base need. +// - Time in bed = sleep target ÷ typical efficiency (median of recent nights, +// clamped to 50…100 %). With no efficiency history it equals the sleep target, +// so the plan never invents an awake allowance it has no evidence for. +// - Fewer than three consecutive recent nights with sleep → no plan (nil). +// +// Constant-explicit + dependency-free so the Kotlin mirror (android … SleepPlanner.kt) +// is byte-identical. + +/// One sleep goal on the planner ladder. +public enum SleepPlannerGoal: String, CaseIterable, Sendable { + case minimum + case solid + case full + + /// Fraction of tonight's need this goal asks for. + public var fraction: Double { + switch self { + case .minimum: return 0.70 + case .solid: return 0.85 + case .full: return 1.00 + } + } +} + +/// The planner's read for one goal. +public struct SleepPlanTier: Equatable, Sendable { + public let goal: SleepPlannerGoal + /// Asleep minutes the goal asks for. + public let sleepTargetMin: Int + /// Minutes in bed needed to reach the target at the typical efficiency. + public let timeInBedMin: Int + /// Bedtime as a local minute of day in [0, 1440). + public let bedtimeMinuteOfDay: Int +} + +/// Tonight's plan: the need breakdown plus one tier per goal. +public struct SleepPlan: Equatable, Sendable { + /// Personal base need (minutes). + public let baseNeedMin: Int + /// Current debt carried into tonight (minutes, ≥ 0). + public let debtMin: Int + /// Nap minutes credited against the debt today (≥ 0, ≤ debtMin). + public let napCreditMin: Int + /// base + debt − nap credit. + public let tonightNeedMin: Int + /// Efficiency used for time in bed, 0.5…1.0 (1.0 when there was no history). + public let efficiencyUsed: Double + /// One tier per goal, in `SleepPlannerGoal.allCases` order. + public let tiers: [SleepPlanTier] +} + +public enum SleepPlanner { + + /// Consecutive nights with sleep the plan needs before it says anything. + public static let requiredConsecutiveNights = 3 + + /// How many recent nights the typical efficiency is taken over. + public static let efficiencyWindowNights = 14 + + public static let minEfficiency = 0.5 + public static let maxEfficiency = 1.0 + + /// Build tonight's plan, or nil when there are fewer than + /// `requiredConsecutiveNights` consecutive nights ending at the newest night. + /// + /// - Parameters: + /// - nights: chronological `(day "yyyy-MM-dd", asleepMin?, efficiency?)` rows. + /// Efficiency may be a fraction (0…1) or a percentage (>1, divided by 100). + /// - baseNeedMin: personal base sleep need (minutes). + /// - debtMin: current sleep-debt estimate (minutes; negatives read as 0). + /// - napMinToday: asleep minutes outside the main night today. + /// - wakeMinuteOfDay: intended wake time, local minute of day. + /// Kotlin twin: `SleepPlanner.plan`. + public static func plan( + nights: [(day: String, asleepMin: Double?, efficiency: Double?)], + baseNeedMin: Double, + debtMin: Double, + napMinToday: Double, + wakeMinuteOfDay: Int + ) -> SleepPlan? { + guard consecutiveNights(nights.map { ($0.day, $0.asleepMin) }) >= requiredConsecutiveNights else { + return nil + } + let base = max(0.0, baseNeedMin) + let debt = max(0.0, debtMin) + let napCredit = min(max(0.0, napMinToday), debt) + let tonight = base + debt - napCredit + let eff = typicalEfficiency(nights.map { $0.efficiency }) ?? 1.0 + + let tiers = SleepPlannerGoal.allCases.map { goal -> SleepPlanTier in + let target = tonight * goal.fraction + let inBed = roundHalfAway(target / eff) + return SleepPlanTier( + goal: goal, + sleepTargetMin: roundHalfAway(target), + timeInBedMin: inBed, + bedtimeMinuteOfDay: wrapMinute(wakeMinuteOfDay - inBed) + ) + } + return SleepPlan( + baseNeedMin: roundHalfAway(base), + debtMin: roundHalfAway(debt), + napCreditMin: roundHalfAway(napCredit), + tonightNeedMin: roundHalfAway(tonight), + efficiencyUsed: eff, + tiers: tiers + ) + } + + /// Length of the run of calendar-consecutive nights with sleep (> 0 min) that ends + /// at the newest night carrying sleep. Rows without sleep are ignored; a gap of a + /// day or more ends the run. Malformed day keys end the run too. + /// Kotlin twin: `SleepPlanner.consecutiveNights`. + public static func consecutiveNights(_ rows: [(day: String, asleepMin: Double?)]) -> Int { + let slept: [Int?] = rows.compactMap { row -> Int?? in + guard let m = row.asleepMin, m > 0 else { return nil } + return .some(epochDay(row.day)) + } + guard let newest = slept.last, var previous = newest else { return 0 } + var run = 1 + for maybeDay in slept.dropLast().reversed() { + guard let day = maybeDay else { break } + if day == previous { continue } + if day != previous - 1 { break } + run += 1 + previous = day + } + return run + } + + /// Median efficiency (fraction) over the newest `efficiencyWindowNights` usable + /// values, clamped to [minEfficiency, maxEfficiency]; nil when none are usable. + /// Kotlin twin: `SleepPlanner.typicalEfficiency`. + public static func typicalEfficiency(_ values: [Double?]) -> Double? { + let usable = values.compactMap { v -> Double? in + guard let v = v, v > 0 else { return nil } + return v > 1.0 ? v / 100.0 : v + } + let window = Array(usable.suffix(efficiencyWindowNights)).sorted() + guard !window.isEmpty else { return nil } + let mid = window.count / 2 + let median = window.count % 2 == 1 ? window[mid] : (window[mid - 1] + window[mid]) / 2.0 + return min(maxEfficiency, max(minEfficiency, median)) + } + + /// Days since 1970-01-01 for a "yyyy-MM-dd" key (proleptic Gregorian), nil if malformed. + /// Pure integer arithmetic so both platforms agree without a calendar API. + /// Kotlin twin: `SleepPlanner.epochDay`. + public static func epochDay(_ key: String) -> Int? { + let parts = key.split(separator: "-") + guard parts.count == 3, let y0 = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2]), + (1...12).contains(m), (1...31).contains(d) else { return nil } + let y = m <= 2 ? y0 - 1 : y0 + let era = (y >= 0 ? y : y - 399) / 400 + let yoe = y - era * 400 + let mp = (m + 9) % 12 + let doy = (153 * mp + 2) / 5 + d - 1 + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy + return era * 146_097 + doe - 719_468 + } + + /// Kotlin twin: `SleepPlanner.wrapMinute`. + static func wrapMinute(_ m: Int) -> Int { + let day = 24 * 60 + return ((m % day) + day) % day + } + + /// Half-away-from-zero, matching Swift `Double.rounded()`. + /// Kotlin twin: `SleepPlanner.roundHalfAway`. + static func roundHalfAway(_ v: Double) -> Int { + Int(v.rounded()) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepPlannerTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepPlannerTests.swift new file mode 100644 index 0000000000..3a2cd7fe1b --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepPlannerTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the display-only sleep planner. The Kotlin twin (SleepPlannerTest.kt) carries the +/// same cases and the oracle literal produced from this implementation. +final class SleepPlannerTests: XCTestCase { + + private func nights(_ rows: [(String, Double?, Double?)]) -> [(day: String, asleepMin: Double?, efficiency: Double?)] { + rows.map { (day: $0.0, asleepMin: $0.1, efficiency: $0.2) } + } + + func testNoPlanUnderThreeConsecutiveNights() { + let rows = nights([("2026-10-17", 420, 0.9), ("2026-10-19", 430, 0.9), ("2026-10-20", 440, 0.9)]) + XCTAssertNil(SleepPlanner.plan(nights: rows, baseNeedMin: 480, debtMin: 0, napMinToday: 0, wakeMinuteOfDay: 420)) + } + + func testConsecutiveRunIgnoresEmptyRowsAndDuplicates() { + let rows: [(day: String, asleepMin: Double?)] = [ + ("2026-10-16", 400), ("2026-10-17", nil), ("2026-10-18", 410), + ("2026-10-19", 420), ("2026-10-19", 30), ("2026-10-20", 430), ("2026-10-21", nil), + ] + XCTAssertEqual(SleepPlanner.consecutiveNights(rows), 3) + } + + func testConsecutiveRunCrossesMonthAndLeapDay() { + let rows: [(day: String, asleepMin: Double?)] = [("2024-02-28", 400), ("2024-02-29", 400), ("2024-03-01", 400)] + XCTAssertEqual(SleepPlanner.consecutiveNights(rows), 3) + XCTAssertEqual(SleepPlanner.epochDay("1970-01-01"), 0) + XCTAssertEqual(SleepPlanner.epochDay("2026-10-20"), 20_746) + XCTAssertNil(SleepPlanner.epochDay("2026-13-01")) + } + + func testTiersWithEfficiencyAndBedtimeWrap() throws { + let rows = nights([("2026-10-18", 420, 90), ("2026-10-19", 400, 0.88), ("2026-10-20", 450, 0.92)]) + let plan = try XCTUnwrap(SleepPlanner.plan(nights: rows, baseNeedMin: 480, debtMin: 40, napMinToday: 0, wakeMinuteOfDay: 390)) + XCTAssertEqual(plan.tonightNeedMin, 520) + XCTAssertEqual(plan.efficiencyUsed, 0.9, accuracy: 1e-12) + XCTAssertEqual(plan.tiers.map(\.sleepTargetMin), [364, 442, 520]) + XCTAssertEqual(plan.tiers.map(\.timeInBedMin), [404, 491, 578]) + // 06:30 wake − 578 min in bed = 20:52 the evening before. + XCTAssertEqual(plan.tiers.map(\.bedtimeMinuteOfDay), [1426, 1339, 1252]) + } + + func testNapsOnlyRepayDebt() throws { + let rows = nights([("2026-10-18", 420, nil), ("2026-10-19", 400, nil), ("2026-10-20", 450, nil)]) + let plan = try XCTUnwrap(SleepPlanner.plan(nights: rows, baseNeedMin: 480, debtMin: 30, napMinToday: 90, wakeMinuteOfDay: 420)) + XCTAssertEqual(plan.napCreditMin, 30) + XCTAssertEqual(plan.tonightNeedMin, 480) + // No efficiency history: time in bed equals the target. + XCTAssertEqual(plan.efficiencyUsed, 1.0) + XCTAssertEqual(plan.tiers.last?.timeInBedMin, 480) + } + + func testEfficiencyMedianAndClamp() { + XCTAssertEqual(SleepPlanner.typicalEfficiency([0.8, nil, 0.9, 95, 0.0]) ?? -1, 0.9, accuracy: 1e-12) + XCTAssertEqual(SleepPlanner.typicalEfficiency([0.2, 0.3]) ?? -1, 0.5, accuracy: 1e-12) + XCTAssertNil(SleepPlanner.typicalEfficiency([nil, 0])) + } +} diff --git a/Strand/Data/SleepLayoutPrefs.swift b/Strand/Data/SleepLayoutPrefs.swift index 75e6cab182..99144490a2 100644 --- a/Strand/Data/SleepLayoutPrefs.swift +++ b/Strand/Data/SleepLayoutPrefs.swift @@ -32,6 +32,8 @@ enum SleepSection: String, CaseIterable, Identifiable { case bodyClock case nightDetail case sleepDebt + /// Tonight's sleep target at three goals; rawValue matches the Android `SLEEP_PLANNER`. + case sleepPlanner case stagesVsTypical case asleepDuration @@ -45,6 +47,7 @@ enum SleepSection: String, CaseIterable, Identifiable { case .bodyClock: return String(localized: "Body clock") case .nightDetail: return String(localized: "Night detail") case .sleepDebt: return String(localized: "Sleep-debt ledger") + case .sleepPlanner: return String(localized: "Sleep planner") case .stagesVsTypical: return String(localized: "Stages vs typical") case .asleepDuration: return String(localized: "Asleep duration") } @@ -55,7 +58,7 @@ enum SleepSection: String, CaseIterable, Identifiable { /// with Stages for now — it's drawn inside the stages hero; making it an independently arrangeable /// card is a follow-up that requires hoisting the hero's edit/delete callbacks.) static let defaultOrder: [SleepSection] = [ - .sleepMarks, .stages, .bodyClock, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration, + .sleepMarks, .stages, .bodyClock, .nightDetail, .sleepDebt, .sleepPlanner, .stagesVsTypical, .asleepDuration, ] } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7f227851a7..69daedbb31 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,45 @@ { "sourceLanguage": "en", "strings": { + "Sleep planner": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schlafplaner"}}, "es": {"stringUnit": {"state": "translated", "value": "Planificador de sueño"}}, "fr": {"stringUnit": {"state": "translated", "value": "Planificateur de sommeil"}}, "it": {"stringUnit": {"state": "translated", "value": "Pianificatore del sonno"}}, "pl": {"stringUnit": {"state": "translated", "value": "Planer snu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Planeador de sono"}}, "ru": {"stringUnit": {"state": "translated", "value": "Планировщик сна"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "睡眠规划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "睡眠規劃"}} + } }, + "A planning guide, not a score. Time in bed assumes your typical %lld%% sleep efficiency.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Eine Planungshilfe, kein Score. Die Zeit im Bett geht von deiner typischen Schlafeffizienz von %lld %% aus."}}, "es": {"stringUnit": {"state": "translated", "value": "Una guía de planificación, no una puntuación. El tiempo en cama supone tu eficiencia del sueño habitual del %lld %%."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un guide de planification, pas un score. Le temps au lit suppose votre efficacité de sommeil habituelle de %lld %%."}}, "it": {"stringUnit": {"state": "translated", "value": "Una guida alla pianificazione, non un punteggio. Il tempo a letto presuppone la tua efficienza del sonno tipica del %lld%%."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wskazówka do planowania, nie wynik. Czas w łóżku zakłada Twoją typową wydajność snu %lld%%."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um guia de planeamento, não uma pontuação. O tempo na cama assume a sua eficiência de sono habitual de %lld%%."}}, "ru": {"stringUnit": {"state": "translated", "value": "Ориентир для планирования, а не оценка. Время в постели рассчитано по вашей обычной эффективности сна %lld%%."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "这是规划参考,不是评分。在床时间按你通常 %lld%% 的睡眠效率计算。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "這是規劃參考,不是評分。在床時間按你通常 %lld%% 的睡眠效率計算。"}} + } }, + "A planning guide, not a score. No efficiency history yet, so time in bed equals the sleep target.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Eine Planungshilfe, kein Score. Noch kein Effizienzverlauf, daher entspricht die Zeit im Bett dem Schlafziel."}}, "es": {"stringUnit": {"state": "translated", "value": "Una guía de planificación, no una puntuación. Aún no hay historial de eficiencia, así que el tiempo en cama es igual al objetivo de sueño."}}, "fr": {"stringUnit": {"state": "translated", "value": "Un guide de planification, pas un score. Pas encore d'historique d'efficacité, le temps au lit égale donc l'objectif de sommeil."}}, "it": {"stringUnit": {"state": "translated", "value": "Una guida alla pianificazione, non un punteggio. Non c'è ancora uno storico di efficienza, quindi il tempo a letto è pari all'obiettivo di sonno."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wskazówka do planowania, nie wynik. Brak jeszcze historii wydajności, więc czas w łóżku równa się celowi snu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Um guia de planeamento, não uma pontuação. Ainda não há histórico de eficiência, por isso o tempo na cama é igual ao objetivo de sono."}}, "ru": {"stringUnit": {"state": "translated", "value": "Ориентир для планирования, а не оценка. Истории эффективности пока нет, поэтому время в постели равно цели сна."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "这是规划参考,不是评分。尚无效率历史,因此在床时间等于睡眠目标。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "這是規劃參考,不是評分。尚無效率歷史,因此在床時間等於睡眠目標。"}} + } }, + "Needs three nights in a row with sleep before it can plan tonight.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Braucht drei Nächte in Folge mit Schlaf, bevor heute Nacht geplant werden kann."}}, "es": {"stringUnit": {"state": "translated", "value": "Necesita tres noches seguidas con sueño antes de poder planificar esta noche."}}, "fr": {"stringUnit": {"state": "translated", "value": "Il faut trois nuits de sommeil d'affilée avant de pouvoir planifier cette nuit."}}, "it": {"stringUnit": {"state": "translated", "value": "Servono tre notti di fila con sonno prima di poter pianificare stanotte."}}, "pl": {"stringUnit": {"state": "translated", "value": "Potrzeba trzech kolejnych nocy ze snem, zanim można zaplanować dzisiejszą noc."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "São necessárias três noites seguidas com sono antes de planear esta noite."}}, "ru": {"stringUnit": {"state": "translated", "value": "Нужно три ночи подряд со сном, прежде чем можно спланировать эту ночь."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "需要连续三晚有睡眠数据,才能规划今晚。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "需要連續三晚有睡眠資料,才能規劃今晚。"}} + } }, + "Wake %@ · from your alarm": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Aufwachen %@ · aus deinem Wecker"}}, "es": {"stringUnit": {"state": "translated", "value": "Despertar %@ · de tu alarma"}}, "fr": {"stringUnit": {"state": "translated", "value": "Réveil %@ · d'après votre alarme"}}, "it": {"stringUnit": {"state": "translated", "value": "Sveglia %@ · dalla tua sveglia"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pobudka %@ · z Twojego alarmu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Acordar %@ · do seu alarme"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подъём %@ · по вашему будильнику"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "起床 %@ · 来自你的闹钟"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "起床 %@ · 來自你的鬧鐘"}} + } }, + "Wake %@ · from last night": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Aufwachen %@ · aus der letzten Nacht"}}, "es": {"stringUnit": {"state": "translated", "value": "Despertar %@ · de anoche"}}, "fr": {"stringUnit": {"state": "translated", "value": "Réveil %@ · d'après la nuit dernière"}}, "it": {"stringUnit": {"state": "translated", "value": "Sveglia %@ · dalla notte scorsa"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pobudka %@ · z ostatniej nocy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Acordar %@ · da noite passada"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подъём %@ · по прошлой ночи"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "起床 %@ · 来自昨晚"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "起床 %@ · 來自昨晚"}} + } }, + "Wake %@ · no alarm or recent wake yet": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Aufwachen %@ · noch kein Wecker oder Aufwachen"}}, "es": {"stringUnit": {"state": "translated", "value": "Despertar %@ · aún sin alarma ni despertar reciente"}}, "fr": {"stringUnit": {"state": "translated", "value": "Réveil %@ · pas encore d'alarme ni de réveil récent"}}, "it": {"stringUnit": {"state": "translated", "value": "Sveglia %@ · nessuna sveglia o risveglio recente"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pobudka %@ · brak alarmu i ostatniej pobudki"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Acordar %@ · ainda sem alarme nem despertar recente"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подъём %@ · будильника и недавнего пробуждения пока нет"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "起床 %@ · 尚无闹钟或最近起床记录"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "起床 %@ · 尚無鬧鐘或最近起床記錄"}} + } }, + "Base": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Basis"}}, "es": {"stringUnit": {"state": "translated", "value": "Base"}}, "fr": {"stringUnit": {"state": "translated", "value": "Base"}}, "it": {"stringUnit": {"state": "translated", "value": "Base"}}, "pl": {"stringUnit": {"state": "translated", "value": "Baza"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Base"}}, "ru": {"stringUnit": {"state": "translated", "value": "База"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "基础"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "基礎"}} + } }, + "Debt": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schuld"}}, "es": {"stringUnit": {"state": "translated", "value": "Deuda"}}, "fr": {"stringUnit": {"state": "translated", "value": "Dette"}}, "it": {"stringUnit": {"state": "translated", "value": "Debito"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dług"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Dívida"}}, "ru": {"stringUnit": {"state": "translated", "value": "Долг"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "欠债"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "欠債"}} + } }, + "Minimum": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Minimum"}}, "es": {"stringUnit": {"state": "translated", "value": "Mínimo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Minimum"}}, "it": {"stringUnit": {"state": "translated", "value": "Minimo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Minimum"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Mínimo"}}, "ru": {"stringUnit": {"state": "translated", "value": "Минимум"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "最低"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "最低"}} + } }, + "Full": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Voll"}}, "es": {"stringUnit": {"state": "translated", "value": "Completo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Complet"}}, "it": {"stringUnit": {"state": "translated", "value": "Pieno"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pełny"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Completo"}}, "ru": {"stringUnit": {"state": "translated", "value": "Полный"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "充足"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "充足"}} + } }, + "%lld%% of need · %@ asleep · %@ in bed": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld %% des Bedarfs · %@ Schlaf · %@ im Bett"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld %% de la necesidad · %@ dormido · %@ en cama"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld %% du besoin · %@ endormi · %@ au lit"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld%% del fabbisogno · %@ di sonno · %@ a letto"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld%% potrzeby · %@ snu · %@ w łóżku"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld%% da necessidade · %@ a dormir · %@ na cama"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld%% потребности · %@ сна · %@ в постели"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "需求的 %lld%% · 睡眠 %@ · 在床 %@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "需求的 %lld%% · 睡眠 %@ · 在床 %@"}} + } }, + "Bed by": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Ins Bett bis"}}, "es": {"stringUnit": {"state": "translated", "value": "A la cama a las"}}, "fr": {"stringUnit": {"state": "translated", "value": "Au lit à"}}, "it": {"stringUnit": {"state": "translated", "value": "A letto entro"}}, "pl": {"stringUnit": {"state": "translated", "value": "Do łóżka o"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Na cama até"}}, "ru": {"stringUnit": {"state": "translated", "value": "Лечь до"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "就寝时间"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "就寢時間"}} + } }, "Move, relative to tonight": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Move, relative to tonight"}}, "de": {"stringUnit": {"state": "translated", "value": "Bewegung, relativ zu dieser Nacht"}}, "es": {"stringUnit": {"state": "translated", "value": "Movimiento, en relación con esta noche"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mouvement, par rapport à cette nuit"}}, "it": {"stringUnit": {"state": "translated", "value": "Movimento, rispetto a questa notte"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ruch, w odniesieniu do tej nocy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Movimento, em relação a esta noite"}}, "ru": {"stringUnit": {"state": "translated", "value": "Движение, относительно этой ночи"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "活动,相对于今晚"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "活動,相對於今晚"}}}}, "Route and GPS distance, without the heart-rate and calorie summary": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Route and GPS distance, without the heart-rate and calorie summary"}}, "de": {"stringUnit": {"state": "translated", "value": "Route und GPS-Distanz, ohne die Herzfrequenz- und Kalorienübersicht"}}, "es": {"stringUnit": {"state": "translated", "value": "Ruta y distancia GPS, sin el resumen de frecuencia cardiaca y calorías"}}, "fr": {"stringUnit": {"state": "translated", "value": "Itinéraire et distance GPS, sans le récapitulatif de fréquence cardiaque et de calories"}}, "it": {"stringUnit": {"state": "translated", "value": "Percorso e distanza GPS, senza il riepilogo di frequenza cardiaca e calorie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trasa i dystans GPS, bez podsumowania tętna i kalorii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Rota e distância GPS, sem o resumo de frequência cardíaca e calorias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Маршрут и GPS-дистанция, без сводки пульса и калорий"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "路线和 GPS 距离,不含心率和卡路里摘要"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "路線和 GPS 距離,不含心率和卡路里摘要"}}}}, "Export Workout Route": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Export Workout Route"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsroute exportieren"}}, "es": {"stringUnit": {"state": "translated", "value": "Exportar ruta del entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Exporter l’itinéraire de la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Esporta il percorso dell’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Eksportuj trasę treningu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Exportar rota do treino"}}, "ru": {"stringUnit": {"state": "translated", "value": "Экспортировать маршрут тренировки"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "导出运动路线"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "匯出運動路線"}}}}, diff --git a/Strand/Screens/SleepCustomizationSheet.swift b/Strand/Screens/SleepCustomizationSheet.swift index 6840bed5a8..8a16d67595 100644 --- a/Strand/Screens/SleepCustomizationSheet.swift +++ b/Strand/Screens/SleepCustomizationSheet.swift @@ -97,6 +97,7 @@ extension SleepSection { case .bodyClock: return "clock.badge.checkmark" case .nightDetail: return "square.grid.2x2" case .sleepDebt: return "arrow.down.right.circle" + case .sleepPlanner: return "bed.double.circle" case .stagesVsTypical: return "chart.bar" case .asleepDuration: return "clock" } @@ -111,6 +112,7 @@ extension SleepSection { case .bodyClock: return StrandPalette.restColor case .nightDetail: return StrandPalette.restBright case .sleepDebt: return StrandPalette.effortColor + case .sleepPlanner: return StrandPalette.restColor case .stagesVsTypical: return StrandPalette.restColor case .asleepDuration: return StrandPalette.restBright } diff --git a/Strand/Screens/SleepPlannerCard.swift b/Strand/Screens/SleepPlannerCard.swift new file mode 100644 index 0000000000..80629cc9d5 --- /dev/null +++ b/Strand/Screens/SleepPlannerCard.swift @@ -0,0 +1,155 @@ +import SwiftUI +import StrandDesign +import StrandAnalytics +import WhoopStore + +// MARK: - Sleep planner card +// +// Tonight's sleep target at three goals (70 / 85 / 100 % of tonight's need), the time in bed each one takes +// at the user's typical efficiency, and the bedtime that implies for the next wake. The arithmetic lives in +// the pure `SleepPlanner` (StrandAnalytics; Kotlin twin com.noop.analytics.SleepPlanner); this file only +// resolves the wake time and renders. Mirrors the Android `SleepPlannerCard`. Display-only. + +/// Where the planner's wake time came from, so the card can say so instead of hiding it. +enum PlannerWakeSource { case alarm, lastNight, fallback } + +struct SleepPlannerCard: View { + let model: SleepModel + /// Asleep minutes in today's naps (outside the main night). + let napMinToday: Double + @EnvironmentObject private var repo: Repository + @EnvironmentObject private var behavior: BehaviorStore + + static let defaultWakeMinute = 7 * 60 + + var body: some View { + let wake = resolvedWake() + let plan = SleepPlanner.plan( + nights: repo.days.map { (day: $0.day, asleepMin: $0.totalSleepMin, efficiency: $0.efficiency) }, + baseNeedMin: baseNeedMin, + debtMin: model.sleepDebt.latest ?? 0, + napMinToday: napMinToday, + wakeMinuteOfDay: wake.minute) + + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + SectionHeader("Sleep planner", overline: "Tonight") + NoopCard(tint: StrandPalette.restColor) { + VStack(alignment: .leading, spacing: NoopMetrics.cardInnerSpacing) { + if let plan { + breakdown(plan) + Divider() + Text(wakeLine(wake)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + ForEach(plan.tiers, id: \.goal) { tierRow($0) } + Text(plan.efficiencyUsed < 1.0 + ? String(localized: "A planning guide, not a score. Time in bed assumes your typical \(Int(plan.efficiencyUsed * 100))% sleep efficiency.") + : String(localized: "A planning guide, not a score. No efficiency history yet, so time in bed equals the sleep target.")) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } else { + Text("Needs three nights in a row with sleep before it can plan tonight.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + } + + /// The same need the Hours-vs-Needed tile measures against: the imported figure for the newest night, + /// else the engine's personalised need. + private var baseNeedMin: Double { + let newest = repo.days.last(where: { ($0.totalSleepMin ?? 0) > 0 })?.day + return newest.flatMap { repo.importedSleep[$0]?.needMin } ?? SleepModel.debtNeedMin(days: repo.days) + } + + /// The armed alarm's next fire (the same `nextSmartAlarmDate` the strap is armed from), else last + /// night's wake, else 07:00. + private func resolvedWake() -> (minute: Int, source: PlannerWakeSource) { + let cal = Calendar.current + if behavior.smartAlarmEnabled, + let next = AppModel.nextSmartAlarmDate(minutes: behavior.smartAlarmMinutes, + weekdays: behavior.smartAlarmWeekdays, + overrides: WindDownNudge.perDayWakeOverrides) { + let c = cal.dateComponents([.hour, .minute], from: next) + return ((c.hour ?? 7) * 60 + (c.minute ?? 0), .alarm) + } + let end = Date(timeIntervalSince1970: TimeInterval(model.night.session.endTs)) + let c = cal.dateComponents([.hour, .minute], from: end) + if let h = c.hour, let m = c.minute { return (h * 60 + m, .lastNight) } + return (Self.defaultWakeMinute, .fallback) + } + + private func wakeLine(_ wake: (minute: Int, source: PlannerWakeSource)) -> String { + let t = clock(wake.minute) + switch wake.source { + case .alarm: return String(localized: "Wake \(t) · from your alarm") + case .lastNight: return String(localized: "Wake \(t) · from last night") + case .fallback: return String(localized: "Wake \(t) · no alarm or recent wake yet") + } + } + + private func breakdown(_ plan: SleepPlan) -> some View { + HStack(alignment: .top) { + cell(String(localized: "Base"), durationText(Double(plan.baseNeedMin))) + cell(String(localized: "Debt"), "+" + durationText(Double(plan.debtMin))) + cell(String(localized: "Naps"), "−" + durationText(Double(plan.napCreditMin))) + cell(String(localized: "Tonight"), durationText(Double(plan.tonightNeedMin))) + } + } + + private func cell(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: NoopMetrics.spaceHalf) { + Text(label).strandOverline() + Text(verbatim: value) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textPrimary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func tierRow(_ tier: SleepPlanTier) -> some View { + let (label, color): (String, Color) = { + switch tier.goal { + case .minimum: return (String(localized: "Minimum"), StrandPalette.restDeep) + case .solid: return (String(localized: "Solid"), StrandPalette.restColor) + case .full: return (String(localized: "Full"), StrandPalette.restBright) + } + }() + let pct = Int(tier.goal.fraction * 100) + return HStack { + VStack(alignment: .leading, spacing: NoopMetrics.spaceHalf) { + Text(label).font(StrandFont.headline).foregroundStyle(color) + Text("\(pct)% of need · \(durationText(Double(tier.sleepTargetMin))) asleep · \(durationText(Double(tier.timeInBedMin))) in bed") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + Spacer(minLength: NoopMetrics.space2) + VStack(alignment: .trailing, spacing: NoopMetrics.spaceHalf) { + Text("Bed by").strandOverline() + Text(verbatim: clock(tier.bedtimeMinuteOfDay)) + .font(StrandFont.number(20)) + .foregroundStyle(StrandPalette.textPrimary) + } + } + .padding(.horizontal, NoopMetrics.space3) + .padding(.vertical, NoopMetrics.space2) + .background(StrandPalette.surfaceInset, in: Capsule()) + .accessibilityElement(children: .combine) + } + + private func clock(_ minuteOfDay: Int) -> String { + let date = Calendar.current.date(bySettingHour: minuteOfDay / 60, minute: minuteOfDay % 60, + second: 0, of: Date()) ?? Date() + return AppClock.hourMinuteFormatter().string(from: date) + } + + /// Minutes → "Xm" / "Yh Zm" (verbatim of `SleepView.durationText`). + private func durationText(_ minutes: Double) -> String { + let m = Swift.max(0, Int(minutes.rounded())) + if m < 60 { return String(localized: "\(m)m") } + return String(localized: "\(m / 60)h \(m % 60)m") + } +} diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index f0955b7555..679803b8a5 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -481,6 +481,9 @@ struct SleepView: View { case .bodyClock: bodyClockDial(model) case .nightDetail: NightDetailCard(model: model) case .sleepDebt: SleepDebtLedgerCard(model: model) + case .sleepPlanner: SleepPlannerCard(model: model, + napMinToday: Self.napSleepMinutes(navDays.first ?? [], + habitualMidsleepSec: habitualMidsleepSec)) case .stagesVsTypical: StagesVsTypicalCard(model: model) case .asleepDuration: durationTrend(model) } diff --git a/StrandTests/SleepLayoutPrefsTests.swift b/StrandTests/SleepLayoutPrefsTests.swift index 475f574024..8ba6606b21 100644 --- a/StrandTests/SleepLayoutPrefsTests.swift +++ b/StrandTests/SleepLayoutPrefsTests.swift @@ -14,10 +14,10 @@ final class SleepLayoutPrefsTests: XCTestCase { func testEncodeDecodeRoundTripsAReorderedList() { let reordered: [SleepSection] = [ - .nightDetail, .sleepMarks, .asleepDuration, .stages, .sleepDebt, .stagesVsTypical, + .nightDetail, .sleepMarks, .asleepDuration, .stages, .sleepDebt, .sleepPlanner, .stagesVsTypical, ] let encoded = SleepLayoutPrefs.encode(reordered) - XCTAssertEqual(encoded, "nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,stagesVsTypical") + XCTAssertEqual(encoded, "nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,sleepPlanner,stagesVsTypical") // `bodyClock` is absent from the saved order, so decode inserts it at its default position — the // migration path every newly-added section takes for an existing customised layout. XCTAssertEqual(SleepLayoutPrefs.decodeOrder(encoded), [.bodyClock] + reordered) @@ -30,7 +30,7 @@ final class SleepLayoutPrefsTests: XCTestCase { let decoded = SleepLayoutPrefs.decodeOrder("asleepDuration,sleepMarks") XCTAssertEqual(decoded.count, SleepSection.allCases.count) XCTAssertEqual(decoded, [ - .stages, .bodyClock, .nightDetail, .sleepDebt, .stagesVsTypical, .asleepDuration, .sleepMarks, + .stages, .bodyClock, .nightDetail, .sleepDebt, .sleepPlanner, .stagesVsTypical, .asleepDuration, .sleepMarks, ]) } @@ -55,10 +55,10 @@ final class SleepLayoutPrefsTests: XCTestCase { } func testVisibleOrderFiltersHiddenWithoutChangingSavedOrder() { - let order = "nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,stagesVsTypical" + let order = "nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,sleepPlanner,stagesVsTypical" XCTAssertEqual( SleepLayoutPrefs.visibleOrder(orderRaw: order, hiddenRaw: "asleepDuration,sleepDebt"), - [.bodyClock, .nightDetail, .sleepMarks, .stages, .stagesVsTypical] + [.bodyClock, .nightDetail, .sleepMarks, .stages, .sleepPlanner, .stagesVsTypical] ) XCTAssertEqual(SleepLayoutPrefs.decodeOrder(order).count, SleepSection.allCases.count) } @@ -81,7 +81,7 @@ final class SleepLayoutPrefsTests: XCTestCase { XCTAssertEqual(raws.count, Set(raws).count) // Pin the exact wire strings — they cross the .noopbak boundary and must match Android byte-for-byte. XCTAssertEqual(raws, [ - "sleepMarks", "stages", "bodyClock", "nightDetail", "sleepDebt", "stagesVsTypical", + "sleepMarks", "stages", "bodyClock", "nightDetail", "sleepDebt", "sleepPlanner", "stagesVsTypical", "asleepDuration", ]) } diff --git a/Tools/parity_ledger_baseline.json b/Tools/parity_ledger_baseline.json index 9fbbdc2a3a..0849ddf80f 100644 --- a/Tools/parity_ledger_baseline.json +++ b/Tools/parity_ledger_baseline.json @@ -15,7 +15,7 @@ {"rule": "duplicate-implementation", "scope": "android/ingest", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope android/ingest; no wildcard matching.", "count": 2, "identities_sha256": "8b3126b470191bb155e6d7d3caf111423e5738edab66643558805020a0b1123d"}, {"rule": "duplicate-implementation", "scope": "android/ui", "reason": "Exact platform-local implementation is retained as visible parity debt pending a source-level consolidation decision.", "provenance": "Exact identities emitted by parity_ledger rule duplicate-implementation for current upstream scope android/ui; no wildcard matching.", "count": 4, "identities_sha256": "b9f579c3d30221ff72e40282c29baab20d57d95acf044e0f71d2338e37c52b1b"}, {"rule": "test-only-callsite", "scope": "Packages/OuraProtocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/OuraProtocol; no wildcard matching.", "count": 5, "identities_sha256": "ff6404cd4d27782334dd844223be3ec86bb3260bef7f7e29c658fb6ad6e7938e"}, - {"rule": "test-only-callsite", "scope": "Packages/StrandAnalytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/StrandAnalytics; no wildcard matching.", "count": 44, "identities_sha256": "fff777acbc66e7dad92bd0a6c8b7644e5c7ab6ddc0a7106dfecc49319da6127b"}, + {"rule": "test-only-callsite", "scope": "Packages/StrandAnalytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/StrandAnalytics; no wildcard matching.", "count": 43, "identities_sha256": "fec40b4791fb0ed15bd5bd70e57785721c2bee91b58b5f89c9cfcf62781d845f"}, {"rule": "test-only-callsite", "scope": "Packages/StrandImport", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/StrandImport; no wildcard matching.", "count": 14, "identities_sha256": "1acdc71178e0a555f000c42980345e8c38495f9e3806a866f31b260865d3ac98"}, {"rule": "test-only-callsite", "scope": "Packages/WhoopProtocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopProtocol; no wildcard matching.", "count": 29, "identities_sha256": "a13306c37eb1a9272b8666d09700d5120fb5fe0e629f3aaaeef649357f7f8b45"}, {"rule": "test-only-callsite", "scope": "Packages/WhoopStore", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopStore; no wildcard matching.", "count": 37, "identities_sha256": "2a62c1ece1c541b662dd407f0668d9a02782bb4d21f9bb181518272ff12f0e40"}, diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 767e162915..024f37fc9a 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,14 +18,14 @@ ] }, "authority": { - "files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"}, - "functions": {"count": 4537, "sha256": "a10084c830d9b91c0f02bb308fa7ac0f1466c7537de3303669e0f4fbeffdf6f2"}, - "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, - "constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"}, - "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, - "function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"}, - "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, - "constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"}, + "files": {"count": 511, "sha256": "1acbf0eff3b98b18683a8d8e5de497bffeed1d1f1abe7881887cdbcbe8ae0904"}, + "functions": {"count": 4549, "sha256": "01e1d8449fad2bfa138bebc870badefd4c8ec5907b92aaae13c74085fb294999"}, + "properties": {"count": 490, "sha256": "77e38a15cf9f1bdd239d0fe691d0ae96c9f792fecaa7d58351263934e01cd9a9"}, + "constants": {"count": 2009, "sha256": "d97bba121420fd82d03882004f7abbf40e8d2f61ffa391ed832218dac71f1853"}, + "file_pairs": {"count": 82, "sha256": "6627bf0c6e0775d1276091a59554f5d7299822da1cb10b2a3c2afe8b3efab41a"}, + "function_pairs": {"count": 232, "sha256": "665ecf3f19523294d7c975a48030679e1cc6d081a295c24a841e93e72e244af9"}, + "property_pairs": {"count": 164, "sha256": "dcd0c04bf9e9513f89e900ffbc4333f8048665d870c073a62c88bce5a107f8f7"}, + "constant_pairs": {"count": 706, "sha256": "e6218ab55b33199e255983cde8b8a3488a1217fabed1078438ec067d0b6f7c37"}, "unpaired_files": {"count": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"}, "unpaired_functions": {"count": 4098, "sha256": "a7c3215172bf4d1cd33b8df390684d4d65fc1109f8ee52b3d344b04d231f08fd"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, diff --git a/android/app/src/main/java/com/noop/analytics/SleepPlanner.kt b/android/app/src/main/java/com/noop/analytics/SleepPlanner.kt new file mode 100644 index 0000000000..a1c63b682d --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SleepPlanner.kt @@ -0,0 +1,186 @@ +package com.noop.analytics + +import kotlin.math.floor + +/* + * SleepPlanner.kt — tonight's sleep target, time in bed and bedtime for a wake time. + * + * Faithful Kotlin mirror of StrandAnalytics/SleepPlanner.swift. Keep the goal ladder, the + * consecutive-night gate, the nap rule, the efficiency median/clamp and the rounding + * byte-identical to Swift — the two clients must print the same plan for the same nights. + * + * Pure, deterministic, DB-free. PRESENTATION ONLY: nothing here feeds Rest, recovery, debt or + * any stored value. + * + * The goal ladder (70 / 85 / 100 % of tonight's need) and the three-consecutive-night gate follow + * the shape of the WHOOP app's sleep planner as observed in its shipped resources (facts only, no + * WHOOP code). The rest is NOOP's own arithmetic: + * - tonight's need = base need + current debt − today's naps, where naps only repay debt; + * - time in bed = sleep target ÷ typical efficiency (median of the newest 14 usable nights, + * clamped to 50…100 %), or the target itself when there is no efficiency history; + * - bedtime = wake − time in bed, wrapped into the day. + */ + +/** One sleep goal on the planner ladder. [raw] matches the Swift `SleepPlannerGoal` raw value. */ +enum class SleepPlannerGoal(val raw: String) { + MINIMUM("minimum"), + SOLID("solid"), + FULL("full"), + ; + + /** Fraction of tonight's need this goal asks for. */ + val fraction: Double get() = when (this) { + MINIMUM -> 0.70 + SOLID -> 0.85 + FULL -> 1.00 + } +} + +/** The planner's read for one goal. Mirrors Swift `SleepPlanTier`. */ +data class SleepPlanTier( + val goal: SleepPlannerGoal, + /** Asleep minutes the goal asks for. */ + val sleepTargetMin: Int, + /** Minutes in bed needed to reach the target at the typical efficiency. */ + val timeInBedMin: Int, + /** Bedtime as a local minute of day in [0, 1440). */ + val bedtimeMinuteOfDay: Int, +) + +/** Tonight's plan: the need breakdown plus one tier per goal. Mirrors Swift `SleepPlan`. */ +data class SleepPlan( + val baseNeedMin: Int, + val debtMin: Int, + val napCreditMin: Int, + val tonightNeedMin: Int, + /** Efficiency used for time in bed, 0.5…1.0 (1.0 when there was no history). */ + val efficiencyUsed: Double, + /** One tier per goal, in [SleepPlannerGoal.entries] order. */ + val tiers: List, +) + +/** One night row for the planner: day key, asleep minutes and efficiency (fraction or percent). */ +data class SleepPlannerNight(val day: String, val asleepMin: Double?, val efficiency: Double?) + +object SleepPlanner { + + /** Consecutive nights with sleep the plan needs before it says anything. */ + const val REQUIRED_CONSECUTIVE_NIGHTS: Int = 3 + + /** How many recent nights the typical efficiency is taken over. */ + const val EFFICIENCY_WINDOW_NIGHTS: Int = 14 + + const val MIN_EFFICIENCY: Double = 0.5 + const val MAX_EFFICIENCY: Double = 1.0 + + /** + * Build tonight's plan, or null when there are fewer than [REQUIRED_CONSECUTIVE_NIGHTS] + * consecutive nights ending at the newest night. [nights] are chronological; negative debt + * reads as 0. Mirrors Swift `SleepPlanner.plan`. + * Swift twin: `SleepPlanner.plan`. + */ + fun plan( + nights: List, + baseNeedMin: Double, + debtMin: Double, + napMinToday: Double, + wakeMinuteOfDay: Int, + ): SleepPlan? { + if (consecutiveNights(nights.map { it.day to it.asleepMin }) < REQUIRED_CONSECUTIVE_NIGHTS) return null + val base = maxOf(0.0, baseNeedMin) + val debt = maxOf(0.0, debtMin) + val napCredit = minOf(maxOf(0.0, napMinToday), debt) + val tonight = base + debt - napCredit + val eff = typicalEfficiency(nights.map { it.efficiency }) ?: 1.0 + + val tiers = SleepPlannerGoal.entries.map { goal -> + val target = tonight * goal.fraction + val inBed = roundHalfAway(target / eff) + SleepPlanTier( + goal = goal, + sleepTargetMin = roundHalfAway(target), + timeInBedMin = inBed, + bedtimeMinuteOfDay = wrapMinute(wakeMinuteOfDay - inBed), + ) + } + return SleepPlan( + baseNeedMin = roundHalfAway(base), + debtMin = roundHalfAway(debt), + napCreditMin = roundHalfAway(napCredit), + tonightNeedMin = roundHalfAway(tonight), + efficiencyUsed = eff, + tiers = tiers, + ) + } + + /** + * Length of the run of calendar-consecutive nights with sleep (> 0 min) ending at the newest + * night carrying sleep. Rows without sleep are ignored; a gap ends the run, as does a + * malformed day key. Mirrors Swift `SleepPlanner.consecutiveNights`. + * Swift twin: `SleepPlanner.consecutiveNights`. + */ + fun consecutiveNights(rows: List>): Int { + val slept = ArrayList() + for ((day, min) in rows) if (min != null && min > 0.0) slept.add(epochDay(day)) + if (slept.isEmpty()) return 0 + var previous = slept.last() ?: return 0 + var run = 1 + for (i in slept.lastIndex - 1 downTo 0) { + val day = slept[i] ?: break + if (day == previous) continue + if (day != previous - 1) break + run += 1 + previous = day + } + return run + } + + /** + * Median efficiency (fraction) over the newest [EFFICIENCY_WINDOW_NIGHTS] usable values, + * clamped to [MIN_EFFICIENCY]..[MAX_EFFICIENCY]; null when none are usable. + * Swift twin: `SleepPlanner.typicalEfficiency`. + */ + fun typicalEfficiency(values: List): Double? { + val usable = values.mapNotNull { v -> v?.takeIf { it > 0.0 }?.let { if (it > 1.0) it / 100.0 else it } } + val window = usable.takeLast(EFFICIENCY_WINDOW_NIGHTS).sorted() + if (window.isEmpty()) return null + val mid = window.size / 2 + val median = if (window.size % 2 == 1) window[mid] else (window[mid - 1] + window[mid]) / 2.0 + return minOf(MAX_EFFICIENCY, maxOf(MIN_EFFICIENCY, median)) + } + + /** Days since 1970-01-01 for a "yyyy-MM-dd" key (proleptic Gregorian), null if malformed. Swift twin: `SleepPlanner.epochDay`. */ + fun epochDay(key: String): Int? { + val parts = key.split("-") + if (parts.size != 3) return null + val y0 = parts[0].toIntOrNull() ?: return null + val m = parts[1].toIntOrNull() ?: return null + val d = parts[2].toIntOrNull() ?: return null + if (m !in 1..12 || d !in 1..31) return null + val y = if (m <= 2) y0 - 1 else y0 + val era = (if (y >= 0) y else y - 399) / 400 + val yoe = y - era * 400 + val mp = (m + 9) % 12 + val doy = (153 * mp + 2) / 5 + d - 1 + val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy + return era * 146_097 + doe - 719_468 + } + + /** Swift twin: `SleepPlanner.wrapMinute`. */ + internal fun wrapMinute(m: Int): Int { + val day = 24 * 60 + return ((m % day) + day) % day + } + + /** + * Half-away-from-zero, matching Swift `Double.rounded()`. Uses the exact fractional part + * rather than `floor(v + 0.5)`, which rounds 0.49999999999999994 up. + * Swift twin: `SleepPlanner.roundHalfAway`. + */ + internal fun roundHalfAway(v: Double): Int { + val a = kotlin.math.abs(v) + val f = floor(a) + val r = if (a - f >= 0.5) f + 1.0 else f + return (if (v < 0) -r else r).toInt() + } +} diff --git a/android/app/src/main/java/com/noop/ui/SleepLayoutPrefs.kt b/android/app/src/main/java/com/noop/ui/SleepLayoutPrefs.kt index 0038534d9a..7c20673c49 100644 --- a/android/app/src/main/java/com/noop/ui/SleepLayoutPrefs.kt +++ b/android/app/src/main/java/com/noop/ui/SleepLayoutPrefs.kt @@ -40,7 +40,13 @@ enum class SleepSection(val raw: String, val title: String) { * diverge here: `sleep.sectionOrder` is not in the .noopbak whitelist, so a cross-OS restore never * reads them. (Consistency's underlying score also differs across platforms — a separate parity item.) */ HOURS_VS_NEEDED("hoursVsNeeded", "Hours vs Needed"), - CONSISTENCY("consistency", "Consistency"); + CONSISTENCY("consistency", "Consistency"), + + /** Android-only like the two above: tonight's sleep target at three goals with the bedtime each implies + * (pure math in analytics/SleepPlanner, Swift twin in StrandAnalytics). The Apple-platform card is a + * follow-up, so this rawValue does not exist in the macOS `SleepSection` yet; `sleep.sectionOrder` is not + * in the .noopbak whitelist, so a cross-OS restore never reads it. */ + SLEEP_PLANNER("sleepPlanner", "Sleep planner"); companion object { fun fromRaw(raw: String?): SleepSection? = entries.firstOrNull { it.raw == raw } @@ -51,7 +57,7 @@ enum class SleepSection(val raw: String, val title: String) { * card is a follow-up that requires hoisting the hero's edit/delete callbacks.) */ val defaultOrder: List = listOf( SLEEP_MARKS, STAGES, BODY_CLOCK, NIGHT_DETAIL, SLEEP_DEBT, STAGES_VS_TYPICAL, ASLEEP_DURATION, - HOURS_VS_NEEDED, CONSISTENCY, + HOURS_VS_NEEDED, SLEEP_PLANNER, CONSISTENCY, ) } } @@ -73,6 +79,7 @@ fun SleepSection.localizedTitle(): String = when (this) { SleepSection.ASLEEP_DURATION -> stringResource(R.string.l10n_sleep_screen_asleep_duration_3638413f) SleepSection.HOURS_VS_NEEDED -> stringResource(R.string.l10n_sleep_screen_hours_vs_needed_500a0aca) SleepSection.CONSISTENCY -> stringResource(R.string.l10n_sleep_screen_consistency_0ea7b95e) + SleepSection.SLEEP_PLANNER -> stringResource(R.string.l10n_sleep_planner_title) } /** diff --git a/android/app/src/main/java/com/noop/ui/SleepPlannerCard.kt b/android/app/src/main/java/com/noop/ui/SleepPlannerCard.kt new file mode 100644 index 0000000000..8da2274d9e --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/SleepPlannerCard.kt @@ -0,0 +1,160 @@ +package com.noop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import com.noop.R +import com.noop.analytics.ClockFormat +import com.noop.analytics.SleepPlan +import com.noop.analytics.SleepPlannerGoal +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +// MARK: - Sleep planner card +// +// Tonight's sleep target at three goals (70 / 85 / 100 % of tonight's need), the time in bed each one +// takes at the user's typical efficiency, and the bedtime that implies for the next wake. The arithmetic +// lives in the pure analytics SleepPlanner (Swift twin in StrandAnalytics); this file only resolves the +// wake time and renders. Display-only: nothing here feeds a score or a stored value. + +/** Where the planner's wake time came from, so the card can say so instead of hiding it. */ +internal enum class PlannerWakeSource { ALARM, LAST_NIGHT, DEFAULT } + +/** + * The wake the plan is built for. The armed smart alarm wins (its per-day target for the NEXT morning, + * resolved through [SmartAlarmStore.targetFor] so this card and the Alarms screen read one value); else + * last night's wake; else 07:00. [nextMorningDow] is the `Calendar.DAY_OF_WEEK` of the coming morning. + */ +internal fun plannerWake( + alarmEnabled: Boolean, + alarmTargetFor: (Int) -> Int, + nextMorningDow: Int, + lastWakeMinuteOfDay: Int?, +): Pair = when { + alarmEnabled -> alarmTargetFor(nextMorningDow) to PlannerWakeSource.ALARM + lastWakeMinuteOfDay != null -> lastWakeMinuteOfDay to PlannerWakeSource.LAST_NIGHT + else -> DEFAULT_PLANNER_WAKE to PlannerWakeSource.DEFAULT +} + +internal const val DEFAULT_PLANNER_WAKE = 7 * 60 + +/** The coming morning's day of week: today before noon, tomorrow from noon on. */ +internal fun nextMorningDayOfWeek(now: Calendar): Int { + val c = now.clone() as Calendar + if (c.get(Calendar.HOUR_OF_DAY) >= 12) c.add(Calendar.DAY_OF_YEAR, 1) + return c.get(Calendar.DAY_OF_WEEK) +} + +/** A minute of day in the shared clock pattern ([ClockFormat.hourMinutePattern], #1821). */ +internal fun plannerClock(minuteOfDay: Int, is24h: Boolean): String { + val c = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, minuteOfDay / 60) + set(Calendar.MINUTE, minuteOfDay % 60) + } + return SimpleDateFormat(ClockFormat.hourMinutePattern(is24h), Locale.getDefault()).format(c.time) +} + +@Composable +internal fun SleepPlannerCard(plan: SleepPlan?, wake: Pair, is24h: Boolean) { + NoopCard(padding = Metrics.cardPadding, tint = Palette.restColor) { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space14)) { + Column { + Overline(uiString(R.string.l10n_sleep_planner_overline_tonight)) + Text(uiString(R.string.l10n_sleep_planner_title), style = NoopType.headline, color = Palette.textPrimary) + } + if (plan == null) { + Text(uiString(R.string.l10n_sleep_planner_needs_three_nights), style = NoopType.footnote, color = Palette.textSecondary) + return@Column + } + + // Need breakdown: base + debt − naps = tonight. + Row(modifier = Modifier.fillMaxWidth()) { + listOf( + uiString(R.string.l10n_sleep_planner_base_need) to durationText(plan.baseNeedMin.toDouble()), + uiString(R.string.l10n_sleep_planner_debt) to "+" + durationText(plan.debtMin.toDouble()), + uiString(R.string.l10n_sleep_planner_naps) to "−" + durationText(plan.napCreditMin.toDouble()), + uiString(R.string.l10n_sleep_planner_tonight_need) to durationText(plan.tonightNeedMin.toDouble()), + ).forEach { (label, value) -> + Column(modifier = Modifier.weight(1f)) { + Overline(label, color = Palette.textTertiary) + Text(value, style = NoopType.captionNumber, color = Palette.textPrimary) + } + } + } + + SleepHairline() + val wakeText = plannerClock(wake.first, is24h) + Text( + when (wake.second) { + PlannerWakeSource.ALARM -> uiString(R.string.l10n_sleep_planner_wake_from_alarm, wakeText) + PlannerWakeSource.LAST_NIGHT -> uiString(R.string.l10n_sleep_planner_wake_from_last_night, wakeText) + PlannerWakeSource.DEFAULT -> uiString(R.string.l10n_sleep_planner_wake_default, wakeText) + }, + style = NoopType.footnote, + color = Palette.textSecondary, + ) + + plan.tiers.forEach { tier -> + val (label, color) = when (tier.goal) { + SleepPlannerGoal.MINIMUM -> uiString(R.string.l10n_sleep_planner_goal_minimum) to Palette.restDeep + SleepPlannerGoal.SOLID -> uiString(R.string.l10n_sleep_planner_goal_solid) to Palette.restColor + SleepPlannerGoal.FULL -> uiString(R.string.l10n_sleep_planner_goal_full) to Palette.restBright + } + PlannerTierRow( + label = label, + percent = (tier.goal.fraction * 100).toInt(), + accent = color, + sleep = durationText(tier.sleepTargetMin.toDouble()), + inBed = durationText(tier.timeInBedMin.toDouble()), + bedtime = plannerClock(tier.bedtimeMinuteOfDay, is24h), + ) + } + + Text( + if (plan.efficiencyUsed < 1.0) { + uiString(R.string.l10n_sleep_planner_footnote_efficiency, (plan.efficiencyUsed * 100).toInt()) + } else { + uiString(R.string.l10n_sleep_planner_footnote_no_efficiency) + }, + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } + } +} + +@Composable +private fun PlannerTierRow(label: String, percent: Int, accent: Color, sleep: String, inBed: String, bedtime: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Metrics.cornerPill)) + .background(Palette.surfaceInset) + .padding(horizontal = Metrics.space14, vertical = Metrics.space8), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(label, style = NoopType.headline, color = accent) + Text( + uiString(R.string.l10n_sleep_planner_tier_detail, percent, sleep, inBed), + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } + Column(horizontalAlignment = Alignment.End) { + Overline(uiString(R.string.l10n_sleep_planner_bed_by), color = Palette.textTertiary) + Text(bedtime, style = NoopType.chartValue, color = Palette.textPrimary) + } + } +} diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index 5dbc199f6e..ab8d4b2137 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -594,6 +594,29 @@ fun SleepScreen( model ?: fallbackSleepModel(days, imported, napSleepMinByDay, sessions = sleeps) } + // Sleep planner (display-only): tonight's need = the SAME need + debt the Hours-vs-Needed card prints + // (tilesModel trends), minus today's naps; the wake is the armed alarm's next-morning target, else last + // night's wake. Pure math in analytics/SleepPlanner (Swift twin: StrandAnalytics/SleepPlanner.swift). + val sleepPlanWake = remember(sleeps) { + val store = com.noop.alarm.SmartAlarmStore.from(context) + val lastWake = sleeps.maxByOrNull { it.endTs }?.endTs?.let { end -> + Calendar.getInstance().apply { timeInMillis = end * 1000L } + .let { it.get(Calendar.HOUR_OF_DAY) * 60 + it.get(Calendar.MINUTE) } + } + plannerWake(store.enabled, store::targetFor, nextMorningDayOfWeek(Calendar.getInstance()), lastWake) + } + val sleepPlan = remember(tilesModel, days, napSleepMinByDay, sleepPlanWake) { + tilesModel?.let { m -> + com.noop.analytics.SleepPlanner.plan( + nights = days.map { com.noop.analytics.SleepPlannerNight(it.day, it.totalSleepMin, it.efficiency) }, + baseNeedMin = (m.trendNeedHours.lastOrNull() ?: 8.0) * 60.0, + debtMin = (m.trendDebtHours.lastOrNull() ?: 0.0) * 60.0, + napMinToday = days.lastOrNull()?.day?.let { napSleepMinByDay[it] } ?: 0.0, + wakeMinuteOfDay = sleepPlanWake.first, + ) + } + } + // Jump straight to a night by its (local) wake-day — the center date block opens a picker. // navDays is newest-day-first, so the day's index IS its offset (0 = last night). (#160, #59) val onPickNightDate: (LocalDate) -> Unit = { targetDate -> @@ -988,6 +1011,14 @@ fun SleepScreen( } } } + SleepSection.SLEEP_PLANNER -> if (tilesModel != null) item(key = k) { + SleepReorderableSection(k, sleepListState, sleepSectionDrag, persistSleepOrder) { + Column { + Spacer(Modifier.height(Metrics.selectorTopUp)) + SleepPlannerCard(sleepPlan, sleepPlanWake, is24h) + } + } + } // Gated on tilesModel to preserve the pre-refactor visibility (both cards shared one // `tilesModel?.let` wrapper) — the card reads `sleeps`, not the model, but must not appear // in the #940 phantom-edit state (night != null, tilesModel == null) where it didn't before. diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 8399bc377c..bd2cc2ff5b 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2873,4 +2873,21 @@ Auf diesem Strap läuft möglicherweise noch eine Aufzeichnung. Das Ergebnis steht im Strap-Log. Erscheint heute, wenn Live Sessions in den Einstellungen aktiviert sind oder eine Session aktiv ist. Today nach Ihren Wünschen, Herzfrequenz für jede App und Italienisch + Schlafplaner + Heute Nacht + Braucht drei aufeinanderfolgende Nächte mit Schlaf, bevor er heute Nacht planen kann. + Basis + Schuld + Nickerchen + Heute Nacht + Wecken %1$s · von deinem Wecker + Wecken %1$s · von letzter Nacht + Wecken %1$s · noch kein Wecker oder Aufwachen + Minimum + Solide + Voll + %1$d%% des Bedarfs · %2$s Schlaf · %3$s im Bett + Ins Bett bis + Eine Planungshilfe, kein Score. Die Zeit im Bett setzt deine typische Schlafeffizienz von %1$d%% voraus. + Eine Planungshilfe, kein Score. Noch keine Effizienzdaten, daher entspricht die Zeit im Bett dem Schlafziel. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 20aed28131..cab3ee86f3 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2860,4 +2860,21 @@ Puede que siga activa una captura en esta correa. El veredicto se registra en el strap log. Aparece hoy si Live Sessions está activado en Ajustes o hay una sesión activa. Hoy a tu manera, frecuencia cardíaca para cualquier aplicación e italiano + Planificador de sueño + Esta noche + Necesita tres noches seguidas con sueño antes de poder planificar esta noche. + Base + Deuda + Siestas + Esta noche + Despertar %1$s · de tu alarma + Despertar %1$s · de anoche + Despertar %1$s · aún sin alarma ni despertar reciente + Mínimo + Sólido + Completo + %1$d%% de la necesidad · %2$s dormido · %3$s en cama + A la cama a las + Una guía de planificación, no una puntuación. El tiempo en cama asume tu eficiencia de sueño típica del %1$d%%. + Una guía de planificación, no una puntuación. Aún no hay historial de eficiencia, así que el tiempo en cama equivale al objetivo de sueño. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index bd1c4cc3b1..7bd73f7308 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2859,4 +2859,21 @@ Une capture est peut-être encore active sur ce bracelet. Le verdict est écrit dans le strap log. Apparaît aujourd’hui si les sessions en direct sont activées dans Paramètres ou si une session est active. Aujourd\'hui à votre façon, fréquence cardiaque lisible par toute application, et l\'italien + Planificateur de sommeil + Cette nuit + Il faut trois nuits consécutives avec du sommeil avant de pouvoir planifier cette nuit. + Base + Dette + Siestes + Cette nuit + Réveil %1$s · depuis votre alarme + Réveil %1$s · d’après la nuit dernière + Réveil %1$s · pas encore d’alarme ni de réveil récent + Minimum + Solide + Complet + %1$d%% du besoin · %2$s de sommeil · %3$s au lit + Au lit à + Un guide de planification, pas un score. Le temps au lit suppose votre efficacité de sommeil habituelle de %1$d%%. + Un guide de planification, pas un score. Pas encore d’historique d’efficacité, donc le temps au lit égale l’objectif de sommeil. diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 1a47561f99..89de24f129 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2828,4 +2828,21 @@ Una acquisizione potrebbe essere ancora attiva su questo strap. Il verdetto va nello strap log. Compare oggi se le sessioni in diretta sono attivate nelle Impostazioni o una sessione è attiva. Oggi a modo tuo, frequenza cardiaca leggibile da ogni app e l\'italiano + Pianificatore del sonno + Stanotte + Servono tre notti consecutive con sonno prima di poter pianificare stanotte. + Base + Debito + Pisolini + Stanotte + Sveglia %1$s · dalla tua sveglia + Sveglia %1$s · dalla scorsa notte + Sveglia %1$s · ancora nessuna sveglia o risveglio recente + Minimo + Solido + Pieno + %1$d%% del fabbisogno · %2$s di sonno · %3$s a letto + A letto entro + Una guida di pianificazione, non un punteggio. Il tempo a letto presuppone la tua efficienza del sonno tipica del %1$d%%. + Una guida di pianificazione, non un punteggio. Nessuno storico di efficienza ancora, quindi il tempo a letto è pari all’obiettivo di sonno. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index b5e9c52de8..37edb772e7 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2876,4 +2876,21 @@ Na tym pasku może nadal trwać zapis. Werdykt trafia do strap log. Pojawia się dzisiaj, gdy sesje na żywo są włączone w Ustawieniach lub trwa sesja. Dziś po swojemu, tętno dostępne dla każdej aplikacji i język włoski + Planer snu + Dziś w nocy + Potrzebuje trzech kolejnych nocy ze snem, zanim zaplanuje dzisiejszą noc. + Baza + Dług + Drzemki + Dziś w nocy + Pobudka %1$s · z twojego alarmu + Pobudka %1$s · z ostatniej nocy + Pobudka %1$s · brak alarmu i ostatniej pobudki + Minimum + Solidnie + Pełny + %1$d%% potrzeby · %2$s snu · %3$s w łóżku + Do łóżka o + Wskazówka do planowania, nie wynik. Czas w łóżku zakłada twoją typową wydajność snu %1$d%%. + Wskazówka do planowania, nie wynik. Brak jeszcze historii wydajności, więc czas w łóżku równa się celowi snu. diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index b64877aeb3..11639ab184 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2852,4 +2852,21 @@ Pode ainda estar a decorrer uma captura nesta pulseira. O veredicto vai para o strap log. Aparece hoje quando as sessões em direto estão ativadas nas Definições ou existe uma sessão ativa. Hoje à sua maneira, frequência cardíaca para qualquer aplicação e italiano + Planeador de sono + Esta noite + Precisa de três noites seguidas com sono antes de poder planear esta noite. + Base + Dívida + Sestas + Esta noite + Acordar %1$s · do teu alarme + Acordar %1$s · da noite passada + Acordar %1$s · ainda sem alarme nem despertar recente + Mínimo + Sólido + Completo + %1$d%% da necessidade · %2$s a dormir · %3$s na cama + Deitar às + Um guia de planeamento, não uma pontuação. O tempo na cama assume a tua eficiência de sono típica de %1$d%%. + Um guia de planeamento, não uma pontuação. Ainda sem histórico de eficiência, por isso o tempo na cama é igual ao objetivo de sono. diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 6fbec7268f..4ffa02fcb4 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2755,4 +2755,21 @@ На этом ремешке запись может всё ещё идти. Результат попадёт в strap log. Появляется сегодня, если сессии в реальном времени включены в Настройках или сеанс уже активен. «Сегодня» на ваш вкус, пульс для любого приложения и итальянский язык + Планировщик сна + Сегодня ночью + Нужны три ночи подряд со сном, прежде чем можно спланировать эту ночь. + База + Долг + Дневной сон + Сегодня + Подъём %1$s · по вашему будильнику + Подъём %1$s · по прошлой ночи + Подъём %1$s · пока нет будильника или недавнего подъёма + Минимум + Хорошо + Полностью + %1$d%% потребности · %2$s сна · %3$s в постели + Лечь в + Ориентир для планирования, а не оценка. Время в постели учитывает вашу обычную эффективность сна %1$d%%. + Ориентир для планирования, а не оценка. Истории эффективности пока нет, поэтому время в постели равно цели сна. diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 75a9e8a04f..23038eb45b 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2830,4 +2830,21 @@ 此腕带上可能仍在采集。结论会写入 strap log。 在设置中启用Live Sessions 实时记录或有正在进行的训练时,今天会显示此部分。 自定义「今天」页面、任何应用都能读取的心率,以及意大利语 + 睡眠规划 + 今晚 + 需要连续三晚的睡眠数据才能规划今晚。 + 基础 + 睡眠债 + 小睡 + 今晚 + 起床 %1$s · 来自你的闹钟 + 起床 %1$s · 来自昨晚 + 起床 %1$s · 尚无闹钟或最近起床时间 + 最低 + 良好 + 充足 + 需求的 %1$d%% · 睡眠 %2$s · 在床 %3$s + 上床时间 + 这是规划参考,不是评分。在床时间按你通常 %1$d%% 的睡眠效率计算。 + 这是规划参考,不是评分。尚无睡眠效率记录,因此在床时间等于睡眠目标。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5ae979343a..7fbe5a2259 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2889,4 +2889,21 @@ A capture may still be running on this strap. The verdict goes to the strap log. Appears today when Live Sessions is enabled in Settings or a session is active. Today your way, heart rate any app can read, and Italian + Sleep planner + Tonight + Needs three nights in a row with sleep before it can plan tonight. + Base + Debt + Naps + Tonight + Wake %1$s · from your alarm + Wake %1$s · from last night + Wake %1$s · no alarm or recent wake yet + Minimum + Solid + Full + %1$d%% of need · %2$s asleep · %3$s in bed + Bed by + A planning guide, not a score. Time in bed assumes your typical %1$d%% sleep efficiency. + A planning guide, not a score. No efficiency history yet, so time in bed equals the sleep target. diff --git a/android/app/src/test/java/com/noop/analytics/SleepPlannerTest.kt b/android/app/src/test/java/com/noop/analytics/SleepPlannerTest.kt new file mode 100644 index 0000000000..96a563059c --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepPlannerTest.kt @@ -0,0 +1,92 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Pins the display-only [SleepPlanner]. Same cases as the Swift SleepPlannerTests; the oracle block + * holds lines printed by the Swift implementation (swiftc over SleepPlanner.swift, 144 input + * combinations, all identical to this twin) so a drift in either direction fails here. + */ +class SleepPlannerTest { + + private fun n(day: String, min: Double?, eff: Double?) = SleepPlannerNight(day, min, eff) + + private fun line(p: SleepPlan): String = + "${p.tonightNeedMin} ${p.napCreditMin} ${String.format(java.util.Locale.US, "%.6f", p.efficiencyUsed)} " + + p.tiers.joinToString(" ") { "${it.sleepTargetMin}/${it.timeInBedMin}/${it.bedtimeMinuteOfDay}" } + + @Test fun noPlanUnderThreeConsecutiveNights() { + val rows = listOf(n("2026-10-17", 420.0, 0.9), n("2026-10-19", 430.0, 0.9), n("2026-10-20", 440.0, 0.9)) + assertNull(SleepPlanner.plan(rows, 480.0, 0.0, 0.0, 420)) + } + + @Test fun consecutiveRunIgnoresEmptyRowsAndDuplicates() { + val rows = listOf( + "2026-10-16" to 400.0, "2026-10-17" to null, "2026-10-18" to 410.0, + "2026-10-19" to 420.0, "2026-10-19" to 30.0, "2026-10-20" to 430.0, "2026-10-21" to null, + ) + assertEquals(3, SleepPlanner.consecutiveNights(rows)) + } + + @Test fun consecutiveRunCrossesMonthAndLeapDay() { + assertEquals(3, SleepPlanner.consecutiveNights(listOf("2024-02-28" to 400.0, "2024-02-29" to 400.0, "2024-03-01" to 400.0))) + assertEquals(0, SleepPlanner.epochDay("1970-01-01")) + assertEquals(20_746, SleepPlanner.epochDay("2026-10-20")) + assertNull(SleepPlanner.epochDay("2026-13-01")) + } + + @Test fun malformedDayEndsTheRun() { + assertEquals(1, SleepPlanner.consecutiveNights(listOf("2026-10-18" to 400.0, "bad" to 400.0, "2026-10-20" to 400.0))) + } + + @Test fun tiersWithEfficiencyAndBedtimeWrap() { + val rows = listOf(n("2026-10-18", 420.0, 90.0), n("2026-10-19", 400.0, 0.88), n("2026-10-20", 450.0, 0.92)) + val plan = SleepPlanner.plan(rows, 480.0, 40.0, 0.0, 390) + assertNotNull(plan) + plan!! + assertEquals(520, plan.tonightNeedMin) + assertEquals(0.9, plan.efficiencyUsed, 1e-12) + assertEquals(listOf(364, 442, 520), plan.tiers.map { it.sleepTargetMin }) + assertEquals(listOf(404, 491, 578), plan.tiers.map { it.timeInBedMin }) + assertEquals(listOf(1426, 1339, 1252), plan.tiers.map { it.bedtimeMinuteOfDay }) + } + + @Test fun napsOnlyRepayDebt() { + val rows = listOf(n("2026-10-18", 420.0, null), n("2026-10-19", 400.0, null), n("2026-10-20", 450.0, null)) + val plan = SleepPlanner.plan(rows, 480.0, 30.0, 90.0, 420)!! + assertEquals(30, plan.napCreditMin) + assertEquals(480, plan.tonightNeedMin) + assertEquals(1.0, plan.efficiencyUsed, 0.0) + assertEquals(480, plan.tiers.last().timeInBedMin) + } + + @Test fun efficiencyMedianAndClamp() { + assertEquals(0.9, SleepPlanner.typicalEfficiency(listOf(0.8, null, 0.9, 95.0, 0.0))!!, 1e-12) + assertEquals(0.5, SleepPlanner.typicalEfficiency(listOf(0.2, 0.3))!!, 1e-12) + assertNull(SleepPlanner.typicalEfficiency(listOf(null, 0.0))) + } + + @Test fun roundingIsHalfAwayFromZero() { + assertEquals(511, SleepPlanner.roundHalfAway(510.5)) + assertEquals(-511, SleepPlanner.roundHalfAway(-510.5)) + assertEquals(0, SleepPlanner.roundHalfAway(0.49999999999999994)) + } + + // Swift oracle: base debt nap wake -> line, efficiencies [0.82, 91, effs[base % 5]]. + @Test fun matchesSwiftOracle() { + val effs = listOf(null, 0.82, 91.0, 0.87, 0.95) + fun run(b: Double, d: Double, nap: Double, w: Int): String { + val rows = listOf( + n("2026-10-18", 400.0, effs[1]), n("2026-10-19", 410.0, effs[2]), n("2026-10-20", 420.0, effs[b.toInt() % 5]), + ) + return line(SleepPlanner.plan(rows, b, d, nap, w)!!) + } + assertEquals("420 0 0.865000 294/340/1100 357/413/1027 420/486/954", run(420.0, -5.0, 0.0, 0)) + assertEquals("480 0 0.865000 336/388/1052 408/472/968 480/555/885", run(480.0, -5.0, 0.0, 0)) + assertEquals("511 0 0.865000 357/413/7 434/502/1358 511/590/1270", run(510.5, -5.0, 0.0, 420)) + assertEquals("511 95 0.865000 357/413/1026 434/502/937 511/590/849", run(510.5, 95.0, 200.0, 1439)) + } +} diff --git a/android/app/src/test/java/com/noop/ui/SleepLayoutPrefsTest.kt b/android/app/src/test/java/com/noop/ui/SleepLayoutPrefsTest.kt index 124d2039d5..343f18b05c 100644 --- a/android/app/src/test/java/com/noop/ui/SleepLayoutPrefsTest.kt +++ b/android/app/src/test/java/com/noop/ui/SleepLayoutPrefsTest.kt @@ -24,10 +24,10 @@ class SleepLayoutPrefsTest { val reordered = listOf( SleepSection.BODY_CLOCK, SleepSection.NIGHT_DETAIL, SleepSection.SLEEP_MARKS, SleepSection.ASLEEP_DURATION, SleepSection.STAGES, SleepSection.SLEEP_DEBT, SleepSection.STAGES_VS_TYPICAL, - SleepSection.HOURS_VS_NEEDED, SleepSection.CONSISTENCY, + SleepSection.HOURS_VS_NEEDED, SleepSection.SLEEP_PLANNER, SleepSection.CONSISTENCY, ) val encoded = SleepLayoutPrefs.encode(reordered) - assertEquals("bodyClock,nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,stagesVsTypical,hoursVsNeeded,consistency", encoded) + assertEquals("bodyClock,nightDetail,sleepMarks,asleepDuration,stages,sleepDebt,stagesVsTypical,hoursVsNeeded,sleepPlanner,consistency", encoded) assertEquals(reordered, SleepLayoutPrefs.decodeOrder(encoded)) } @@ -42,7 +42,7 @@ class SleepLayoutPrefsTest { listOf( SleepSection.STAGES, SleepSection.BODY_CLOCK, SleepSection.NIGHT_DETAIL, SleepSection.SLEEP_DEBT, SleepSection.STAGES_VS_TYPICAL, SleepSection.ASLEEP_DURATION, SleepSection.SLEEP_MARKS, - SleepSection.HOURS_VS_NEEDED, SleepSection.CONSISTENCY, + SleepSection.HOURS_VS_NEEDED, SleepSection.SLEEP_PLANNER, SleepSection.CONSISTENCY, ), decoded, ) @@ -77,7 +77,8 @@ class SleepLayoutPrefsTest { assertEquals( listOf( SleepSection.BODY_CLOCK, SleepSection.NIGHT_DETAIL, SleepSection.SLEEP_MARKS, SleepSection.STAGES, - SleepSection.STAGES_VS_TYPICAL, SleepSection.HOURS_VS_NEEDED, SleepSection.CONSISTENCY, + SleepSection.STAGES_VS_TYPICAL, SleepSection.HOURS_VS_NEEDED, SleepSection.SLEEP_PLANNER, + SleepSection.CONSISTENCY, ), SleepLayoutPrefs.visibleOrder(order, "asleepDuration,sleepDebt"), ) @@ -105,7 +106,7 @@ class SleepLayoutPrefsTest { assertEquals("raw keys must be unique (they're the persisted identity)", raws.size, raws.toSet().size) // Pin the exact wire strings — they cross the .noopbak boundary and must match macOS byte-for-byte. assertEquals( - listOf("sleepMarks", "stages", "bodyClock", "nightDetail", "sleepDebt", "stagesVsTypical", "asleepDuration", "hoursVsNeeded", "consistency"), + listOf("sleepMarks", "stages", "bodyClock", "nightDetail", "sleepDebt", "stagesVsTypical", "asleepDuration", "hoursVsNeeded", "consistency", "sleepPlanner"), raws, ) } diff --git a/android/app/src/test/java/com/noop/ui/SleepSectionReorderTest.kt b/android/app/src/test/java/com/noop/ui/SleepSectionReorderTest.kt index 7beb24571c..0900a0e073 100644 --- a/android/app/src/test/java/com/noop/ui/SleepSectionReorderTest.kt +++ b/android/app/src/test/java/com/noop/ui/SleepSectionReorderTest.kt @@ -3,6 +3,7 @@ package com.noop.ui import com.noop.ui.SleepSection.ASLEEP_DURATION import com.noop.ui.SleepSection.BODY_CLOCK import com.noop.ui.SleepSection.CONSISTENCY +import com.noop.ui.SleepSection.SLEEP_PLANNER import com.noop.ui.SleepSection.HOURS_VS_NEEDED import com.noop.ui.SleepSection.NIGHT_DETAIL import com.noop.ui.SleepSection.SLEEP_DEBT @@ -25,14 +26,14 @@ class SleepSectionReorderTest { @Test fun movingDownPlacesTheCardAtTheTargetsSlot() { assertEquals( - listOf(STAGES, BODY_CLOCK, NIGHT_DETAIL, SLEEP_MARKS, SLEEP_DEBT, STAGES_VS_TYPICAL, ASLEEP_DURATION, HOURS_VS_NEEDED, CONSISTENCY), + listOf(STAGES, BODY_CLOCK, NIGHT_DETAIL, SLEEP_MARKS, SLEEP_DEBT, STAGES_VS_TYPICAL, ASLEEP_DURATION, HOURS_VS_NEEDED, SLEEP_PLANNER, CONSISTENCY), order.movedSleepSection(SLEEP_MARKS, NIGHT_DETAIL), ) } @Test fun movingUpPlacesTheCardAtTheTargetsSlot() { assertEquals( - listOf(SLEEP_MARKS, ASLEEP_DURATION, STAGES, BODY_CLOCK, NIGHT_DETAIL, SLEEP_DEBT, STAGES_VS_TYPICAL, HOURS_VS_NEEDED, CONSISTENCY), + listOf(SLEEP_MARKS, ASLEEP_DURATION, STAGES, BODY_CLOCK, NIGHT_DETAIL, SLEEP_DEBT, STAGES_VS_TYPICAL, HOURS_VS_NEEDED, SLEEP_PLANNER, CONSISTENCY), order.movedSleepSection(ASLEEP_DURATION, STAGES), ) }