analytics(ui): AnalyticsView (6 areas + states + charts) + Workout entry (KC-51 ph4b)

Self-contained Analytics UI: Overview/Daily/Weekly/Monthly/12-Month/Exercises
with color+glyph status, empty/partial states, Swift Charts, VoiceOver labels,
Wenza tokens, and the non-medical disclaimer. Entry via an IButton in the
Workout header (sheet). Added Identifiable chart points.

Self-reviewed for compile issues (fixed tuple key-paths, a11y trait literal,
format string) but NOT build-verified — no iOS runtime; SwiftUI/app deps. Logged
in ISSUES.md as device-required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-07-07 23:23:54 +03:00
committed by kutesir
parent 3f46369078
commit f8a1f90f8c
6 changed files with 453 additions and 4 deletions

View File

@@ -46,6 +46,7 @@
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; };
9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */; };
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; };
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; };
A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; };
@@ -113,6 +114,7 @@
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStore.swift; sourceTree = "<group>"; };
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = "<group>"; };
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -243,6 +245,7 @@
children = (
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */,
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */,
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
D44530A77DF12A17E52AAF34 /* MatrixView.swift */,
@@ -514,6 +517,7 @@
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */,
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */,
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */,
9AA3E4808E88CA639BF3F28B /* AnalyticsView.swift in Sources */,
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,

View File

@@ -143,6 +143,19 @@ struct WeeklyReport: Codable, Equatable, Identifiable {
var id: String { weekStartKey }
}
// Chart-friendly Identifiable points (tuples can't be keyed in SwiftUI Charts).
struct MonthVolumePoint: Identifiable, Equatable {
let monthKey: String
let volume: Double
var id: String { monthKey }
}
struct ExerciseVolumePoint: Identifiable, Equatable {
let dateKey: String
let volume: Double
var id: String { dateKey }
}
struct MonthlyReport: Codable, Equatable, Identifiable {
let monthKey: String // "yyyy-MM"
let generatedAt: Date

View File

@@ -131,8 +131,9 @@ final class AnalyticsService: ObservableObject {
func monthlyReports() -> [MonthlyReport] { store.allMonthlyReports().sorted { $0.monthKey > $1.monthKey } }
/// 12-month volume series (oldestnewest) for the trends screen.
func monthlyVolumeTrend() -> [(monthKey: String, volume: Double)] {
store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey }.map { ($0.monthKey, $0.summary.volume) }
func monthlyVolumeTrend() -> [MonthVolumePoint] {
store.allMonthlyReports().sorted { $0.monthKey < $1.monthKey }
.map { MonthVolumePoint(monthKey: $0.monthKey, volume: $0.summary.volume) }
}
func monthlyTrendClassification() -> TrendResult {
@@ -151,11 +152,11 @@ final class AnalyticsService: ObservableObject {
}
/// Per-exercise volume history (dateKeyvolume) across stored snapshots.
func exerciseHistory(identity: String) -> [(dateKey: String, volume: Double)] {
func exerciseHistory(identity: String) -> [ExerciseVolumePoint] {
store.allSnapshotKeys().sorted().compactMap { key in
guard let sample = store.snapshot(dateKey: key),
let ex = sample.exercises.first(where: { $0.identity == identity }) else { return nil }
return (key, ex.volume)
return ExerciseVolumePoint(dateKey: key, volume: ex.volume)
}
}

View File

@@ -1608,3 +1608,22 @@ last. Device/build QA tracked here.
- ⚠️ NOT compiled/build-verified (no iOS runtime + app-module/SwiftUI deps).
Time-of-day "user-scheduled" report notification (vs deliver-on-detect) and
historical HealthKit reference are documented follow-ups.
### KC-51 progress — Phase 4b: Analytics UI (NOT build-verified)
- `AnalyticsView` (self-contained): 6 areas — Overview / Daily / Weekly / Monthly
/ 12-Month / Exercises — via a chip selector. Reads `AnalyticsService`.
- Status by BOTH color and glyph (arrow.up.right / arrow.down.right / equal /
minus) — never color alone. Empty + partial ("not enough data") states.
- Swift Charts for 12-month volume (bar) + per-exercise volume (line).
- Informational non-medical disclaimer (`AnalyticsEngine.disclaimer`) footer.
- VoiceOver labels on badges/chips/charts; Wenza tokens (auto light/dark).
- Entry point: an Analytics `IButton` (chart.line.uptrend) in the Workout header
→ presents `AnalyticsView` as a sheet (mirrors the existing history/stats).
- Added Chart-friendly `MonthVolumePoint` / `ExerciseVolumePoint` (tuples can't be
keyed in Charts).
- Self-review caught + fixed pre-emptively (no build here): tuple key-paths in
`Chart(id:)`, accessibility-traits array literal, `%@` format string.
- ⚠️ NOT compiled — SwiftUI + app-module deps; needs an Xcode build. Likely still
has issues only a compiler will surface. Remaining UI polish: deep-link routing
into the presented view, Dynamic-Type audit, denied-permission state for future
HealthKit trends.

View File

@@ -0,0 +1,405 @@
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?
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)
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)
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") }
}
}
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))
}
}

View File

@@ -64,6 +64,7 @@ struct WorkoutView: View {
@State private var showAddSection = false
@State private var showHistory = false
@State private var showStats = false
@State private var showAnalytics = false
@State private var isReordering = false
@State private var trainAnyway = false // override the rest-day state for today
@ObservedObject private var tm = TutorialManager.shared
@@ -103,6 +104,7 @@ struct WorkoutView: View {
.foregroundColor(AppColors.text3(cs))
}
Spacer()
IButton(icon: "chart.line.uptrend.xyaxis") { showAnalytics = true }
IButton(icon: "chart.bar.xaxis") { showStats = true }
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
Menu {
@@ -324,6 +326,11 @@ struct WorkoutView: View {
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showAnalytics) {
AnalyticsView()
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showStats) {
WorkoutStatsView().environmentObject(vm)
.presentationDetents([.large])