diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WeeklyPlan.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WeeklyPlan.swift new file mode 100644 index 0000000000..dc049085a3 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WeeklyPlan.swift @@ -0,0 +1,146 @@ +import Foundation + +// WeeklyPlan.swift - this week's progress toward the user's own weekly habit goals. +// +// The WHOOP app has a server-driven "weekly plan" (behaviour goals with a per-week check-in; observed in the +// shipped app's model shapes, facts only, no WHOOP code). NOOP has no server, so this is the local +// equivalent over the daily metrics NOOP already stores: "sleep at least X on N nights", "reach Effort Y on +// N days", "walk Z steps on N days", counted over the Monday-to-Sunday week that contains today. +// +// Rules (Kotlin twin: com.noop.analytics.WeeklyPlan, held aligned by mirrored fixtures): +// - the week is the ISO week (Monday first) containing `today`, by TZ-free civil-day arithmetic; +// - a day counts once per goal when its value reaches the threshold (>=) and lies inside the plausible +// range of the metric, so an artefact cannot tick a goal; +// - `daysLeft` is the days from today through Sunday that have not hit yet; `onTrack` is +// daysHit + daysLeft >= targetDays; a goal with targetDays 0 is off and is left out. +// +// Pure: no clock, no I/O, no storage. PRESENTATION ONLY: nothing here feeds a score or a stored value. + +/// One kind of weekly goal. The raw value matches the Kotlin `WeeklyPlan.Kind.raw`. +public enum WeeklyPlanKind: String, CaseIterable, Sendable { + case sleep, effort, steps + + /// Inclusive plausibility range of the daily value. + public var range: ClosedRange { + switch self { + case .sleep: return 60...960 // asleep minutes + case .effort: return 0...21 // strain + case .steps: return 1...100_000 + } + } + + /// Default threshold: 7 h asleep, Effort 10, 8,000 steps. + public var defaultThreshold: Double { + switch self { + case .sleep: return 420 + case .effort: return 10 + case .steps: return 8_000 + } + } + + /// Default days per week. + public var defaultTargetDays: Int { + switch self { + case .sleep, .steps: return 5 + case .effort: return 3 + } + } +} + +public enum WeeklyPlan { + + public struct Goal: Equatable, Sendable { + public var kind: WeeklyPlanKind + public var threshold: Double + /// Days per week, 0...7 (0 = goal off). + public var targetDays: Int + + public init(kind: WeeklyPlanKind, threshold: Double, targetDays: Int) { + self.kind = kind + self.threshold = threshold + self.targetDays = targetDays + } + } + + /// One goal per kind at its default threshold and days. + /// Kotlin twin: `WeeklyPlan.defaultGoals`. + public static func defaultGoals() -> [Goal] { + WeeklyPlanKind.allCases.map { Goal(kind: $0, threshold: $0.defaultThreshold, targetDays: $0.defaultTargetDays) } + } + + public struct Day: Equatable, Sendable { + public var day: String + public var sleepMin: Double? + public var strain: Double? + public var steps: Double? + + public init(day: String, sleepMin: Double? = nil, strain: Double? = nil, steps: Double? = nil) { + self.day = day + self.sleepMin = sleepMin + self.strain = strain + self.steps = steps + } + } + + public struct Progress: Equatable, Sendable { + public let goal: Goal + public let daysHit: Int + public let daysLeft: Int + public let onTrack: Bool + public let met: Bool + /// Monday-first weekday index (0...6) of each day that hit, ascending. + public let hitWeekdays: [Int] + } + + public struct Week: Equatable, Sendable { + /// Days since 1970-01-01 of this week's Monday. + public let mondayEpochDay: Int + /// Monday-first weekday index of today (0...6). + public let todayIndex: Int + public let progress: [Progress] + } + + /// Monday-first weekday index (0 = Monday) of an epoch day. 1970-01-01 was a Thursday. + /// Kotlin twin: `WeeklyPlan.weekdayIndex`. + public static func weekdayIndex(_ epochDay: Int) -> Int { + ((epochDay + 3) % 7 + 7) % 7 + } + + /// Kotlin twin: `WeeklyPlan.value`. + static func value(_ d: Day, _ kind: WeeklyPlanKind) -> Double? { + switch kind { + case .sleep: return d.sleepMin + case .effort: return d.strain + case .steps: return d.steps + } + } + + /// This week's progress per active goal, in input order. nil when `today` does not parse. + /// Kotlin twin: `WeeklyPlan.week`. + public static func week(_ days: [Day], today: String, goals: [Goal]) -> Week? { + guard let t = Baselines.isoEpochDay(today) else { return nil } + let idx = weekdayIndex(t) + let monday = t - idx + var inWeek: [Int: [Day]] = [:] + for d in days { + guard let e = Baselines.isoEpochDay(d.day), e >= monday, e <= t else { continue } + inWeek[e - monday, default: []].append(d) + } + let progress = goals.compactMap { raw -> Progress? in + let goal = Goal(kind: raw.kind, threshold: raw.threshold, targetDays: min(7, max(0, raw.targetDays))) + guard goal.targetDays > 0 else { return nil } + let hits = (0...idx).filter { i in + (inWeek[i] ?? []).contains { d in + guard let v = value(d, goal.kind), !v.isNaN, goal.kind.range.contains(v) else { return false } + return v >= goal.threshold + } + } + let todayHit = hits.contains(idx) + let left = (6 - idx) + (todayHit ? 0 : 1) + return Progress(goal: goal, daysHit: hits.count, daysLeft: left, + onTrack: hits.count + left >= goal.targetDays, + met: hits.count >= goal.targetDays, hitWeekdays: hits) + } + return Week(mondayEpochDay: monday, todayIndex: idx, progress: progress) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WeeklyPlanTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WeeklyPlanTests.swift new file mode 100644 index 0000000000..ed4c2f378b --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WeeklyPlanTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import StrandAnalytics + +/// Mirrored by android WeeklyPlanTest.kt - same fixtures, same expected outputs. +final class WeeklyPlanTests: XCTestCase { + + // 2026-10-21 is a Wednesday, so its week runs Mon 2026-10-19 ... Sun 2026-10-25. + private let today = "2026-10-21" + + func testWeekdayIndexIsMondayFirst() { + XCTAssertEqual(WeeklyPlan.weekdayIndex(Baselines.isoEpochDay("2026-10-19")!), 0) + XCTAssertEqual(WeeklyPlan.weekdayIndex(Baselines.isoEpochDay("2026-10-25")!), 6) + XCTAssertEqual(WeeklyPlan.weekdayIndex(0), 3) // 1970-01-01, a Thursday + } + + func testWeekCountsOnlyThisWeekAndPlausibleValues() { + let days = [ + WeeklyPlan.Day(day: "2026-10-18", sleepMin: 500, strain: 15, steps: 12_000), // last Sunday + WeeklyPlan.Day(day: "2026-10-19", sleepMin: 430, strain: 9.9, steps: 8_000), + WeeklyPlan.Day(day: "2026-10-20", sleepMin: 30, strain: 25, steps: 200_000), // implausible + WeeklyPlan.Day(day: "2026-10-21", sleepMin: 419, strain: 10, steps: 9_000), + ] + let w = WeeklyPlan.week(days, today: today, goals: WeeklyPlan.defaultGoals())! + XCTAssertEqual(w.todayIndex, 2) + XCTAssertEqual(w.mondayEpochDay, Baselines.isoEpochDay("2026-10-19")) + let sleep = w.progress[0], effort = w.progress[1], steps = w.progress[2] + XCTAssertEqual(sleep.hitWeekdays, [0]) + XCTAssertEqual(sleep.daysLeft, 5) // today missed + Thu..Sun + XCTAssertTrue(sleep.onTrack) // 1 + 5 >= 5 + XCTAssertEqual(effort.hitWeekdays, [2]) + XCTAssertEqual(effort.daysLeft, 4) // today already hit + XCTAssertEqual(steps.hitWeekdays, [0, 2]) + XCTAssertFalse(steps.met) + } + + func testOffGoalsAreDroppedAndOffTrackIsReported() { + let goals = [WeeklyPlan.Goal(kind: .sleep, threshold: 420, targetDays: 7), + WeeklyPlan.Goal(kind: .steps, threshold: 8_000, targetDays: 0)] + let w = WeeklyPlan.week([], today: today, goals: goals)! + XCTAssertEqual(w.progress.count, 1) + XCTAssertFalse(w.progress[0].onTrack) // 0 + 5 < 7 + XCTAssertNil(WeeklyPlan.week([], today: "bad", goals: goals)) + } +} diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7f227851a7..8b8f828bc6 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,30 @@ { "sourceLanguage": "en", "strings": { + "Weekly plan": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Wochenplan"}}, "es": {"stringUnit": {"state": "translated", "value": "Plan semanal"}}, "fr": {"stringUnit": {"state": "translated", "value": "Plan hebdomadaire"}}, "it": {"stringUnit": {"state": "translated", "value": "Piano settimanale"}}, "pl": {"stringUnit": {"state": "translated", "value": "Plan tygodniowy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Plano semanal"}}, "ru": {"stringUnit": {"state": "translated", "value": "План на неделю"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每周计划"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每週計劃"}} + } }, + "Your own goals, counted Monday to Sunday from this device's data.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Deine eigenen Ziele, von Montag bis Sonntag aus den Daten dieses Geräts gezählt."}}, "es": {"stringUnit": {"state": "translated", "value": "Tus propios objetivos, contados de lunes a domingo con los datos de este dispositivo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Vos propres objectifs, comptés du lundi au dimanche à partir des données de cet appareil."}}, "it": {"stringUnit": {"state": "translated", "value": "I tuoi obiettivi, contati da lunedì a domenica con i dati di questo dispositivo."}}, "pl": {"stringUnit": {"state": "translated", "value": "Twoje własne cele, liczone od poniedziałku do niedzieli z danych tego urządzenia."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Os seus próprios objetivos, contados de segunda a domingo com os dados deste dispositivo."}}, "ru": {"stringUnit": {"state": "translated", "value": "Ваши собственные цели, считаются с понедельника по воскресенье по данным этого устройства."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "你自己的目标,按本设备的数据从周一到周日计算。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "你自己的目標,按本裝置的資料從週一到週日計算。"}} + } }, + "Sleep %lldh %lldm+": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Schlaf %lld h %lld min+"}}, "es": {"stringUnit": {"state": "translated", "value": "Sueño %lld h %lld min+"}}, "fr": {"stringUnit": {"state": "translated", "value": "Sommeil %lld h %lld min+"}}, "it": {"stringUnit": {"state": "translated", "value": "Sonno %lld h %lld min+"}}, "pl": {"stringUnit": {"state": "translated", "value": "Sen %lld h %lld min+"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sono %lld h %lld min+"}}, "ru": {"stringUnit": {"state": "translated", "value": "Сон %lld ч %lld мин+"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "睡眠 %lld 小时 %lld 分钟+"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "睡眠 %lld 小時 %lld 分鐘+"}} + } }, + "Effort %lld+": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Belastung %lld+"}}, "es": {"stringUnit": {"state": "translated", "value": "Esfuerzo %lld+"}}, "fr": {"stringUnit": {"state": "translated", "value": "Effort %lld+"}}, "it": {"stringUnit": {"state": "translated", "value": "Sforzo %lld+"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wysiłek %lld+"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esforço %lld+"}}, "ru": {"stringUnit": {"state": "translated", "value": "Нагрузка %lld+"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "强度 %lld+"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "強度 %lld+"}} + } }, + "%@+ steps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%@+ Schritte"}}, "es": {"stringUnit": {"state": "translated", "value": "%@+ pasos"}}, "fr": {"stringUnit": {"state": "translated", "value": "%@+ pas"}}, "it": {"stringUnit": {"state": "translated", "value": "%@+ passi"}}, "pl": {"stringUnit": {"state": "translated", "value": "%@+ kroków"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%@+ passos"}}, "ru": {"stringUnit": {"state": "translated", "value": "%@+ шагов"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%@+ 步"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%@+ 步"}} + } }, + "Done · %lld of %lld days": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Erreicht · %lld von %lld Tagen"}}, "es": {"stringUnit": {"state": "translated", "value": "Hecho · %lld de %lld días"}}, "fr": {"stringUnit": {"state": "translated", "value": "Atteint · %lld sur %lld jours"}}, "it": {"stringUnit": {"state": "translated", "value": "Fatto · %lld di %lld giorni"}}, "pl": {"stringUnit": {"state": "translated", "value": "Gotowe · %lld z %lld dni"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Concluído · %lld de %lld dias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Выполнено · %lld из %lld дней"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已完成 · %lld/%lld 天"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已完成 · %lld/%lld 天"}} + } }, + "On track · %lld of %lld days": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Im Plan · %lld von %lld Tagen"}}, "es": {"stringUnit": {"state": "translated", "value": "En camino · %lld de %lld días"}}, "fr": {"stringUnit": {"state": "translated", "value": "En bonne voie · %lld sur %lld jours"}}, "it": {"stringUnit": {"state": "translated", "value": "In linea · %lld di %lld giorni"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zgodnie z planem · %lld z %lld dni"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "No caminho certo · %lld de %lld dias"}}, "ru": {"stringUnit": {"state": "translated", "value": "По плану · %lld из %lld дней"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "进度正常 · %lld/%lld 天"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "進度正常 · %lld/%lld 天"}} + } }, + "Out of reach this week · %lld of %lld days": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Diese Woche nicht mehr erreichbar · %lld von %lld Tagen"}}, "es": {"stringUnit": {"state": "translated", "value": "Fuera de alcance esta semana · %lld de %lld días"}}, "fr": {"stringUnit": {"state": "translated", "value": "Hors de portée cette semaine · %lld sur %lld jours"}}, "it": {"stringUnit": {"state": "translated", "value": "Non raggiungibile questa settimana · %lld di %lld giorni"}}, "pl": {"stringUnit": {"state": "translated", "value": "Poza zasięgiem w tym tygodniu · %lld z %lld dni"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Fora de alcance esta semana · %lld de %lld dias"}}, "ru": {"stringUnit": {"state": "translated", "value": "На этой неделе уже не достичь · %lld из %lld дней"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "本周已无法达成 · %lld/%lld 天"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "本週已無法達成 · %lld/%lld 天"}} + } }, "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/SettingsView.swift b/Strand/Screens/SettingsView.swift index 48e3a5e8df..4584ac7405 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -269,6 +269,7 @@ struct SettingsView: View { liveNotificationsCard.staggeredAppear(index: 3) #endif streakCard.staggeredAppear(index: 4) + WeeklyPlanCard(days: model.repo.days).staggeredAppear(index: 4) featuresCard.staggeredAppear(index: 5) #if os(iOS) syncCard.staggeredAppear(index: 6) diff --git a/Strand/Screens/WeeklyPlanCard.swift b/Strand/Screens/WeeklyPlanCard.swift new file mode 100644 index 0000000000..6fcd167f13 --- /dev/null +++ b/Strand/Screens/WeeklyPlanCard.swift @@ -0,0 +1,117 @@ +import SwiftUI +import StrandDesign +import StrandAnalytics +import WhoopStore + +// MARK: - Weekly plan card +// +// This week's progress toward the user's own sleep / Effort / steps goals. The counting rules live in the +// pure `WeeklyPlan` (StrandAnalytics; Kotlin twin com.noop.analytics.WeeklyPlan); this file only stores the +// user's goal day-counts, formats and renders. Mirrors the Android `WeeklyPlanCard.kt`. + +/// Days per week for each goal. Same keys as the Android `WeeklyPlanPrefs`; not in .noopbak. +enum WeeklyPlanPrefs { + static func key(_ kind: WeeklyPlanKind) -> String { "weeklyPlan.\(kind.rawValue)Days" } + + static func goals(_ defaults: UserDefaults = .standard) -> [WeeklyPlan.Goal] { + WeeklyPlan.defaultGoals().map { g in + var g = g + if defaults.object(forKey: key(g.kind)) != nil { + g.targetDays = min(7, max(0, defaults.integer(forKey: key(g.kind)))) + } + return g + } + } +} + +struct WeeklyPlanCard: View { + let days: [DailyMetric] + @State private var goals = WeeklyPlanPrefs.goals() + + var body: some View { + let today = AnalyticsEngine.dayString(Int(Date().timeIntervalSince1970), + offsetSec: TimeZone.current.secondsFromGMT()) + let week = WeeklyPlan.week(days.map { + WeeklyPlan.Day(day: $0.day, sleepMin: $0.totalSleepMin, strain: $0.strain, steps: $0.steps.map(Double.init)) + }, today: today, goals: goals) + + NoopCard(tint: StrandPalette.effortColor) { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("This week").strandOverline() + Text("Weekly plan").font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) + ForEach(goals.indices, id: \.self) { i in + goalRow(i, week?.progress.first { $0.goal.kind == goals[i].kind }, todayIndex: week?.todayIndex ?? 0) + } + Text("Your own goals, counted Monday to Sunday from this device's data.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + + private func goalRow(_ i: Int, _ p: WeeklyPlan.Progress?, todayIndex: Int) -> some View { + let goal = goals[i] + let binding = Binding( + get: { goals[i].targetDays }, + set: { v in + goals[i].targetDays = min(7, max(0, v)) + UserDefaults.standard.set(goals[i].targetDays, forKey: WeeklyPlanPrefs.key(goal.kind)) + }) + return VStack(alignment: .leading, spacing: NoopMetrics.space1) { + Stepper(value: binding, in: 0...7) { + VStack(alignment: .leading, spacing: NoopMetrics.spaceHalf) { + Text(label(goal)).font(StrandFont.subhead).foregroundStyle(accent(goal.kind)) + Text(status(goal, p)).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + } + } + if goal.targetDays > 0 { + HStack(spacing: NoopMetrics.space2) { + ForEach(0..<7, id: \.self) { d in + let hit = p?.hitWeekdays.contains(d) == true + VStack(spacing: NoopMetrics.spaceHalf) { + Circle() + .fill(hit ? accent(goal.kind) + : StrandPalette.textTertiary.opacity(d <= todayIndex ? 0.35 : 0.12)) + .frame(width: NoopMetrics.space2, height: NoopMetrics.space2) + Text(verbatim: weekdayLetter(d)) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + .accessibilityHidden(true) + } + } + .padding(.horizontal, NoopMetrics.space3) + .padding(.vertical, NoopMetrics.space2) + .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: NoopMetrics.cardRadius)) + } + + private func label(_ g: WeeklyPlan.Goal) -> String { + switch g.kind { + case .sleep: + let m = Int(g.threshold.rounded()) + return String(localized: "Sleep \(m / 60)h \(m % 60)m+") + case .effort: return String(localized: "Effort \(Int(g.threshold))+") + case .steps: return String(localized: "\(Int(g.threshold).formatted())+ steps") + } + } + + private func status(_ g: WeeklyPlan.Goal, _ p: WeeklyPlan.Progress?) -> String { + guard g.targetDays > 0 else { return String(localized: "Off") } + guard let p else { return "" } + if p.met { return String(localized: "Done · \(p.daysHit) of \(g.targetDays) days") } + if p.onTrack { return String(localized: "On track · \(p.daysHit) of \(g.targetDays) days") } + return String(localized: "Out of reach this week · \(p.daysHit) of \(g.targetDays) days") + } + + private func accent(_ k: WeeklyPlanKind) -> Color { + k == .sleep ? StrandPalette.restColor : StrandPalette.effortColor + } + + /// Narrow weekday symbol, Monday first. + private func weekdayLetter(_ mondayIndex: Int) -> String { + let symbols = Calendar.current.veryShortStandaloneWeekdaySymbols // Sunday first + return symbols[(mondayIndex + 1) % 7] + } +} diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 767e162915..54d275faa4 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,13 +18,13 @@ ] }, "authority": { - "files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"}, - "functions": {"count": 4537, "sha256": "a10084c830d9b91c0f02bb308fa7ac0f1466c7537de3303669e0f4fbeffdf6f2"}, - "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, + "files": {"count": 511, "sha256": "54927a49a1e017d17e85d8cfcf51671b74584066204133240187469bf4438de0"}, + "functions": {"count": 4545, "sha256": "2d816c2f59762eb71e69e7eee1710ff0fde7f8270c829e301ce387fb608319c8"}, + "properties": {"count": 494, "sha256": "0ec1cfc6982ebdaa11c0b95e84e85f96f3cec0b4a6fa0f3333d13e86fa577238"}, "constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"}, - "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, - "function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"}, - "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, + "file_pairs": {"count": 82, "sha256": "513e529aa52866b93439bf91df7d8ca7b1927fb9e0d2c4bcc5d689d039cd6b64"}, + "function_pairs": {"count": 230, "sha256": "4f18b276d346ef7d5d0ee1165cadce6bb5d441fd8e637d438a0184607296fe25"}, + "property_pairs": {"count": 166, "sha256": "2df1c060fdc42fec8d7aaaaa5d3e66f1da1b3da7938ed0de1e452217425ae9ec"}, "constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"}, "unpaired_files": {"count": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"}, "unpaired_functions": {"count": 4098, "sha256": "a7c3215172bf4d1cd33b8df390684d4d65fc1109f8ee52b3d344b04d231f08fd"}, diff --git a/android/app/src/main/java/com/noop/analytics/WeeklyPlan.kt b/android/app/src/main/java/com/noop/analytics/WeeklyPlan.kt new file mode 100644 index 0000000000..1c5bbd4a6b --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/WeeklyPlan.kt @@ -0,0 +1,106 @@ +package com.noop.analytics + +/** One kind of weekly goal. [raw] matches the Swift `WeeklyPlanKind` raw value. */ +enum class WeeklyPlanKind(val raw: String) { + SLEEP("sleep"), + EFFORT("effort"), + STEPS("steps"), + ; + + /** Inclusive plausibility range of the daily value (asleep minutes, strain, steps). */ + val range: ClosedFloatingPointRange get() = when (this) { + SLEEP -> 60.0..960.0 + EFFORT -> 0.0..21.0 + STEPS -> 1.0..100_000.0 + } + + /** Default threshold: 7 h asleep, Effort 10, 8,000 steps. */ + val defaultThreshold: Double get() = when (this) { + SLEEP -> 420.0 + EFFORT -> 10.0 + STEPS -> 8_000.0 + } + + /** Default days per week. */ + val defaultTargetDays: Int get() = when (this) { + SLEEP, STEPS -> 5 + EFFORT -> 3 + } +} + +/** + * WeeklyPlan - this week's progress toward the user's own weekly habit goals. + * + * The WHOOP app has a server-driven "weekly plan" (behaviour goals with a per-week check-in; observed in + * the shipped app's model shapes, facts only, no WHOOP code). NOOP has no server, so this is the local + * equivalent over the daily metrics NOOP already stores: "sleep at least X on N nights", "reach Effort Y on + * N days", "walk Z steps on N days", counted over the Monday-to-Sunday week that contains today. + * + * Rules (Swift twin: StrandAnalytics/WeeklyPlan.swift, held aligned by mirrored fixtures): + * - the week is the ISO week (Monday first) containing `today`, by TZ-free civil-day arithmetic; + * - a day counts once per goal when its value reaches the threshold (>=) and lies inside the plausible + * range of the metric, so an artefact cannot tick a goal; + * - `daysLeft` is the days from today through Sunday that have not hit yet; `onTrack` is + * daysHit + daysLeft >= targetDays; a goal with targetDays 0 is off and is left out. + * + * Pure: no clock, no I/O, no storage. PRESENTATION ONLY: nothing here feeds a score or a stored value. + */ +object WeeklyPlan { + + /** [targetDays] is 0..7 (0 = goal off). */ + data class Goal(val kind: WeeklyPlanKind, val threshold: Double, val targetDays: Int) + + /** Swift twin: `WeeklyPlan.defaultGoals`. */ + fun defaultGoals(): List = WeeklyPlanKind.entries.map { Goal(it, it.defaultThreshold, it.defaultTargetDays) } + + data class Day(val day: String, val sleepMin: Double? = null, val strain: Double? = null, val steps: Double? = null) + + /** [hitWeekdays] are Monday-first weekday indexes (0..6) of each day that hit, ascending. */ + data class Progress( + val goal: Goal, + val daysHit: Int, + val daysLeft: Int, + val onTrack: Boolean, + val met: Boolean, + val hitWeekdays: List, + ) + + /** [mondayEpochDay]: days since 1970-01-01 of this week's Monday. [todayIndex]: Monday-first, 0..6. */ + data class Week(val mondayEpochDay: Int, val todayIndex: Int, val progress: List) + + /** Monday-first weekday index (0 = Monday) of an epoch day. 1970-01-01 was a Thursday. Swift twin: `WeeklyPlan.weekdayIndex`. */ + fun weekdayIndex(epochDay: Int): Int = Math.floorMod(epochDay + 3, 7) + + /** Swift twin: `WeeklyPlan.value`. */ + private fun value(d: Day, kind: WeeklyPlanKind): Double? = when (kind) { + WeeklyPlanKind.SLEEP -> d.sleepMin + WeeklyPlanKind.EFFORT -> d.strain + WeeklyPlanKind.STEPS -> d.steps + } + + /** This week's progress per active goal, in input order. null when [today] does not parse. Swift twin: `WeeklyPlan.week`. */ + fun week(days: List, today: String, goals: List): Week? { + val t = Baselines.isoEpochDay(today) ?: return null + val idx = weekdayIndex(t) + val monday = t - idx + val inWeek = HashMap>() + for (d in days) { + val e = Baselines.isoEpochDay(d.day) ?: continue + if (e < monday || e > t) continue + inWeek.getOrPut(e - monday) { mutableListOf() }.add(d) + } + val progress = goals.mapNotNull { raw -> + val goal = raw.copy(targetDays = raw.targetDays.coerceIn(0, 7)) + if (goal.targetDays <= 0) return@mapNotNull null + val hits = (0..idx).filter { i -> + inWeek[i].orEmpty().any { d -> + val v = value(d, goal.kind) + v != null && !v.isNaN() && v in goal.kind.range && v >= goal.threshold + } + } + val left = (6 - idx) + (if (idx in hits) 0 else 1) + Progress(goal, hits.size, left, hits.size + left >= goal.targetDays, hits.size >= goal.targetDays, hits) + } + return Week(monday, idx, progress) + } +} diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index c76e2c8ed9..72787ddc75 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -237,6 +237,18 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { com.noop.analytics.StreakCalculator.Streaks(0, 0), ) + /** The weekly-plan inputs; the goals live in [WeeklyPlanPrefs] and are applied by the card. */ + val weeklyPlanDays: StateFlow> = + repository.daysMergedFlow(noopApp.activeDeviceId) + .map { days -> + days.map { + com.noop.analytics.WeeklyPlan.Day( + day = it.day, sleepMin = it.totalSleepMin, strain = it.strain, steps = it.steps?.toDouble(), + ) + } + } + .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000L), emptyList()) + /** #1410: record an `APP_VERSION_CHANGED` event on the first launch after an update. `"noop-app"` is a * synthetic, non-strap deviceId sentinel (strap ids are BLE UUIDs, so it can't collide). */ private suspend fun recordAppVersionChange() { diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 5329b464e7..245cf065c9 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -954,6 +954,8 @@ fun SettingsScreen( ) } } + val weeklyPlanDays by vm.weeklyPlanDays.collectAsStateWithLifecycle() + WeeklyPlanCard(weeklyPlanDays) SettingsCard( icon = Icons.Outlined.AccountCircle, diff --git a/android/app/src/main/java/com/noop/ui/WeeklyPlanCard.kt b/android/app/src/main/java/com/noop/ui/WeeklyPlanCard.kt new file mode 100644 index 0000000000..dba3eb3491 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/WeeklyPlanCard.kt @@ -0,0 +1,154 @@ +package com.noop.ui + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.noop.R +import com.noop.analytics.AnalyticsEngine +import com.noop.analytics.WeeklyPlan +import com.noop.analytics.WeeklyPlanKind +import java.time.DayOfWeek +import java.time.format.TextStyle +import java.util.Locale + +// MARK: - Weekly plan card +// +// This week's progress toward the user's own sleep / Effort / steps goals. The counting rules live in the +// pure analytics WeeklyPlan (Swift twin in StrandAnalytics); this file only stores the user's goal +// day-counts, formats and renders. Display-only: nothing here feeds a score or a stored metric. + +/** The user's days-per-week for each goal, in SharedPreferences "noop_weekly_plan" (key = Kind.raw). + * Same keys as the Apple `WeeklyPlanPrefs` (UserDefaults "weeklyPlan.Days"); not in .noopbak. */ +internal object WeeklyPlanPrefs { + private const val FILE = "noop_weekly_plan" + private fun key(kind: WeeklyPlanKind) = "weeklyPlan.${kind.raw}Days" + + fun goals(context: Context): List { + val p = context.getSharedPreferences(FILE, Context.MODE_PRIVATE) + return WeeklyPlan.defaultGoals().map { it.copy(targetDays = p.getInt(key(it.kind), it.targetDays).coerceIn(0, 7)) } + } + + fun setDays(context: Context, kind: WeeklyPlanKind, days: Int) { + context.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit().putInt(key(kind), days.coerceIn(0, 7)).apply() + } +} + +@Composable +private fun WeeklyPlanKind.goalLabel(threshold: Double): String = when (this) { + WeeklyPlanKind.SLEEP -> uiString(R.string.l10n_weekly_plan_goal_sleep, durationText(threshold)) + WeeklyPlanKind.EFFORT -> uiString(R.string.l10n_weekly_plan_goal_effort, String.format(Locale.getDefault(), "%.0f", threshold)) + WeeklyPlanKind.STEPS -> uiString(R.string.l10n_weekly_plan_goal_steps, String.format(Locale.getDefault(), "%,.0f", threshold)) +} + +@Composable +private fun WeeklyPlanKind.accent(): Color = when (this) { + WeeklyPlanKind.SLEEP -> Palette.restColor + WeeklyPlanKind.EFFORT, WeeklyPlanKind.STEPS -> Palette.effortColor +} + +@Composable +internal fun WeeklyPlanCard(days: List) { + val context = LocalContext.current + var goals by remember { mutableStateOf(WeeklyPlanPrefs.goals(context)) } + val nowSec = System.currentTimeMillis() / 1000L + val tz = java.util.TimeZone.getDefault().getOffset(nowSec * 1000L) / 1000L + val week = WeeklyPlan.week(days, AnalyticsEngine.dayString(nowSec, tz), goals) + NoopCard(padding = Metrics.cardPadding, tint = Palette.effortColor) { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space8)) { + Column { + Overline(uiString(R.string.l10n_weekly_plan_overline)) + Text(uiString(R.string.l10n_weekly_plan_title), style = NoopType.headline, color = Palette.textPrimary) + } + goals.forEach { goal -> + val p = week?.progress?.firstOrNull { it.goal.kind == goal.kind } + WeeklyGoalRow(goal, p, week?.todayIndex ?: 0) { newDays -> + WeeklyPlanPrefs.setDays(context, goal.kind, newDays) + goals = WeeklyPlanPrefs.goals(context) + } + } + Text(uiString(R.string.l10n_weekly_plan_footnote), style = NoopType.footnote, color = Palette.textTertiary) + } + } +} + +@Composable +private fun WeeklyGoalRow(goal: WeeklyPlan.Goal, p: WeeklyPlan.Progress?, todayIndex: Int, onDays: (Int) -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Metrics.space14)) + .background(Palette.surfaceInset) + .padding(horizontal = Metrics.space14, vertical = Metrics.space8), + verticalArrangement = Arrangement.spacedBy(Metrics.space4), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(goal.kind.goalLabel(goal.threshold), style = NoopType.subhead, color = goal.kind.accent()) + Text( + when { + goal.targetDays == 0 -> uiString(R.string.l10n_weekly_plan_status_off) + p == null -> "" + p.met -> uiString(R.string.l10n_weekly_plan_status_met, p.daysHit, goal.targetDays) + p.onTrack -> uiString(R.string.l10n_weekly_plan_status_on_track, p.daysHit, goal.targetDays) + else -> uiString(R.string.l10n_weekly_plan_status_behind, p.daysHit, goal.targetDays) + }, + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } + IconButton(onClick = { onDays(goal.targetDays - 1) }, enabled = goal.targetDays > 0) { + Icon(Icons.Outlined.Remove, contentDescription = uiString(R.string.l10n_weekly_plan_fewer_days)) + } + Text(goal.targetDays.toString(), style = NoopType.captionNumber, color = Palette.textPrimary) + IconButton(onClick = { onDays(goal.targetDays + 1) }, enabled = goal.targetDays < 7) { + Icon(Icons.Outlined.Add, contentDescription = uiString(R.string.l10n_weekly_plan_more_days)) + } + } + if (goal.targetDays > 0) { + Row(horizontalArrangement = Arrangement.spacedBy(Metrics.space8)) { + DayOfWeek.entries.forEachIndexed { i, dow -> + val hit = p?.hitWeekdays?.contains(i) == true + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier + .size(Metrics.legendSwatch) + .clip(CircleShape) + .background( + when { + hit -> goal.kind.accent() + i <= todayIndex -> Palette.textTertiary.copy(alpha = 0.35f) + else -> Palette.textTertiary.copy(alpha = 0.12f) + }, + ), + ) + Text(dow.getDisplayName(TextStyle.NARROW, Locale.getDefault()), style = NoopType.footnote, color = Palette.textTertiary) + } + } + } + } + } +} diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 8399bc377c..37372aa3ee 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2873,4 +2873,16 @@ 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 + Wochenplan + Diese Woche + Schlaf %1$s+ + Belastung %1$s+ + %1$s+ Schritte + Erreicht · %1$d von %2$d Tagen + Im Plan · %1$d von %2$d Tagen + Diese Woche nicht mehr erreichbar · %1$d von %2$d Tagen + Aus + Weniger Tage + Mehr Tage + Deine eigenen Ziele, von Montag bis Sonntag aus den Daten dieses Geräts gezählt. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 20aed28131..375d7cca3d 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2860,4 +2860,16 @@ 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 + Plan semanal + Esta semana + Sueño %1$s+ + Esfuerzo %1$s+ + %1$s+ pasos + Hecho · %1$d de %2$d días + En camino · %1$d de %2$d días + Fuera de alcance esta semana · %1$d de %2$d días + Desactivado + Menos días + Más días + Tus propios objetivos, contados de lunes a domingo con los datos de este dispositivo. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index bd1c4cc3b1..8b9ab577b2 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2859,4 +2859,16 @@ 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 + Plan hebdomadaire + Cette semaine + Sommeil %1$s+ + Effort %1$s+ + %1$s+ pas + Atteint · %1$d sur %2$d jours + En bonne voie · %1$d sur %2$d jours + Hors de portée cette semaine · %1$d sur %2$d jours + Désactivé + Moins de jours + Plus de jours + Vos propres objectifs, comptés du lundi au dimanche à partir des données de cet appareil. diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 1a47561f99..6022b0cad1 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2828,4 +2828,16 @@ 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 + Piano settimanale + Questa settimana + Sonno %1$s+ + Sforzo %1$s+ + %1$s+ passi + Fatto · %1$d di %2$d giorni + In linea · %1$d di %2$d giorni + Non raggiungibile questa settimana · %1$d di %2$d giorni + Disattivato + Meno giorni + Più giorni + I tuoi obiettivi, contati da lunedì a domenica con i dati di questo dispositivo. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index b5e9c52de8..5459e8925f 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2876,4 +2876,16 @@ 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 + Plan tygodniowy + Ten tydzień + Sen %1$s+ + Wysiłek %1$s+ + %1$s+ kroków + Gotowe · %1$d z %2$d dni + Zgodnie z planem · %1$d z %2$d dni + Poza zasięgiem w tym tygodniu · %1$d z %2$d dni + Wyłączone + Mniej dni + Więcej dni + Twoje własne cele, liczone od poniedziałku do niedzieli z danych tego urządzenia. 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..0edfb3e009 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,16 @@ 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 + Plano semanal + Esta semana + Sono %1$s+ + Esforço %1$s+ + %1$s+ passos + Concluído · %1$d de %2$d dias + No caminho certo · %1$d de %2$d dias + Fora de alcance esta semana · %1$d de %2$d dias + Desligado + Menos dias + Mais dias + Os seus próprios objetivos, contados de segunda a domingo com os dados deste dispositivo. diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 6fbec7268f..c5d2829c8e 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2755,4 +2755,16 @@ На этом ремешке запись может всё ещё идти. Результат попадёт в strap log. Появляется сегодня, если сессии в реальном времени включены в Настройках или сеанс уже активен. «Сегодня» на ваш вкус, пульс для любого приложения и итальянский язык + План на неделю + Эта неделя + Сон %1$s+ + Нагрузка %1$s+ + %1$s+ шагов + Выполнено · %1$d из %2$d дней + По плану · %1$d из %2$d дней + На этой неделе уже не достичь · %1$d из %2$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..8eae08223d 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2830,4 +2830,16 @@ 此腕带上可能仍在采集。结论会写入 strap log。 在设置中启用Live Sessions 实时记录或有正在进行的训练时,今天会显示此部分。 自定义「今天」页面、任何应用都能读取的心率,以及意大利语 + 每周计划 + 本周 + 睡眠 %1$s+ + 强度 %1$s+ + %1$s+ 步 + 已完成 · %2$d 天中 %1$d 天 + 进度正常 · %2$d 天中 %1$d 天 + 本周已无法达成 · %2$d 天中 %1$d 天 + 关闭 + 减少天数 + 增加天数 + 你自己的目标,按本设备的数据从周一到周日计算。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5ae979343a..e781786352 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2889,4 +2889,16 @@ 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 + Weekly plan + This week + Sleep %1$s+ + Effort %1$s+ + %1$s+ steps + Done · %1$d of %2$d days + On track · %1$d of %2$d days + Out of reach this week · %1$d of %2$d days + Off + Fewer days + More days + Your own goals, counted Monday to Sunday from this device\'s data. diff --git a/android/app/src/test/java/com/noop/analytics/WeeklyPlanTest.kt b/android/app/src/test/java/com/noop/analytics/WeeklyPlanTest.kt new file mode 100644 index 0000000000..9954d27df0 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/WeeklyPlanTest.kt @@ -0,0 +1,52 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Mirrors StrandAnalyticsTests/WeeklyPlanTests.swift - same fixtures, same expected outputs. */ +class WeeklyPlanTest { + + // 2026-10-21 is a Wednesday, so its week runs Mon 2026-10-19 ... Sun 2026-10-25. + private val today = "2026-10-21" + + + @Test fun weekdayIndexIsMondayFirst() { + assertEquals(0, WeeklyPlan.weekdayIndex(Baselines.isoEpochDay("2026-10-19")!!)) + assertEquals(6, WeeklyPlan.weekdayIndex(Baselines.isoEpochDay("2026-10-25")!!)) + assertEquals(3, WeeklyPlan.weekdayIndex(0)) // 1970-01-01, a Thursday + } + + @Test fun weekCountsOnlyThisWeekAndPlausibleValues() { + val days = listOf( + WeeklyPlan.Day("2026-10-18", sleepMin = 500.0, strain = 15.0, steps = 12_000.0), // last Sunday + WeeklyPlan.Day("2026-10-19", sleepMin = 430.0, strain = 9.9, steps = 8_000.0), + WeeklyPlan.Day("2026-10-20", sleepMin = 30.0, strain = 25.0, steps = 200_000.0), // implausible + WeeklyPlan.Day("2026-10-21", sleepMin = 419.0, strain = 10.0, steps = 9_000.0), + ) + val w = WeeklyPlan.week(days, today, WeeklyPlan.defaultGoals())!! + assertEquals(2, w.todayIndex) + assertEquals(Baselines.isoEpochDay("2026-10-19"), w.mondayEpochDay) + val (sleep, effort, steps) = w.progress + assertEquals(listOf(0), sleep.hitWeekdays) + assertEquals(5, sleep.daysLeft) + assertTrue(sleep.onTrack) + assertEquals(listOf(2), effort.hitWeekdays) + assertEquals(4, effort.daysLeft) + assertEquals(listOf(0, 2), steps.hitWeekdays) + assertFalse(steps.met) + } + + @Test fun offGoalsAreDroppedAndOffTrackIsReported() { + val goals = listOf( + WeeklyPlan.Goal(WeeklyPlanKind.SLEEP, 420.0, 7), + WeeklyPlan.Goal(WeeklyPlanKind.STEPS, 8_000.0, 0), + ) + val w = WeeklyPlan.week(emptyList(), today, goals)!! + assertEquals(1, w.progress.size) + assertFalse(w.progress[0].onTrack) + assertNull(WeeklyPlan.week(emptyList(), "bad", goals)) + } +}