Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/WeeklyPlan.swift
Original file line number Diff line number Diff line change
@@ -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<Double> {
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)
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
24 changes: 24 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -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": "匯出運動路線"}}}},
Expand Down
1 change: 1 addition & 0 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading