Files
workouts/Workouts/Views/Progress/ProgressTabView.swift
T
rzen 7cb2d6da26 Show only the day's docket on the Today board and add ad hoc Now workouts
- Due-filter schedules: daily always, fixed days by weekday, one-offs on
  their date; rest days get an empty state
- Workouts with no due schedule row (ad hoc or off-day starts) now render
  as their own board rows
- The + sheet is now "New Workout" with a "When" picker; "Now" (offered
  when adding on today) skips the schedule and starts the workout directly
- Remove the times-per-week scheduling mode everywhere (enum, document,
  entity, mappers, planner, seeds, tests)
2026-07-11 11:09:11 -04:00

314 lines
12 KiB
Swift

//
// 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)))
}
}