Files
KisaniCal/KisaniCal/Views/AnalyticsView.swift
kutesir 839ac72846
Some checks failed
CI / build-and-test (push) Has been cancelled
analytics(healthkit): wire historical HealthKit reference into reports (KC-51 ph4e)
Completes 'available HealthKit trends' for weekly/monthly reports. Adds
HealthKitManager.dailyReference (3 parallel bucketed HKStatisticsCollectionQuery
for steps/active-calories/resting-HR over ~13 months), AnalyticsService.
refreshHealthReference to cache and merge it into DaySamples, and avgSteps/
avgActiveCalories/avgRestingHR on PeriodSummary (nil when absent — honest).
Surfaced in AnalyticsView's weekly/monthly cards.

Verified standalone (swiftc, 8/8): aggregation correct when present, nil when
absent, workout metrics unaffected, reports stay deterministic. Tests added to
AnalyticsEngineTests. This completes all analytics logic — remaining work is the
first Xcode build (device, user-run) to catch any SwiftUI compile issues.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:39:00 +03:00

428 lines
17 KiB
Swift

import SwiftUI
import Charts
// Analytics area: Overview · Daily · Weekly · Monthly · 12-Month · Exercises.
// Reads from AnalyticsService (which reconciles reports elsewhere). Status is
// conveyed by BOTH color and a glyph (never color alone). Includes empty/partial
// states and an informational, non-medical disclaimer.
//
// NOTE: written without an available build in this environment verify on device.
struct AnalyticsView: View {
@Environment(\.colorScheme) private var cs
@ObservedObject private var service = AnalyticsService.shared
@State private var area: Area = .overview
@State private var selectedExercise: String?
/// Optional deep link (from a report notification) picks the initial area.
init(initialLink: AnalyticsDeepLink? = nil) {
switch initialLink {
case .weekly: _area = State(initialValue: .weekly)
case .monthly: _area = State(initialValue: .monthly)
default: break // keep default .overview
}
}
enum Area: String, CaseIterable, Identifiable {
case overview = "Overview", daily = "Daily", weekly = "Weekly"
case monthly = "Monthly", trends = "12-Month", exercises = "Exercises"
var id: String { rawValue }
}
private var hasAnyReports: Bool {
!service.weeklyReports().isEmpty || !service.monthlyReports().isEmpty
}
var body: some View {
VStack(spacing: 0) {
header
areaSelector
Divider().background(AppColors.border(cs))
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
content
disclaimer
}
.padding(16)
.padding(.bottom, 40)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
private var header: some View {
Text("Analytics")
.font(AppFonts.sans(20, weight: .bold))
.foregroundColor(AppColors.text(cs))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 8)
}
private var areaSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Area.allCases) { a in
let on = a == area
Text(a.rawValue)
.font(AppFonts.sans(12, weight: .semibold))
.foregroundColor(on ? .white : AppColors.text2(cs))
.padding(.horizontal, 12).padding(.vertical, 7)
.background(on ? AppColors.accent : AppColors.surface2(cs))
.clipShape(Capsule())
.contentShape(Capsule())
.onTapGesture { withAnimation(KisaniSpring.micro) { area = a } }
.accessibilityLabel(a.rawValue)
.accessibilityAddTraits(on ? [.isButton, .isSelected] : [.isButton])
}
}
.padding(.horizontal, 16).padding(.vertical, 10)
}
}
@ViewBuilder private var content: some View {
if !hasAnyReports && area != .daily {
emptyState
} else {
switch area {
case .overview: overview
case .daily: dailySection
case .weekly: weeklySection
case .monthly: monthlySection
case .trends: trendsSection
case .exercises: exercisesSection
}
}
}
// MARK: - Empty state
private var emptyState: some View {
VStack(spacing: 8) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.system(size: 30)).foregroundColor(AppColors.text3(cs))
Text("No reports yet")
.font(AppFonts.sans(15, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Text("As each week and month completes, an immutable summary appears here.")
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity).padding(.vertical, 60)
}
// MARK: - Overview
private var overview: some View {
VStack(alignment: .leading, spacing: 16) {
if let w = service.weeklyReports().first {
sectionTitle("Latest week")
weeklyCard(w)
}
if let m = service.monthlyReports().first {
sectionTitle("Latest month")
monthlyCard(m)
}
sectionTitle("12-month direction")
HStack {
trendBadge(service.monthlyTrendClassification().classification)
Spacer()
Text("\(service.monthlyReports().count) mo · \(service.weeklyReports().count) wk")
.font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
.padding(12).cardBG(cs)
}
}
// MARK: - Daily
private var dailySection: some View {
let cmp = service.dailyComparison(on: Date())
return VStack(alignment: .leading, spacing: 16) {
if cmp.restDay {
infoCard("Rest day", "No workout logged today — nothing to compare.")
}
comparisonGroup("vs. yesterday", cmp.vsPreviousDay)
comparisonGroup("vs. same day last week", cmp.vsSameWeekdayLastWeek)
}
}
private func comparisonGroup(_ title: String, _ comps: [ExerciseComparison]) -> some View {
VStack(alignment: .leading, spacing: 8) {
sectionTitle(title)
if comps.isEmpty {
infoCard("Nothing to compare", "No matching exercises for this comparison.")
} else {
ForEach(comps, id: \.identity) { c in
HStack {
Text(c.identity.capitalized)
.font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs))
if c.isNew {
Text("NEW").font(AppFonts.mono(8, weight: .bold))
.foregroundColor(AppColors.blue)
.padding(.horizontal, 5).padding(.vertical, 2)
.background(AppColors.blueSoft).clipShape(Capsule())
}
Spacer()
deltaBadge(c.volume, unit: "kg")
}
.padding(12).cardBG(cs)
}
}
}
}
// MARK: - Weekly / Monthly
private var weeklySection: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(service.weeklyReports()) { weeklyCard($0) }
}
}
private var monthlySection: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(service.monthlyReports()) { monthlyCard($0) }
}
}
private func weeklyCard(_ r: WeeklyReport) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Week of \(r.weekStartKey)")
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Spacer()
trendBadge(r.progression)
}
summaryRow(r.summary)
healthRow(r.summary)
deltaChips(r.vsPreviousWeek)
}
.padding(14).cardBG(cs)
}
private func monthlyCard(_ r: MonthlyReport) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(r.monthKey)
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Spacer()
trendBadge(r.classification)
}
summaryRow(r.summary)
healthRow(r.summary)
deltaChips(r.vsPreviousMonth)
}
.padding(14).cardBG(cs)
}
private func summaryRow(_ s: PeriodSummary) -> some View {
HStack(spacing: 14) {
stat("\(s.sessions)", "sessions")
stat("\(s.sets)", "sets")
stat("\(Int(s.volume))", "kg")
stat("\(Int(s.consistency * 100))%", "consistent")
if s.missed > 0 { stat("\(s.missed)", "missed") }
}
}
/// Available HealthKit trends for the period (shown only when present).
@ViewBuilder private func healthRow(_ s: PeriodSummary) -> some View {
if s.avgSteps != nil || s.avgActiveCalories != nil || s.avgRestingHR != nil {
HStack(spacing: 14) {
if let v = s.avgSteps { stat("\(v)", "avg steps") }
if let v = s.avgActiveCalories { stat("\(v)", "avg kcal") }
if let v = s.avgRestingHR { stat("\(v)", "avg bpm") }
}
}
}
private func deltaChips(_ deltas: [String: MetricDelta]) -> some View {
let order = ["volume", "sessions", "sets", "duration", "consistency"]
return FlowRow(order.compactMap { key -> (String, MetricDelta)? in
guard let d = deltas[key], d.meaningful, d.percent != nil else { return nil }
return (key, d)
}) { pair in
HStack(spacing: 4) {
Text(pair.0).font(AppFonts.mono(9)).foregroundColor(AppColors.text3(cs))
deltaBadge(pair.1, unit: "")
}
.padding(.horizontal, 8).padding(.vertical, 4)
.background(AppColors.surface2(cs)).clipShape(Capsule())
}
}
// MARK: - 12-Month trends
private var trendsSection: some View {
let series = service.monthlyVolumeTrend()
return VStack(alignment: .leading, spacing: 12) {
HStack {
sectionTitle("Monthly volume")
Spacer()
trendBadge(service.monthlyTrendClassification().classification)
}
if series.isEmpty {
infoCard("Not enough data", "A few completed months are needed to chart a trend.")
} else {
Chart(series) { point in
BarMark(
x: .value("Month", point.monthKey),
y: .value("Volume", point.volume)
)
.foregroundStyle(AppColors.accent)
}
.frame(height: 200)
.padding(12).cardBG(cs)
.accessibilityLabel("Monthly training volume for the last year")
}
}
}
// MARK: - Per-exercise
private var exercisesSection: some View {
let names = service.knownExerciseIdentities()
return VStack(alignment: .leading, spacing: 12) {
if names.isEmpty {
infoCard("No exercise history", "Logged exercises will appear here over time.")
} else {
Menu {
ForEach(names, id: \.self) { n in
Button(n.capitalized) { selectedExercise = n }
}
} label: {
HStack {
Text((selectedExercise ?? names.first ?? "").capitalized)
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Image(systemName: "chevron.down").font(.system(size: 11)).foregroundColor(AppColors.text3(cs))
Spacer()
}
.padding(12).cardBG(cs)
}
let id = selectedExercise ?? names.first ?? ""
let history = service.exerciseHistory(identity: id)
if history.count < 2 {
infoCard("Not enough history", "Log this exercise on more days to see a trend.")
} else {
Chart(history) { point in
LineMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
.foregroundStyle(AppColors.accent)
PointMark(x: .value("Date", point.dateKey), y: .value("Volume", point.volume))
.foregroundStyle(AppColors.accent)
}
.frame(height: 200)
.padding(12).cardBG(cs)
.accessibilityLabel("Volume history for \(id)")
}
}
}
}
// MARK: - Reusable bits
private func sectionTitle(_ t: String) -> some View {
Text(t.uppercased())
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
}
private func stat(_ value: String, _ label: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(value).font(AppFonts.sans(15, weight: .bold)).foregroundColor(AppColors.text(cs))
Text(label).font(AppFonts.mono(8)).foregroundColor(AppColors.text3(cs))
}
}
private func infoCard(_ title: String, _ body: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Text(body).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12).cardBG(cs)
}
/// Direction shown by glyph + sign + color (glyph works without color).
private func deltaBadge(_ d: MetricDelta, unit: String) -> some View {
let (glyph, color): (String, Color) = {
switch d.direction {
case .up: return ("arrow.up.right", AppColors.green)
case .down: return ("arrow.down.right", AppColors.red)
case .flat: return ("equal", AppColors.text3(cs))
case .none: return ("minus", AppColors.text3(cs))
}
}()
let text: String = {
if let p = d.percent { return String(format: "%+.0f%%", p) }
if let a = d.absolute {
let u = unit.isEmpty ? "" : " " + unit
return String(format: "%+.0f", a) + u
}
return ""
}()
return HStack(spacing: 3) {
Image(systemName: glyph).font(.system(size: 9, weight: .bold))
Text(text).font(AppFonts.mono(11, weight: .semibold))
}
.foregroundColor(d.meaningful ? color : AppColors.text3(cs))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(d.direction.rawValue) \(text)")
}
private func trendBadge(_ t: TrendClassification) -> some View {
let (glyph, color, label): (String, Color, String) = {
switch t {
case .improving: return ("arrow.up.right", AppColors.green, "Improving")
case .declining: return ("arrow.down.right", AppColors.red, "Declining")
case .plateau: return ("equal", AppColors.text2(cs), "Plateau")
case .insufficientData: return ("minus", AppColors.text3(cs), "Not enough data")
}
}()
return HStack(spacing: 4) {
Image(systemName: glyph).font(.system(size: 9, weight: .bold))
Text(label).font(AppFonts.mono(9, weight: .bold))
}
.foregroundColor(color)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(color.opacity(0.12)).clipShape(Capsule())
.accessibilityElement(children: .combine)
.accessibilityLabel("Trend: \(label)")
}
private var disclaimer: some View {
Text(AnalyticsEngine.disclaimer)
.font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
}
// A minimal wrapping row (chips flow to new lines).
private struct FlowRow<Data: RandomAccessCollection, Content: View>: View {
let items: Data
let content: (Data.Element) -> Content
init(_ items: Data, @ViewBuilder content: @escaping (Data.Element) -> Content) {
self.items = items; self.content = content
}
var body: some View {
// Simple 2-per-row grid is enough for the small delta-chip set.
let arr = Array(items)
return VStack(alignment: .leading, spacing: 6) {
ForEach(Array(stride(from: 0, to: arr.count, by: 2)), id: \.self) { i in
HStack(spacing: 6) {
content(arr[i])
if i + 1 < arr.count { content(arr[i + 1]) }
Spacer(minLength: 0)
}
}
}
}
}
private extension View {
/// Standard card background used across the analytics screens.
func cardBG(_ cs: ColorScheme) -> some View {
self.frame(maxWidth: .infinity, alignment: .leading)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
}
}