From c18297431f61a3903e52e78197dc3b9c7c434034 Mon Sep 17 00:00:00 2001 From: LeonardSEO <130681849+LeonardSEO@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:30:54 +0200 Subject: [PATCH] Add a milestones card: streak and threshold milestones from the user's own history Pure math in StrandAnalytics Achievements with a Kotlin twin and mirrored fixtures. Settings card on Android and Apple. Display-only. --- .../StrandAnalytics/Achievements.swift | 96 +++++++++++++++++++ .../AchievementsTests.swift | 45 +++++++++ Strand/Resources/Localizable.xcstrings | 33 +++++++ Strand/Screens/AchievementsCard.swift | 76 +++++++++++++++ Strand/Screens/SettingsView.swift | 1 + Tools/parity_twin_map.json | 12 +-- .../java/com/noop/analytics/Achievements.kt | 80 ++++++++++++++++ .../main/java/com/noop/ui/AchievementsCard.kt | 84 ++++++++++++++++ .../src/main/java/com/noop/ui/AppViewModel.kt | 19 ++++ .../main/java/com/noop/ui/SettingsScreen.kt | 2 + .../app/src/main/res/values-de/strings.xml | 13 +++ .../app/src/main/res/values-es/strings.xml | 13 +++ .../app/src/main/res/values-fr/strings.xml | 13 +++ .../app/src/main/res/values-it/strings.xml | 13 +++ .../app/src/main/res/values-pl/strings.xml | 13 +++ .../src/main/res/values-pt-rPT/strings.xml | 13 +++ .../app/src/main/res/values-ru/strings.xml | 13 +++ .../app/src/main/res/values-zh/strings.xml | 13 +++ android/app/src/main/res/values/strings.xml | 13 +++ .../com/noop/analytics/AchievementsTest.kt | 40 ++++++++ 20 files changed, 599 insertions(+), 6 deletions(-) create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/Achievements.swift create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AchievementsTests.swift create mode 100644 Strand/Screens/AchievementsCard.swift create mode 100644 android/app/src/main/java/com/noop/analytics/Achievements.kt create mode 100644 android/app/src/main/java/com/noop/ui/AchievementsCard.kt create mode 100644 android/app/src/test/java/com/noop/analytics/AchievementsTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/Achievements.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/Achievements.swift new file mode 100644 index 0000000000..e2d2399928 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/Achievements.swift @@ -0,0 +1,96 @@ +import Foundation + +// Achievements.swift - milestones unlocked from the user's own daily history. +// +// The WHOOP app has a server-driven achievements surface with celebration screens (observed in the shipped +// app's model shapes, facts only, no WHOOP code). NOOP has no server, so this is the local equivalent: a +// fixed list of milestones, each unlocked on the first day the user's own stored metrics reach it. +// +// Rules (Kotlin twin: com.noop.analytics.Achievements, held aligned by mirrored fixtures): +// - a "scored day" is a day with a Charge (recovery) value in 0...100; +// - streaks are consecutive civil days with a score (TZ-free epoch arithmetic); a streak milestone +// unlocks on the day the run first reaches its length; +// - single-day milestones unlock on the earliest day the value reaches the threshold inside the +// metric's plausible range, so an artefact never unlocks one; +// - `isNew` = unlocked within the last `newWindowDays` days ending at today. +// +// Pure: no clock, no I/O, no storage. PRESENTATION ONLY. + +/// One milestone. The raw value matches the Kotlin `Achievements.Id.raw`. +public enum AchievementId: String, CaseIterable, Sendable { + case firstScore, streak7, streak30, scored100, charge90, sleep8h, effort18, steps10k +} + +public enum Achievements { + + public static let newWindowDays = 7 + + public struct Day: Equatable, Sendable { + public var day: String + public var recovery: Double? + public var strain: Double? + public var sleepMin: Double? + public var steps: Double? + + public init(day: String, recovery: Double? = nil, strain: Double? = nil, sleepMin: Double? = nil, + steps: Double? = nil) { + self.day = day + self.recovery = recovery + self.strain = strain + self.sleepMin = sleepMin + self.steps = steps + } + } + + public struct Achievement: Equatable, Sendable { + public let id: AchievementId + /// The day it unlocked, nil while locked. + public let unlockedDay: String? + public let isNew: Bool + } + + /// Kotlin twin: `Achievements.inRange`. + static func inRange(_ v: Double?, _ lo: Double, _ hi: Double) -> Double? { + guard let v, !v.isNaN, v >= lo, v <= hi else { return nil } + return v + } + + /// Every milestone in `AchievementId.allCases` order, locked or not. Rows may arrive in any order; + /// unparseable day keys are ignored. When `today` does not parse, nothing is new. + /// Kotlin twin: `Achievements.evaluate`. + public static func evaluate(_ days: [Day], today: String) -> [Achievement] { + let dated = days.compactMap { d in Baselines.isoEpochDay(d.day).map { (epoch: $0, day: d) } } + .sorted { $0.epoch < $1.epoch } + var unlocked: [AchievementId: (day: String, epoch: Int)] = [:] + var run = 0 + var lastScored: Int? + var scored = 0 + for row in dated { + let d = row.day + var hits: [AchievementId] = [] + if inRange(d.recovery, 0, 100) != nil { + if lastScored != row.epoch { // same-day duplicates count once + run = (lastScored == row.epoch - 1) ? run + 1 : 1 + scored += 1 + lastScored = row.epoch + } + hits.append(.firstScore) + if run >= 7 { hits.append(.streak7) } + if run >= 30 { hits.append(.streak30) } + if scored >= 100 { hits.append(.scored100) } + } + if let r = inRange(d.recovery, 0, 100), r >= 90 { hits.append(.charge90) } + if let s = inRange(d.sleepMin, 60, 960), s >= 480 { hits.append(.sleep8h) } + if let e = inRange(d.strain, 0, 21), e >= 18 { hits.append(.effort18) } + if let st = inRange(d.steps, 1, 100_000), st >= 10_000 { hits.append(.steps10k) } + for id in hits where unlocked[id] == nil { unlocked[id] = (d.day, row.epoch) } + } + let t = Baselines.isoEpochDay(today) + return AchievementId.allCases.map { id in + let u = unlocked[id] + var isNew = false + if let u, let t { isNew = u.epoch <= t && u.epoch > t - newWindowDays } + return Achievement(id: id, unlockedDay: u?.day, isNew: isNew) + } + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AchievementsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AchievementsTests.swift new file mode 100644 index 0000000000..9a838cbeb4 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AchievementsTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import StrandAnalytics + +/// Mirrored by android AchievementsTest.kt - same fixtures, same expected outputs. +final class AchievementsTests: XCTestCase { + + private func key(_ epoch: Int) -> String { + let d = Date(timeIntervalSince1970: TimeInterval(epoch) * 86_400) + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.timeZone = TimeZone(identifier: "UTC") + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f.string(from: d) + } + + func testStreakAndThresholdMilestones() { + let start = Baselines.isoEpochDay("2026-09-01")! + // 8 scored days in a row, a gap, then one more. + var days = (0..<8).map { Achievements.Day(day: key(start + $0), recovery: 50) } + days.append(Achievements.Day(day: key(start + 10), recovery: 95, strain: 18.2, sleepMin: 481, steps: 10_000)) + days.append(Achievements.Day(day: key(start + 11), strain: 30, sleepMin: 2_000)) // implausible, no score + let a = Dictionary(uniqueKeysWithValues: Achievements.evaluate(days.shuffled(), today: key(start + 12)) + .map { ($0.id, $0) }) + XCTAssertEqual(a[.firstScore]?.unlockedDay, "2026-09-01") + XCTAssertEqual(a[.streak7]?.unlockedDay, "2026-09-07") + XCTAssertNil(a[.streak30]?.unlockedDay) + XCTAssertNil(a[.scored100]?.unlockedDay) + XCTAssertEqual(a[.charge90]?.unlockedDay, "2026-09-11") + XCTAssertEqual(a[.sleep8h]?.unlockedDay, "2026-09-11") + XCTAssertEqual(a[.effort18]?.unlockedDay, "2026-09-11") + XCTAssertEqual(a[.steps10k]?.unlockedDay, "2026-09-11") + XCTAssertTrue(a[.charge90]!.isNew) + XCTAssertFalse(a[.firstScore]!.isNew) + XCTAssertEqual(Achievements.evaluate([], today: "x").count, AchievementId.allCases.count) + } + + func testDuplicateDayCountsOnceInStreak() { + let start = Baselines.isoEpochDay("2026-01-01")! + var days = (0..<6).map { Achievements.Day(day: key(start + $0), recovery: 40) } + days.append(Achievements.Day(day: key(start + 5), recovery: 41)) + let a = Achievements.evaluate(days, today: key(start + 5)) + XCTAssertNil(a.first { $0.id == .streak7 }?.unlockedDay) + } +} diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7f227851a7..179d54ef6f 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,39 @@ { "sourceLanguage": "en", "strings": { + "Milestones": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Meilensteine"}}, "es": {"stringUnit": {"state": "translated", "value": "Hitos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Jalons"}}, "it": {"stringUnit": {"state": "translated", "value": "Traguardi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Kamienie milowe"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Marcos"}}, "ru": {"stringUnit": {"state": "translated", "value": "Достижения"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "里程碑"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "里程碑"}} + } }, + "%lld of %lld unlocked": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld von %lld freigeschaltet"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld de %lld desbloqueados"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld sur %lld débloqués"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld di %lld sbloccati"}}, "pl": {"stringUnit": {"state": "translated", "value": "Odblokowano %lld z %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld de %lld desbloqueados"}}, "ru": {"stringUnit": {"state": "translated", "value": "Открыто %lld из %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已解锁 %lld/%lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已解鎖 %lld/%lld"}} + } }, + "Unlocked from your own history on this device.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Aus deinem eigenen Verlauf auf diesem Gerät freigeschaltet."}}, "es": {"stringUnit": {"state": "translated", "value": "Desbloqueado con tu propio historial en este dispositivo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Débloqué à partir de votre propre historique sur cet appareil."}}, "it": {"stringUnit": {"state": "translated", "value": "Sbloccato dal tuo storico su questo dispositivo."}}, "pl": {"stringUnit": {"state": "translated", "value": "Odblokowane z Twojej własnej historii na tym urządzeniu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Desbloqueado a partir do seu próprio histórico neste dispositivo."}}, "ru": {"stringUnit": {"state": "translated", "value": "Открыто по вашей собственной истории на этом устройстве."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "根据你在本设备上的历史记录解锁。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "根據你在本裝置上的歷史記錄解鎖。"}} + } }, + "First scored day": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Erster bewerteter Tag"}}, "es": {"stringUnit": {"state": "translated", "value": "Primer día puntuado"}}, "fr": {"stringUnit": {"state": "translated", "value": "Premier jour noté"}}, "it": {"stringUnit": {"state": "translated", "value": "Primo giorno con punteggio"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pierwszy oceniony dzień"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Primeiro dia pontuado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Первый оценённый день"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "首个评分日"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "首個評分日"}} + } }, + "7-day streak": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "7-Tage-Serie"}}, "es": {"stringUnit": {"state": "translated", "value": "Racha de 7 días"}}, "fr": {"stringUnit": {"state": "translated", "value": "Série de 7 jours"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie di 7 giorni"}}, "pl": {"stringUnit": {"state": "translated", "value": "Seria 7 dni"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Série de 7 dias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Серия 7 дней"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "连续 7 天"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "連續 7 天"}} + } }, + "30-day streak": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "30-Tage-Serie"}}, "es": {"stringUnit": {"state": "translated", "value": "Racha de 30 días"}}, "fr": {"stringUnit": {"state": "translated", "value": "Série de 30 jours"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie di 30 giorni"}}, "pl": {"stringUnit": {"state": "translated", "value": "Seria 30 dni"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Série de 30 dias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Серия 30 дней"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "连续 30 天"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "連續 30 天"}} + } }, + "100 scored days": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "100 bewertete Tage"}}, "es": {"stringUnit": {"state": "translated", "value": "100 días puntuados"}}, "fr": {"stringUnit": {"state": "translated", "value": "100 jours notés"}}, "it": {"stringUnit": {"state": "translated", "value": "100 giorni con punteggio"}}, "pl": {"stringUnit": {"state": "translated", "value": "100 ocenionych dni"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "100 dias pontuados"}}, "ru": {"stringUnit": {"state": "translated", "value": "100 оценённых дней"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "100 个评分日"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "100 個評分日"}} + } }, + "Charge 90%+": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Charge 90 %+"}}, "es": {"stringUnit": {"state": "translated", "value": "Charge 90 %+"}}, "fr": {"stringUnit": {"state": "translated", "value": "Charge 90 %+"}}, "it": {"stringUnit": {"state": "translated", "value": "Charge 90%+"}}, "pl": {"stringUnit": {"state": "translated", "value": "Charge 90%+"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Charge 90%+"}}, "ru": {"stringUnit": {"state": "translated", "value": "Charge 90%+"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "Charge 90%+"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "Charge 90%+"}} + } }, + "8 hours asleep": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "8 Stunden Schlaf"}}, "es": {"stringUnit": {"state": "translated", "value": "8 horas de sueño"}}, "fr": {"stringUnit": {"state": "translated", "value": "8 heures de sommeil"}}, "it": {"stringUnit": {"state": "translated", "value": "8 ore di sonno"}}, "pl": {"stringUnit": {"state": "translated", "value": "8 godzin snu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "8 horas de sono"}}, "ru": {"stringUnit": {"state": "translated", "value": "8 часов сна"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "睡满 8 小时"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "睡滿 8 小時"}} + } }, + "Effort 18+": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Belastung 18+"}}, "es": {"stringUnit": {"state": "translated", "value": "Esfuerzo 18+"}}, "fr": {"stringUnit": {"state": "translated", "value": "Effort 18+"}}, "it": {"stringUnit": {"state": "translated", "value": "Sforzo 18+"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wysiłek 18+"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esforço 18+"}}, "ru": {"stringUnit": {"state": "translated", "value": "Нагрузка 18+"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "强度 18+"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "強度 18+"}} + } }, + "10,000 steps": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "10.000 Schritte"}}, "es": {"stringUnit": {"state": "translated", "value": "10.000 pasos"}}, "fr": {"stringUnit": {"state": "translated", "value": "10 000 pas"}}, "it": {"stringUnit": {"state": "translated", "value": "10.000 passi"}}, "pl": {"stringUnit": {"state": "translated", "value": "10 000 kroków"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "10 000 passos"}}, "ru": {"stringUnit": {"state": "translated", "value": "10 000 шагов"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "10,000 步"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "10,000 步"}} + } }, "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/AchievementsCard.swift b/Strand/Screens/AchievementsCard.swift new file mode 100644 index 0000000000..21440dd33e --- /dev/null +++ b/Strand/Screens/AchievementsCard.swift @@ -0,0 +1,76 @@ +import SwiftUI +import StrandDesign +import StrandAnalytics +import WhoopStore + +// MARK: - Milestones card +// +// Milestones unlocked from the user's own daily history. The rules live in the pure `Achievements` +// (StrandAnalytics; Kotlin twin com.noop.analytics.Achievements); this file only formats and renders. +// Mirrors the Android `AchievementsCard.kt`. + +struct AchievementsCard: View { + let days: [DailyMetric] + + var body: some View { + let today = AnalyticsEngine.dayString(Int(Date().timeIntervalSince1970), + offsetSec: TimeZone.current.secondsFromGMT()) + let list = Achievements.evaluate(days.map { + Achievements.Day(day: $0.day, recovery: $0.recovery, strain: $0.strain, sleepMin: $0.totalSleepMin, + steps: $0.steps.map(Double.init)) + }, today: today) + let unlocked = list.filter { $0.unlockedDay != nil }.count + + NoopCard(tint: StrandPalette.gold) { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("\(unlocked) of \(list.count) unlocked").strandOverline() + Text("Milestones").font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) + ForEach(list, id: \.id) { a in + let on = a.unlockedDay != nil + HStack(spacing: NoopMetrics.space2) { + Circle() + .fill(on ? StrandPalette.gold : StrandPalette.textTertiary.opacity(0.2)) + .frame(width: NoopMetrics.space2, height: NoopMetrics.space2) + Text(label(a.id)) + .font(StrandFont.subhead) + .foregroundStyle(on ? StrandPalette.textPrimary : StrandPalette.textTertiary) + Spacer(minLength: NoopMetrics.space2) + if a.isNew { Text("NEW").strandOverline().foregroundStyle(StrandPalette.gold) } + Text(verbatim: a.unlockedDay.map(dayText) ?? String(localized: "Locked")) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + .padding(.horizontal, NoopMetrics.space3) + .padding(.vertical, NoopMetrics.space2) + .background(StrandPalette.surfaceInset, in: Capsule()) + .accessibilityElement(children: .combine) + } + Text("Unlocked from your own history on this device.") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + } + } + } + + private func label(_ id: AchievementId) -> String { + switch id { + case .firstScore: return String(localized: "First scored day") + case .streak7: return String(localized: "7-day streak") + case .streak30: return String(localized: "30-day streak") + case .scored100: return String(localized: "100 scored days") + case .charge90: return String(localized: "Charge 90%+") + case .sleep8h: return String(localized: "8 hours asleep") + case .effort18: return String(localized: "Effort 18+") + case .steps10k: return String(localized: "10,000 steps") + } + } + + private func dayText(_ key: String) -> String { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + guard let d = f.date(from: key) else { return key } + return d.formatted(date: .abbreviated, time: .omitted) + } +} diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 48e3a5e8df..12a125119d 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) + AchievementsCard(days: model.repo.days).staggeredAppear(index: 4) featuresCard.staggeredAppear(index: 5) #if os(iOS) syncCard.staggeredAppear(index: 6) diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 767e162915..2430497908 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"}, + "files": {"count": 511, "sha256": "42442f6b0d9ec52720957cf4cc4675e67246a4999ca34085cec423de4b188ecb"}, + "functions": {"count": 4541, "sha256": "c68e55fc6a9a19c3b00028b59cdabfa931cd4a0eb45f4d07787095525b3fdb67"}, "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, - "constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"}, - "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, - "function_pairs": {"count": 226, "sha256": "7c7f2298ad7921101241f39d86ecab774d21eadeb89d52d4d5db0397878905f2"}, + "constants": {"count": 2003, "sha256": "243d75e1ce035743b6bb5bf60196fe6632d91ed8376594230b304fbf373928cd"}, + "file_pairs": {"count": 82, "sha256": "1a7a45d873d7954c18b694a7cfd03bca8a322090ebba2e5ccdc69da82ec994bb"}, + "function_pairs": {"count": 228, "sha256": "65a9d28fd6b9919526ded6359d7d75befd50067de00da8adcdbc59203f3185fd"}, "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, - "constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"}, + "constant_pairs": {"count": 703, "sha256": "abfffea0ffc5202ab28f35c3c244ae9144b63073bbf4e558bd075145ffda913c"}, "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/Achievements.kt b/android/app/src/main/java/com/noop/analytics/Achievements.kt new file mode 100644 index 0000000000..dbf5bf555b --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/Achievements.kt @@ -0,0 +1,80 @@ +package com.noop.analytics + +/** + * Achievements - milestones unlocked from the user's own daily history. + * + * The WHOOP app has a server-driven achievements surface with celebration screens (observed in the shipped + * app's model shapes, facts only, no WHOOP code). NOOP has no server, so this is the local equivalent: a + * fixed list of milestones, each unlocked on the first day the user's own stored metrics reach it. + * + * Rules (Swift twin: StrandAnalytics/Achievements.swift, held aligned by mirrored fixtures): + * - a "scored day" is a day with a Charge (recovery) value in 0..100; + * - streaks are consecutive civil days with a score (TZ-free epoch arithmetic); a streak milestone + * unlocks on the day the run first reaches its length; + * - single-day milestones unlock on the earliest day the value reaches the threshold inside the + * metric's plausible range, so an artefact never unlocks one; + * - `isNew` = unlocked within the last [NEW_WINDOW_DAYS] days ending at today. + * + * Pure: no clock, no I/O, no storage. PRESENTATION ONLY. + */ +object Achievements { + + const val NEW_WINDOW_DAYS: Int = 7 + + /** One milestone. [raw] matches the Swift `AchievementId` raw value. */ + enum class Id(val raw: String) { + FIRST_SCORE("firstScore"), STREAK_7("streak7"), STREAK_30("streak30"), SCORED_100("scored100"), + CHARGE_90("charge90"), SLEEP_8H("sleep8h"), EFFORT_18("effort18"), STEPS_10K("steps10k"), + } + + data class Day( + val day: String, + val recovery: Double? = null, + val strain: Double? = null, + val sleepMin: Double? = null, + val steps: Double? = null, + ) + + /** [unlockedDay] is null while locked. */ + data class Achievement(val id: Id, val unlockedDay: String?, val isNew: Boolean) + + /** Swift twin: `Achievements.inRange`. */ + private fun inRange(v: Double?, lo: Double, hi: Double): Double? = + v?.takeIf { !it.isNaN() && it >= lo && it <= hi } + + /** Every milestone in [Id.entries] order, locked or not. Rows may arrive in any order; unparseable day + * Swift twin: `Achievements.evaluate`. + * keys are ignored. When [today] does not parse, nothing is new. */ + fun evaluate(days: List, today: String): List { + val dated = days.mapNotNull { d -> Baselines.isoEpochDay(d.day)?.let { it to d } }.sortedBy { it.first } + val unlocked = HashMap>() + var run = 0 + var lastScored: Int? = null + var scored = 0 + for ((epoch, d) in dated) { + val hits = ArrayList() + if (inRange(d.recovery, 0.0, 100.0) != null) { + if (lastScored != epoch) { // same-day duplicates count once + run = if (lastScored == epoch - 1) run + 1 else 1 + scored += 1 + lastScored = epoch + } + hits += Id.FIRST_SCORE + if (run >= 7) hits += Id.STREAK_7 + if (run >= 30) hits += Id.STREAK_30 + if (scored >= 100) hits += Id.SCORED_100 + } + if ((inRange(d.recovery, 0.0, 100.0) ?: -1.0) >= 90.0) hits += Id.CHARGE_90 + if ((inRange(d.sleepMin, 60.0, 960.0) ?: -1.0) >= 480.0) hits += Id.SLEEP_8H + if ((inRange(d.strain, 0.0, 21.0) ?: -1.0) >= 18.0) hits += Id.EFFORT_18 + if ((inRange(d.steps, 1.0, 100_000.0) ?: -1.0) >= 10_000.0) hits += Id.STEPS_10K + for (id in hits) unlocked.putIfAbsent(id, d.day to epoch) + } + val t = Baselines.isoEpochDay(today) + return Id.entries.map { id -> + val u = unlocked[id] + val isNew = u != null && t != null && u.second <= t && u.second > t - NEW_WINDOW_DAYS + Achievement(id, u?.first, isNew) + } + } +} diff --git a/android/app/src/main/java/com/noop/ui/AchievementsCard.kt b/android/app/src/main/java/com/noop/ui/AchievementsCard.kt new file mode 100644 index 0000000000..a59417308c --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/AchievementsCard.kt @@ -0,0 +1,84 @@ +package com.noop.ui + +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import com.noop.R +import com.noop.analytics.Achievements +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +// MARK: - Milestones card +// +// Milestones unlocked from the user's own daily history. The rules live in the pure analytics Achievements +// (Swift twin in StrandAnalytics); this file only formats and renders. Display-only. + +/** A `yyyy-MM-dd` key as a medium localized date; the key itself if it does not parse. */ +internal fun achievementDayText(day: String): String = + runCatching { LocalDate.parse(day).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)) } + .getOrDefault(day) + +@Composable +private fun Achievements.Id.label(): String = when (this) { + Achievements.Id.FIRST_SCORE -> uiString(R.string.l10n_achievement_first_score) + Achievements.Id.STREAK_7 -> uiString(R.string.l10n_achievement_streak_7) + Achievements.Id.STREAK_30 -> uiString(R.string.l10n_achievement_streak_30) + Achievements.Id.SCORED_100 -> uiString(R.string.l10n_achievement_scored_100) + Achievements.Id.CHARGE_90 -> uiString(R.string.l10n_achievement_charge_90) + Achievements.Id.SLEEP_8H -> uiString(R.string.l10n_achievement_sleep_8h) + Achievements.Id.EFFORT_18 -> uiString(R.string.l10n_achievement_effort_18) + Achievements.Id.STEPS_10K -> uiString(R.string.l10n_achievement_steps_10k) +} + +@Composable +internal fun AchievementsCard(achievements: List) { + val unlocked = achievements.count { it.unlockedDay != null } + NoopCard(padding = Metrics.cardPadding, tint = Palette.gold) { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space8)) { + Column { + Overline(uiString(R.string.l10n_achievements_overline, unlocked, achievements.size)) + Text(uiString(R.string.l10n_achievements_title), style = NoopType.headline, color = Palette.textPrimary) + } + achievements.forEach { a -> + val on = a.unlockedDay != null + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Metrics.cornerPill)) + .background(Palette.surfaceInset) + .padding(horizontal = Metrics.space14, vertical = Metrics.space8), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Metrics.space8), + ) { + Box(Modifier.size(Metrics.legendSwatch).clip(CircleShape).background(if (on) Palette.gold else Palette.textTertiary.copy(alpha = 0.2f))) + Text( + a.id.label(), + style = NoopType.subhead, + color = if (on) Palette.textPrimary else Palette.textTertiary, + modifier = Modifier.weight(1f), + ) + if (a.isNew) Overline(uiString(R.string.l10n_achievements_new_badge), color = Palette.gold) + Text( + a.unlockedDay?.let { achievementDayText(it) } ?: uiString(R.string.l10n_achievements_locked), + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } + } + Text(uiString(R.string.l10n_achievements_footnote), style = NoopType.footnote, color = Palette.textTertiary) + } + } +} 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..d6705a7b4a 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,25 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { com.noop.analytics.StreakCalculator.Streaks(0, 0), ) + /** Milestones over the same merged daily metrics (local equivalent of the WHOOP app's server-side + * achievements). Pure math in [com.noop.analytics.Achievements] (Swift/Kotlin twin); display-only. */ + val achievements: StateFlow> = + repository.daysMergedFlow(noopApp.activeDeviceId) + .map { days -> + val nowSec = System.currentTimeMillis() / 1000L + val tz = java.util.TimeZone.getDefault().getOffset(nowSec * 1000L) / 1000L + com.noop.analytics.Achievements.evaluate( + days.map { + com.noop.analytics.Achievements.Day( + day = it.day, recovery = it.recovery, strain = it.strain, + sleepMin = it.totalSleepMin, steps = it.steps?.toDouble(), + ) + }, + today = com.noop.analytics.AnalyticsEngine.dayString(nowSec, tz), + ) + } + .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..658076d1b9 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 achievements by vm.achievements.collectAsStateWithLifecycle() + AchievementsCard(achievements) SettingsCard( icon = Icons.Outlined.AccountCircle, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 8399bc377c..6b43303011 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2873,4 +2873,17 @@ 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 + NEU + Meilensteine + %1$d von %2$d freigeschaltet + Gesperrt + Aus deinem eigenen Verlauf auf diesem Gerät freigeschaltet. + Erster bewerteter Tag + 7-Tage-Serie + 30-Tage-Serie + 100 bewertete Tage + Charge 90 %+ + 8 Stunden Schlaf + Belastung 18+ + 10.000 Schritte diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 20aed28131..a95e92abb3 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2860,4 +2860,17 @@ 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 + NUEVO + Hitos + %1$d de %2$d desbloqueados + Bloqueado + Desbloqueado con tu propio historial en este dispositivo. + Primer día puntuado + Racha de 7 días + Racha de 30 días + 100 días puntuados + Charge 90 %+ + 8 horas de sueño + Esfuerzo 18+ + 10.000 pasos diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index bd1c4cc3b1..94fb88057a 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2859,4 +2859,17 @@ 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 + NOUVEAU + Jalons + %1$d sur %2$d débloqués + Verrouillé + Débloqué à partir de votre propre historique sur cet appareil. + Premier jour noté + Série de 7 jours + Série de 30 jours + 100 jours notés + Charge 90 %+ + 8 heures de sommeil + Effort 18+ + 10 000 pas diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 1a47561f99..863beebd29 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2828,4 +2828,17 @@ 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 + NUOVO + Traguardi + %1$d di %2$d sbloccati + Bloccato + Sbloccato dal tuo storico su questo dispositivo. + Primo giorno con punteggio + Serie di 7 giorni + Serie di 30 giorni + 100 giorni con punteggio + Charge 90%+ + 8 ore di sonno + Sforzo 18+ + 10.000 passi diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index b5e9c52de8..995be4785c 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2876,4 +2876,17 @@ 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 + NOWY + Kamienie milowe + Odblokowano %1$d z %2$d + Zablokowane + Odblokowane z Twojej własnej historii na tym urządzeniu. + Pierwszy oceniony dzień + Seria 7 dni + Seria 30 dni + 100 ocenionych dni + Charge 90%+ + 8 godzin snu + Wysiłek 18+ + 10 000 kroków 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..7ac43eb49c 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,17 @@ 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 + NOVO + Marcos + %1$d de %2$d desbloqueados + Bloqueado + Desbloqueado a partir do seu próprio histórico neste dispositivo. + Primeiro dia pontuado + Série de 7 dias + Série de 30 dias + 100 dias pontuados + Charge 90%+ + 8 horas de sono + Esforço 18+ + 10 000 passos diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 6fbec7268f..8dc9ab2d05 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2755,4 +2755,17 @@ На этом ремешке запись может всё ещё идти. Результат попадёт в strap log. Появляется сегодня, если сессии в реальном времени включены в Настройках или сеанс уже активен. «Сегодня» на ваш вкус, пульс для любого приложения и итальянский язык + НОВЫЙ + Достижения + Открыто %1$d из %2$d + Закрыто + Открыто по вашей собственной истории на этом устройстве. + Первый оценённый день + Серия 7 дней + Серия 30 дней + 100 оценённых дней + Charge 90%+ + 8 часов сна + Нагрузка 18+ + 10 000 шагов diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 75a9e8a04f..db6405d50b 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2830,4 +2830,17 @@ 此腕带上可能仍在采集。结论会写入 strap log。 在设置中启用Live Sessions 实时记录或有正在进行的训练时,今天会显示此部分。 自定义「今天」页面、任何应用都能读取的心率,以及意大利语 + 新 + 里程碑 + 已解锁 %1$d / %2$d + 未解锁 + 根据你在本设备上的历史记录解锁。 + 首个评分日 + 连续 7 天 + 连续 30 天 + 100 个评分日 + Charge 90%+ + 睡满 8 小时 + 强度 18+ + 10,000 步 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5ae979343a..0d5c095972 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2889,4 +2889,17 @@ 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 + NEW + Milestones + %1$d of %2$d unlocked + Locked + Unlocked from your own history on this device. + First scored day + 7-day streak + 30-day streak + 100 scored days + Charge 90%+ + 8 hours asleep + Effort 18+ + 10,000 steps diff --git a/android/app/src/test/java/com/noop/analytics/AchievementsTest.kt b/android/app/src/test/java/com/noop/analytics/AchievementsTest.kt new file mode 100644 index 0000000000..fd528e28f1 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/AchievementsTest.kt @@ -0,0 +1,40 @@ +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/AchievementsTests.swift - same fixtures, same expected outputs. */ +class AchievementsTest { + + private fun key(epoch: Int): String = java.time.LocalDate.ofEpochDay(epoch.toLong()).toString() + + @Test fun streakAndThresholdMilestones() { + val start = Baselines.isoEpochDay("2026-09-01")!! + val days = (0 until 8).map { Achievements.Day(key(start + it), recovery = 50.0) }.toMutableList() + days += Achievements.Day(key(start + 10), recovery = 95.0, strain = 18.2, sleepMin = 481.0, steps = 10_000.0) + days += Achievements.Day(key(start + 11), strain = 30.0, sleepMin = 2_000.0) // implausible, no score + val a = Achievements.evaluate(days.shuffled(), key(start + 12)).associateBy { it.id } + assertEquals("2026-09-01", a[Achievements.Id.FIRST_SCORE]?.unlockedDay) + assertEquals("2026-09-07", a[Achievements.Id.STREAK_7]?.unlockedDay) + assertNull(a[Achievements.Id.STREAK_30]?.unlockedDay) + assertNull(a[Achievements.Id.SCORED_100]?.unlockedDay) + assertEquals("2026-09-11", a[Achievements.Id.CHARGE_90]?.unlockedDay) + assertEquals("2026-09-11", a[Achievements.Id.SLEEP_8H]?.unlockedDay) + assertEquals("2026-09-11", a[Achievements.Id.EFFORT_18]?.unlockedDay) + assertEquals("2026-09-11", a[Achievements.Id.STEPS_10K]?.unlockedDay) + assertTrue(a[Achievements.Id.CHARGE_90]!!.isNew) + assertFalse(a[Achievements.Id.FIRST_SCORE]!!.isNew) + assertEquals(Achievements.Id.entries.size, Achievements.evaluate(emptyList(), "x").size) + } + + @Test fun duplicateDayCountsOnceInStreak() { + val start = Baselines.isoEpochDay("2026-01-01")!! + val days = (0 until 6).map { Achievements.Day(key(start + it), recovery = 40.0) } + + Achievements.Day(key(start + 5), recovery = 41.0) + val a = Achievements.evaluate(days, key(start + 5)) + assertNull(a.first { it.id == Achievements.Id.STREAK_7 }.unlockedDay) + } +}