Files
workouts/Workouts/Views/Today/TodayView.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

399 lines
16 KiB
Swift

//
// TodayView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// The Today board — the today-projection of the user's schedules, grouped by goal.
/// A schedule row shows its routine, its recurrence, and today's status for that
/// routine; tapping starts (or opens) that routine's workout for today.
struct TodayView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
private var schedules: [Schedule]
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
/// ID of the workout to push into — a just-started one (the navigator polls for
/// its cache entity) or an existing today-workout (resolves immediately). One
/// id-based path for both, so no destination ever retains a `@Model`.
@State private var pendingWorkoutID: String?
@State private var scheduleToEdit: Schedule?
@State private var showingAddSchedule = false
@State private var showingDayPicker = false
/// The day the board is projecting — today by default; the calendar toolbar
/// button navigates to any other day.
@State private var selectedDate = Date()
private var isViewingToday: Bool { selectedDate.isSameDay(as: Date()) }
/// The viewed day is strictly before today (day-precision).
private var isViewingPastDay: Bool { selectedDate < Calendar.current.startOfDay(for: Date()) }
/// Schedules due on the viewed day: `.daily` always qualifies; `.fixedDays` only
/// when the viewed day's calendar weekday is one of the schedule's days; `.once`
/// only on its own day. A dateless `.once` (shouldn't happen) shows everywhere
/// rather than nowhere.
private var visibleSchedules: [Schedule] {
schedules.filter { schedule in
switch schedule.recurrenceEnum {
case .daily:
return true
case .fixedDays:
let weekday = Calendar.current.component(.weekday, from: selectedDate)
return (schedule.weekdays ?? []).contains(weekday)
case .once:
guard let date = schedule.date else { return true }
return date.isSameDay(as: selectedDate)
}
}
}
/// Workouts on the viewed day whose routine isn't already shown via a schedule row
/// above — e.g. an ad hoc "Start Now" workout, or a routine whose schedule isn't due
/// today. Matched the same way `todaysWorkout` matches a workout to a schedule: both
/// sides live-resolved through the seed clone-on-edit redirect.
private var orphanWorkouts: [Workout] {
let scheduledRoutineIDs = Set(visibleSchedules.map { sync.currentRoutineID(for: $0.routineID) })
return workouts.filter { workout in
guard workout.start.isSameDay(as: selectedDate) else { return false }
guard let routineID = workout.routineID else { return true }
return !scheduledRoutineIDs.contains(sync.currentRoutineID(for: routineID))
}
}
/// Non-empty goal groups in the user's chosen goal order.
private var goalGroups: [(kind: GoalKind, schedules: [Schedule])] {
GoalKind.orderedCases().compactMap { kind in
let matches = visibleSchedules.filter { $0.goalKind == kind }
return matches.isEmpty ? nil : (kind, matches)
}
}
/// Schedules with no goal, or a goal raw value this version doesn't know.
private var unassignedSchedules: [Schedule] {
visibleSchedules.filter { $0.goalKind == nil }
}
/// Motivational openers for the board title — each reads as a phrase completed
/// by "Today" ("Seize Today"). One per day, rotating by day-of-era: stable all
/// day, fresh the next.
private static let greetings: [String] = [
"Seize",
"Own",
"Win",
"Conquer",
"Show Up",
"Stay the Course",
"Make It Count",
"Do the Work",
"Start Strong",
"No Excuses",
"Begin Again",
"Keep the Streak",
]
private var greetingLine: String {
let day = Calendar.current.ordinality(of: .day, in: .era, for: Date()) ?? 0
return Self.greetings[day % Self.greetings.count]
}
var body: some View {
NavigationStack {
List {
ForEach(goalGroups, id: \.kind) { group in
Section {
ForEach(group.schedules) { scheduleRow($0) }
} header: {
Label(group.kind.displayName, systemImage: group.kind.systemImage)
.foregroundStyle(Color.color(from: group.kind.colorName))
}
}
if !unassignedSchedules.isEmpty || !orphanWorkouts.isEmpty {
Section("Unassigned") {
ForEach(unassignedSchedules) { scheduleRow($0) }
ForEach(orphanWorkouts) { orphanWorkoutRow($0) }
}
}
}
.overlay {
if schedules.isEmpty && orphanWorkouts.isEmpty {
ContentUnavailableView(
"No Schedules Yet",
systemImage: "calendar.badge.plus",
description: Text("Plan your week by adding a schedule for a routine.")
)
} else if visibleSchedules.isEmpty && orphanWorkouts.isEmpty {
ContentUnavailableView(
"Nothing Planned",
systemImage: "calendar",
description: Text("Looks like a rest day. Tap + to plan or start a workout.")
)
}
}
.navigationTitle(isViewingToday ? "\(greetingLine) Today" : selectedDate.formatDate())
.toolbar {
ToolbarItemGroup(placement: .topBarLeading) {
Button {
shiftDay(by: -1)
} label: {
Label("Previous Day", systemImage: "chevron.backward")
}
Button {
showingDayPicker = true
} label: {
Label("Go to Day", systemImage: "calendar")
}
Button {
shiftDay(by: 1)
} label: {
Label("Next Day", systemImage: "chevron.forward")
}
// Quick way home — redundant on today itself, so hidden there.
if !isViewingToday {
Button("Today") {
withAnimation { selectedDate = Date() }
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAddSchedule = true
} label: {
Label("Add Workout", systemImage: "plus")
}
}
}
// Row taps land here for both cases: a fresh start (entity arrives a beat
// after the file write) and an existing today-workout (resolves at once).
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
.safeAreaInset(edge: .bottom) {
SyncStatusBanner()
.padding(.horizontal)
.padding(.bottom, 8)
}
.sheet(isPresented: $showingDayPicker) {
DayPickerSheet(selectedDate: $selectedDate, workoutDays: workoutDays)
}
.sheet(isPresented: $showingAddSchedule) {
// Seed a would-be one-off with the day on screen, so navigating to a
// day and tapping + plans that day. "Now" (offered only on today) hands
// the routine back here to start immediately.
ScheduleAddEditView(defaultDate: selectedDate, onStartNow: startNow)
}
.sheet(item: $scheduleToEdit) { schedule in
ScheduleAddEditView(schedule: schedule)
}
}
}
// MARK: - Row
private func scheduleRow(_ schedule: Schedule) -> some View {
let routine = resolvedRoutine(for: schedule)
let today = todaysWorkout(for: schedule)
return Button {
openOrStart(schedule: schedule, routine: routine, today: today)
} label: {
HStack(spacing: 12) {
Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional")
.font(.title3)
.foregroundStyle(iconColor(routine: routine, schedule: schedule))
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(routine?.name ?? schedule.routineName)
.foregroundStyle(.primary)
Text(schedule.recurrenceSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let today {
todayStatus(for: today)
} else if isViewingPastDay {
skippedTag
}
}
.contentShape(Rectangle())
}
.tint(.primary)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
Task { await sync.delete(schedule: schedule) }
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
scheduleToEdit = schedule
} label: {
Label("Edit", systemImage: "pencil")
}
}
}
/// A workout on the viewed day with no matching schedule row — an ad hoc "Start
/// Now" run, or a routine whose schedule isn't due today. No recurrence caption
/// (there's no schedule behind it), just the routine name and its status.
private func orphanWorkoutRow(_ workout: Workout) -> some View {
let routine = resolvedRoutine(id: workout.routineID)
return Button {
guard !workout.isDeleted else { return }
pendingWorkoutID = workout.id
} label: {
HStack(spacing: 12) {
Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional")
.font(.title3)
.foregroundStyle(routine.map { Color.color(from: $0.color) } ?? .secondary)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(routine?.name ?? workout.routineName ?? Routine.unnamed)
.foregroundStyle(.primary)
Text("Ad Hoc")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
todayStatus(for: workout)
}
.contentShape(Rectangle())
}
.tint(.primary)
}
/// Today's status decoration: a completed run shows a green check + its finish
/// time; an unfinished one shows an orange "In Progress" tag.
@ViewBuilder
private func todayStatus(for workout: Workout) -> some View {
if workout.status == .completed {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text((workout.end ?? workout.start).formattedTime())
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Text("In Progress")
.font(.caption2.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.orange.opacity(0.15), in: Capsule())
}
}
/// Signpost on a past-day schedule row with no logged workout. Deliberately
/// undifferentiated for now — a later pass will distinguish *abandoned* from an
/// explicit skip carrying an excuse ("sick day"); see UX-REDESIGN.md.
private var skippedTag: some View {
Text("Skipped")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.secondary.opacity(0.15), in: Capsule())
}
/// Step the viewed day back or forward (day precision; the time component is
/// irrelevant to the board).
private func shiftDay(by days: Int) {
withAnimation {
selectedDate = Calendar.current.date(byAdding: .day, value: days, to: selectedDate) ?? selectedDate
}
}
// MARK: - Resolution helpers
/// The live routine this schedule points at, following the seed clone-on-edit
/// redirect; nil when the routine has been deleted.
private func resolvedRoutine(for schedule: Schedule) -> Routine? {
resolvedRoutine(id: schedule.routineID)
}
/// The live routine for a routine id, following the seed clone-on-edit redirect;
/// nil when there's no id or the routine has been deleted.
private func resolvedRoutine(id: String?) -> Routine? {
guard let id else { return nil }
let liveID = sync.currentRoutineID(for: id)
return routines.first { $0.id == liveID }
}
/// The most recent workout started on the viewed day for this schedule's routine
/// (comparing through the clone redirect on both sides). `workouts` is
/// start-descending, so the first match is the newest.
private func todaysWorkout(for schedule: Schedule) -> Workout? {
let liveRoutineID = sync.currentRoutineID(for: schedule.routineID)
return workouts.first { workout in
guard let rid = workout.routineID, workout.start.isSameDay(as: selectedDate) else { return false }
return sync.currentRoutineID(for: rid) == liveRoutineID
}
}
/// Day-precision (year/month/day) components of every day with at least one
/// logged workout — the day picker decorates these with a dot. Built as *bare*
/// y/m/d components, not `dateComponents([.year, .month, .day], from:)` output:
/// the latter also sets `isLeapMonth = false`, which `==` ignores but hashing
/// doesn't — so a bare-components `Set.contains` probe (the day picker's) would
/// miss every member.
private var workoutDays: Set<DateComponents> {
Set(workouts.map { workout in
let c = Calendar.current.dateComponents([.year, .month, .day], from: workout.start)
var day = DateComponents()
day.year = c.year
day.month = c.month
day.day = c.day
return day
})
}
/// Row icon color: the routine's own color, or the goal's color as a generic
/// fallback when the routine is gone.
private func iconColor(routine: Routine?, schedule: Schedule) -> Color {
if let routine { return Color.color(from: routine.color) }
return Color.color(from: schedule.goalKind?.colorName ?? "")
}
/// Tap: open the viewed day's workout if one exists, otherwise start a fresh one
/// from the resolved routine — but only when the board is on today, since a start
/// always happens *now*. Does nothing on other days without a workout, or for an
/// orphaned schedule (no live routine).
private func openOrStart(schedule: Schedule, routine: Routine?, today: Workout?) {
if let today {
guard !today.isDeleted else { return }
pendingWorkoutID = today.id
} else if isViewingToday, let routine {
Task {
pendingWorkoutID = await WorkoutStarter.start(routine: routine, sync: sync)
}
}
}
/// The "Now" path from the add sheet: start the chosen routine immediately and
/// navigate in, same as tapping a due schedule row on today's board.
private func startNow(routine: Routine) {
Task {
pendingWorkoutID = await WorkoutStarter.start(routine: routine, sync: sync)
}
}
}