// // ProgressTabView.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import SwiftData /// The Progress tab — observe by goals (UX-REDESIGN.md). Per-goal adherence tracks /// first, then this-week totals, aggregate trends, per-exercise drill-down, the /// achievements grid, and full history (absorbed from the old Log tab). Everything on /// this screen is derived by `ProgressPlanner` from the workout documents; nothing is /// persisted, so it all survives cache rebuilds. struct ProgressTabView: View { /// Screenshot-capture affordance: `.bottom` opens the tab pre-scrolled to the /// end (nil = the normal top). Only the DEBUG screenshot root passes it. var initialAnchor: UnitPoint? @Environment(SyncEngine.self) private var sync @Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)]) private var schedules: [Schedule] @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] var body: some View { NavigationStack { ScrollView { let facts = workoutFacts let tracks = ProgressPlanner.goalTracks( schedules: scheduleFacts, workouts: facts, goalOrder: GoalKind.orderedCases()) VStack(alignment: .leading, spacing: 16) { WeekSummaryTiles(workouts: facts) if tracks.isEmpty { goalTracksHint } else { ForEach(tracks) { GoalTrackCard(track: $0) } } TrendsSection(workouts: facts) exerciseTrendsLink AchievementsSection( achievements: ProgressPlanner.achievements(tracks: tracks, workouts: facts)) historySection } .padding(.horizontal) .padding(.bottom, 24) } .defaultScrollAnchor(initialAnchor) .background(Color(.systemGroupedBackground)) .navigationTitle("Progress") } } // MARK: - Fact building (live-resolve IDs through the seed clone-on-edit redirect) private var scheduleFacts: [ScheduleFacts] { schedules.map { schedule in ScheduleFacts( id: schedule.id, goal: schedule.goalKind, recurrence: schedule.recurrenceEnum, weekdays: schedule.weekdays, onceDate: schedule.date, createdAt: schedule.createdAt, routineID: sync.currentRoutineID(for: schedule.routineID), routineName: schedule.routineName) } } private var workoutFacts: [WorkoutFacts] { workouts.map { workout in WorkoutFacts( routineID: workout.routineID.map { sync.currentRoutineID(for: $0) }, start: workout.start, end: workout.end, completed: workout.status == .completed, volume: Self.volume(of: workout), activeMinutes: workout.end.map { max(0, $0.timeIntervalSince(workout.start) / 60) } ?? 0, energyKcal: workout.metricActiveEnergyKcal ?? 0) } } /// Total lifted volume for one workout: the recorded watch metric when present, /// else derived from per-set actuals, else the plan (the same fallback ladder the /// documents' `effectiveSetEntries` uses). static func volume(of workout: Workout) -> Double { if let recorded = workout.metricTotalVolume { return recorded } return workout.logs.reduce(0) { sum, log in guard log.status == .completed, log.loadTypeEnum == .weight else { return sum } if let entries = log.setEntries, !entries.isEmpty { return sum + entries.reduce(0) { $0 + Double($1.reps ?? 0) * ($1.weight ?? 0) } } return sum + Double(log.sets * log.reps) * log.weight } } // MARK: - Sections private var goalTracksHint: some View { VStack(alignment: .leading, spacing: 6) { Label("No Goal Tracks Yet", systemImage: "chart.bar.fill") .font(.headline) Text("Add schedules on the Today tab and each goal builds a weekly adherence track here.") .font(.subheadline) .foregroundStyle(.secondary) } .progressCard() } private var exerciseTrendsLink: some View { NavigationLink { ExerciseTrendsListView() } label: { HStack(spacing: 12) { Image(systemName: "chart.line.uptrend.xyaxis") .font(.title3) .foregroundStyle(.blue) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text("Exercise Trends") .font(.headline) .foregroundStyle(.primary) Text("Weight progression for every exercise you've logged") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) } .progressCard() } .buttonStyle(.plain) } private var historySection: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("History") .font(.headline) Spacer() NavigationLink("All Workouts") { WorkoutHistoryView() } .font(.subheadline) } if workouts.isEmpty { Text("No workouts yet. Start one from the Today tab.") .font(.subheadline) .foregroundStyle(.secondary) } else { ForEach(workouts.prefix(3)) { workout in NavigationLink { WorkoutLogListView(workout: workout) } label: { HStack { CalendarListItem( date: workout.start, title: workout.routineName ?? Routine.unnamed, subtitle: subtitle(for: workout), subtitle2: workout.statusName) Spacer() Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) } .contentShape(Rectangle()) } .buttonStyle(.plain) } } } .progressCard() } private func subtitle(for workout: Workout) -> String { if workout.status == .completed, let endDate = workout.end { return workout.start.humanTimeInterval(to: endDate) } return workout.start.formattedDate() } } // MARK: - Shared card chrome extension View { /// The Progress tab's card treatment — one shared background so every section /// reads as part of the same system. func progressCard() -> some View { padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background( Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } } // MARK: - This-week stat tiles /// Three at-a-glance totals for the current calendar week, each with a delta arrow /// against last week. struct WeekSummaryTiles: View { let workouts: [WorkoutFacts] @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb var body: some View { let calendar = Calendar.current let now = Date() let this = totals(in: calendar.dateInterval(of: .weekOfYear, for: now)) let last = totals(in: calendar.date(byAdding: .weekOfYear, value: -1, to: now) .flatMap { calendar.dateInterval(of: .weekOfYear, for: $0) }) HStack(spacing: 12) { ProgressStatTile( title: "This Week", value: "\(this.workouts)", unit: this.workouts == 1 ? "workout" : "workouts", delta: Double(this.workouts - last.workouts), deltaText: "\(abs(this.workouts - last.workouts))") ProgressStatTile( title: "Volume", value: this.volume.formatted(.number.notation(.compactName).precision(.significantDigits(3))), unit: weightUnit.abbreviation, delta: this.volume - last.volume, deltaText: abs(this.volume - last.volume) .formatted(.number.notation(.compactName).precision(.significantDigits(2)))) ProgressStatTile( title: "Active Time", value: Self.hoursMinutes(this.minutes), unit: nil, delta: this.minutes - last.minutes, deltaText: Self.hoursMinutes(abs(this.minutes - last.minutes))) } } private func totals(in interval: DateInterval?) -> (workouts: Int, volume: Double, minutes: Double) { guard let interval else { return (0, 0, 0) } let matching = workouts.filter { $0.completed && interval.contains($0.start) } return (matching.count, matching.reduce(0) { $0 + $1.volume }, matching.reduce(0) { $0 + $1.activeMinutes }) } static func hoursMinutes(_ minutes: Double) -> String { let total = Int(minutes.rounded()) let (h, m) = (total / 60, total % 60) return h > 0 ? "\(h)h \(m)m" : "\(m)m" } } /// One compact stat tile: caption, big value, and a vs-last-week arrow. Shared by the /// week summary row and the exercise drill-down. struct ProgressStatTile: View { let title: String let value: String var unit: String? /// Signed change vs the comparison period; only its sign is displayed. var delta: Double? /// Preformatted magnitude for the delta line (callers know their unit best). var deltaText: String? var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.caption) .foregroundStyle(.secondary) HStack(alignment: .firstTextBaseline, spacing: 3) { Text(value) .font(.title3.weight(.semibold)) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.7) if let unit { Text(unit) .font(.caption) .foregroundStyle(.secondary) } } if let delta, delta != 0 { Label("\(deltaText ?? fallbackDeltaText(delta)) vs last wk", systemImage: delta > 0 ? "arrow.up" : "arrow.down") .font(.caption2.weight(.medium)) .foregroundStyle(delta > 0 ? .green : .secondary) } else { // Keep tile heights equal whether or not a delta renders. Text("—") .font(.caption2) .foregroundStyle(.tertiary) } } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) } private func fallbackDeltaText(_ delta: Double) -> String { abs(delta).formatted(.number.notation(.compactName).precision(.significantDigits(2))) } }