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
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
33 changes: 33 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -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": "匯出運動路線"}}}},
Expand Down
Loading