Some checks failed
CI / build-and-test (push) Has been cancelled
Phase 3 core (verifiable): WorkoutAnalyticsAdapter (app data → DaySample), AnalyticsCoordinator (idempotent backfill of missing weekly/monthly reports + retention prune, injectable providers), AnalyticsDeepLink (wenza://analytics report routing). Fixed a reconcile bug caught by tests: a retention-boundary week was generated then immediately pruned, regenerating every run — aligned completedWeekStartKeys to the prune cutoff → idempotent. Tests: AnalyticsAdapterTests + AnalyticsCoordinatorTests. Verified standalone via swiftc (~90 analytics assertions total across engine/store/adapter/coordinator). Remaining (app wiring, notifications, UI) requires an Xcode build — logged in ISSUES.md as device-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
327 lines
15 KiB
Swift
327 lines
15 KiB
Swift
import Foundation
|
||
|
||
// Deterministic, side-effect-free analytics calculations. No SwiftUI, no
|
||
// persistence, no HealthKit, no LLM — pure functions over value types so every
|
||
// formula is unit-testable and reproducible. All calendar math takes an injected
|
||
// `Calendar` so locale / firstWeekday / timezone / DST behavior is testable.
|
||
//
|
||
// FORMULAS
|
||
// volume = Σ (weight × reps) over completed sets [kg]
|
||
// percentChange = (current − baseline) / |baseline| × 100 [%]
|
||
// consistency = min(1, sessions / scheduled) [0…1]
|
||
// trend = classify(least-squares slope / |mean|) vs threshold
|
||
//
|
||
// All comparisons degrade honestly: missing baseline → no %, tiny/zero baseline
|
||
// → no % (avoids divide-by-tiny), new exercise / rest day → not meaningful.
|
||
// Language is factual and supportive; nothing here diagnoses or advises medically.
|
||
|
||
enum AnalyticsEngine {
|
||
|
||
/// Informational, non-medical disclaimer surfaced with every report.
|
||
static let disclaimer = """
|
||
These are informational summaries of your own logged activity — not medical \
|
||
advice, diagnosis, or treatment. Talk to a qualified professional for health \
|
||
decisions.
|
||
"""
|
||
|
||
// MARK: - Exercise identity
|
||
|
||
/// Normalized identity so the same exercise matches across days regardless of
|
||
/// casing / whitespace. Deterministic and locale-independent.
|
||
static func exerciseIdentity(_ name: String) -> String {
|
||
name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.lowercased()
|
||
.replacingOccurrences(of: " ", with: " ")
|
||
}
|
||
|
||
// MARK: - Delta
|
||
|
||
/// Compare `current` against `baseline`, guarding percentage honesty.
|
||
/// - `isNew`: no prior baseline exists (e.g. a newly added exercise).
|
||
/// - `restDay`: baseline day was a rest day → % is not meaningful.
|
||
static func delta(current: Double,
|
||
baseline: Double?,
|
||
minMeaningfulBaseline: Double = 0.0001,
|
||
isNew: Bool = false,
|
||
restDay: Bool = false) -> MetricDelta {
|
||
guard let base = baseline, !isNew, !restDay else {
|
||
return MetricDelta(current: current,
|
||
baseline: baseline,
|
||
absolute: baseline.map { current - $0 },
|
||
percent: nil,
|
||
direction: current > 0 ? .up : .flat,
|
||
meaningful: false)
|
||
}
|
||
let abs = current - base
|
||
let dir: MetricDirection = abs > 0 ? .up : (abs < 0 ? .down : .flat)
|
||
// Only compute % when the baseline magnitude is meaningful.
|
||
let pct: Double? = Swift.abs(base) >= minMeaningfulBaseline ? (abs / Swift.abs(base)) * 100 : nil
|
||
return MetricDelta(current: current,
|
||
baseline: base,
|
||
absolute: abs,
|
||
percent: pct,
|
||
direction: dir,
|
||
meaningful: pct != nil)
|
||
}
|
||
|
||
/// Classify a single week/month-over-period change from its volume delta.
|
||
static func classifyChange(_ d: MetricDelta, plateauPct: Double = 3) -> TrendClassification {
|
||
guard d.meaningful, let pct = d.percent else { return .insufficientData }
|
||
if pct > plateauPct { return .improving }
|
||
if pct < -plateauPct { return .declining }
|
||
return .plateau
|
||
}
|
||
|
||
// MARK: - Consistency
|
||
|
||
static func consistency(sessions: Int, scheduled: Int) -> Double {
|
||
guard scheduled > 0 else { return 0 }
|
||
return min(1.0, Double(sessions) / Double(scheduled))
|
||
}
|
||
|
||
// MARK: - Volume
|
||
|
||
static func volume(weight: Double, reps: Int) -> Double { weight * Double(reps) }
|
||
|
||
// MARK: - Trend (multi-point, for 12-month views)
|
||
|
||
static func classifyTrend(_ values: [Double],
|
||
minPoints: Int = 3,
|
||
relativeThreshold: Double = 0.03) -> TrendResult {
|
||
let n = values.count
|
||
guard n >= minPoints else {
|
||
return TrendResult(classification: .insufficientData, slope: 0, points: n)
|
||
}
|
||
let slope = leastSquaresSlope(values)
|
||
let mean = values.reduce(0, +) / Double(n)
|
||
guard Swift.abs(mean) > 0 else {
|
||
let cls: TrendClassification = slope == 0 ? .plateau : (slope > 0 ? .improving : .declining)
|
||
return TrendResult(classification: cls, slope: slope, points: n)
|
||
}
|
||
let rel = slope / Swift.abs(mean)
|
||
let cls: TrendClassification
|
||
if rel > relativeThreshold { cls = .improving }
|
||
else if rel < -relativeThreshold { cls = .declining }
|
||
else { cls = .plateau }
|
||
return TrendResult(classification: cls, slope: slope, points: n)
|
||
}
|
||
|
||
static func leastSquaresSlope(_ y: [Double]) -> Double {
|
||
let n = Double(y.count)
|
||
guard n > 1 else { return 0 }
|
||
let xs = (0..<y.count).map(Double.init)
|
||
let sumX = xs.reduce(0, +)
|
||
let sumY = y.reduce(0, +)
|
||
let sumXY = zip(xs, y).map(*).reduce(0, +)
|
||
let sumXX = xs.map { $0 * $0 }.reduce(0, +)
|
||
let denom = n * sumXX - sumX * sumX
|
||
guard denom != 0 else { return 0 }
|
||
return (n * sumXY - sumX * sumY) / denom
|
||
}
|
||
|
||
// MARK: - Outliers
|
||
|
||
/// Winsorize to the [lower, upper] percentile band to tame outliers before
|
||
/// trend/mean calculations. No-op for very small samples.
|
||
static func winsorize(_ values: [Double], lower: Double = 0.1, upper: Double = 0.9) -> [Double] {
|
||
guard values.count >= 5 else { return values }
|
||
let sorted = values.sorted()
|
||
let lo = percentile(sorted, lower)
|
||
let hi = percentile(sorted, upper)
|
||
return values.map { min(max($0, lo), hi) }
|
||
}
|
||
|
||
static func percentile(_ sorted: [Double], _ p: Double) -> Double {
|
||
guard !sorted.isEmpty else { return 0 }
|
||
let idx = Int((Double(sorted.count - 1) * min(max(p, 0), 1)).rounded())
|
||
return sorted[min(max(idx, 0), sorted.count - 1)]
|
||
}
|
||
|
||
// MARK: - Exercise comparison
|
||
|
||
static func compareExercises(current: [ExerciseSample],
|
||
baseline: [ExerciseSample],
|
||
restDay: Bool = false) -> [ExerciseComparison] {
|
||
let baseByIdentity = Dictionary(baseline.map { ($0.identity, $0) }, uniquingKeysWith: { a, _ in a })
|
||
return current.map { cur in
|
||
let base = baseByIdentity[cur.identity]
|
||
let isNew = base == nil
|
||
return ExerciseComparison(
|
||
identity: cur.identity,
|
||
volume: delta(current: cur.volume, baseline: base?.volume, isNew: isNew, restDay: restDay),
|
||
sets: delta(current: Double(cur.sets), baseline: base.map { Double($0.sets) }, isNew: isNew, restDay: restDay),
|
||
reps: delta(current: Double(cur.reps), baseline: base.map { Double($0.reps) }, isNew: isNew, restDay: restDay),
|
||
isNew: isNew
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Daily comparison
|
||
|
||
static func dailyComparison(day: DaySample,
|
||
previousDay: DaySample?,
|
||
sameWeekdayLastWeek: DaySample?) -> DailyComparison {
|
||
DailyComparison(
|
||
dateKey: day.dateKey,
|
||
restDay: !day.didWorkout,
|
||
vsPreviousDay: compareExercises(current: day.exercises,
|
||
baseline: previousDay?.exercises ?? [],
|
||
restDay: previousDay.map { !$0.didWorkout } ?? true),
|
||
vsSameWeekdayLastWeek: compareExercises(current: day.exercises,
|
||
baseline: sameWeekdayLastWeek?.exercises ?? [],
|
||
restDay: sameWeekdayLastWeek.map { !$0.didWorkout } ?? true)
|
||
)
|
||
}
|
||
|
||
// MARK: - Period summaries & reports
|
||
|
||
static func summarize(_ days: [DaySample]) -> PeriodSummary {
|
||
let sessions = days.filter { $0.didWorkout }.count
|
||
let scheduled = days.filter { $0.scheduled }.count
|
||
let missed = days.filter { $0.scheduled && !$0.didWorkout }.count
|
||
let sets = days.reduce(0) { $0 + $1.sets }
|
||
let volume = days.reduce(0) { $0 + $1.volume }
|
||
let duration = days.reduce(0) { $0 + $1.durationMinutes }
|
||
return PeriodSummary(sessions: sessions, sets: sets, volume: volume,
|
||
durationMinutes: duration, scheduled: scheduled,
|
||
missed: missed, consistency: consistency(sessions: sessions, scheduled: scheduled))
|
||
}
|
||
|
||
private static func periodDeltas(current cur: PeriodSummary, previous prev: PeriodSummary) -> [String: MetricDelta] {
|
||
[
|
||
"sessions": delta(current: Double(cur.sessions), baseline: Double(prev.sessions)),
|
||
"sets": delta(current: Double(cur.sets), baseline: Double(prev.sets)),
|
||
"volume": delta(current: cur.volume, baseline: prev.volume),
|
||
"duration": delta(current: cur.durationMinutes, baseline: prev.durationMinutes),
|
||
"consistency": delta(current: cur.consistency * 100, baseline: prev.consistency * 100),
|
||
]
|
||
}
|
||
|
||
static func weeklyReport(weekStartKey: String,
|
||
days: [DaySample],
|
||
previousWeekDays: [DaySample],
|
||
generatedAt: Date) -> WeeklyReport {
|
||
let cur = summarize(days)
|
||
let prev = summarize(previousWeekDays)
|
||
let deltas = periodDeltas(current: cur, previous: prev)
|
||
return WeeklyReport(
|
||
weekStartKey: weekStartKey,
|
||
generatedAt: generatedAt,
|
||
summary: cur,
|
||
vsPreviousWeek: deltas,
|
||
progression: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
|
||
disclaimer: disclaimer
|
||
)
|
||
}
|
||
|
||
static func monthlyReport(monthKey: String,
|
||
days: [DaySample],
|
||
previousMonthDays: [DaySample],
|
||
generatedAt: Date) -> MonthlyReport {
|
||
let cur = summarize(days)
|
||
let prev = summarize(previousMonthDays)
|
||
let deltas = periodDeltas(current: cur, previous: prev)
|
||
return MonthlyReport(
|
||
monthKey: monthKey,
|
||
generatedAt: generatedAt,
|
||
summary: cur,
|
||
vsPreviousMonth: deltas,
|
||
classification: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
|
||
disclaimer: disclaimer
|
||
)
|
||
}
|
||
|
||
// MARK: - Calendar helpers (injected Calendar → locale/TZ/DST-safe)
|
||
|
||
private static func keyFormatter(_ calendar: Calendar) -> DateFormatter {
|
||
let f = DateFormatter()
|
||
f.calendar = calendar
|
||
f.locale = Locale(identifier: "en_US_POSIX")
|
||
f.timeZone = calendar.timeZone
|
||
f.dateFormat = "yyyy-MM-dd"
|
||
return f
|
||
}
|
||
|
||
static func dateKey(for date: Date, calendar: Calendar) -> String {
|
||
keyFormatter(calendar).string(from: calendar.startOfDay(for: date))
|
||
}
|
||
|
||
static func monthKey(for date: Date, calendar: Calendar) -> String {
|
||
let f = keyFormatter(calendar); f.dateFormat = "yyyy-MM"
|
||
return f.string(from: date)
|
||
}
|
||
|
||
static func weekInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
|
||
calendar.dateInterval(of: .weekOfYear, for: date)
|
||
}
|
||
|
||
static func monthInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
|
||
calendar.dateInterval(of: .month, for: date)
|
||
}
|
||
|
||
/// The same weekday one week earlier — used for the day-vs-last-week compare.
|
||
static func sameWeekdayLastWeek(_ date: Date, calendar: Calendar) -> Date? {
|
||
calendar.date(byAdding: .weekOfYear, value: -1, to: date)
|
||
}
|
||
|
||
/// Whether `date` falls in a completed period (strictly before the period
|
||
/// containing `now`) — reports are only generated for completed periods.
|
||
static func isCompletedWeek(_ date: Date, now: Date, calendar: Calendar) -> Bool {
|
||
guard let w = weekInterval(containing: date, calendar: calendar) else { return false }
|
||
return w.end <= (weekInterval(containing: now, calendar: calendar)?.start ?? now)
|
||
}
|
||
|
||
static func isCompletedMonth(_ date: Date, now: Date, calendar: Calendar) -> Bool {
|
||
guard let m = monthInterval(containing: date, calendar: calendar) else { return false }
|
||
return m.end <= (monthInterval(containing: now, calendar: calendar)?.start ?? now)
|
||
}
|
||
|
||
// MARK: - Retention
|
||
|
||
/// Keys older than `months` before `now` (for safe pruning of raw data).
|
||
static func expiredDayKeys(_ keys: [String], now: Date, months: Int = 12, calendar: Calendar) -> [String] {
|
||
guard let cutoff = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return [] }
|
||
let cutoffKey = dateKey(for: cutoff, calendar: calendar)
|
||
return keys.filter { $0 < cutoffKey }
|
||
}
|
||
|
||
// MARK: - Reconciliation selection (which completed periods need a report)
|
||
|
||
/// Week-start keys of every completed week within the last `months`, oldest→newest.
|
||
/// Only weeks whose start is on/after the retention cutoff are included, so
|
||
/// generation and `prune` agree — otherwise a boundary week would be
|
||
/// generated then immediately pruned, regenerating on every run.
|
||
static func completedWeekStartKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
|
||
guard let windowStart = cal.date(byAdding: .month, value: -months, to: now),
|
||
let thisWeek = weekInterval(containing: now, calendar: cal),
|
||
var cursor = weekInterval(containing: windowStart, calendar: cal)?.start else { return [] }
|
||
let cutoffKey = dateKey(for: cal.startOfDay(for: windowStart), calendar: cal)
|
||
var keys: [String] = []
|
||
var guardCount = 0
|
||
while cursor < thisWeek.start, guardCount < 70 {
|
||
let key = dateKey(for: cursor, calendar: cal)
|
||
if key >= cutoffKey { keys.append(key) }
|
||
guard let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor) else { break }
|
||
cursor = next; guardCount += 1
|
||
}
|
||
return keys
|
||
}
|
||
|
||
/// Month keys of every completed month within the last `months`, oldest→newest.
|
||
static func completedMonthKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
|
||
var keys: [String] = []
|
||
for m in stride(from: months, through: 1, by: -1) {
|
||
guard let d = cal.date(byAdding: .month, value: -m, to: now) else { continue }
|
||
let key = monthKey(for: d, calendar: cal)
|
||
if !keys.contains(key) { keys.append(key) }
|
||
}
|
||
return keys
|
||
}
|
||
|
||
/// Keys present in `all` but not in `existing` — the periods still needing a
|
||
/// report. Idempotent by construction: already-generated periods are skipped.
|
||
static func missingKeys(_ all: [String], existing: Set<String>) -> [String] {
|
||
all.filter { !existing.contains($0) }
|
||
}
|
||
}
|