Files
KisaniCal/KisaniCal/Analytics/AnalyticsEngine.swift

322 lines
14 KiB
Swift
Raw Normal View History

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) [01]
// 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`, oldestnewest.
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 [] }
var keys: [String] = []
var guardCount = 0
while cursor < thisWeek.start, guardCount < 70 {
keys.append(dateKey(for: cursor, calendar: cal))
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`, oldestnewest.
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) }
}
}