// // 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. Each is then filed /// under its `inferredGoal(for:)` group, falling back to Unassigned. 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. A group exists when the /// viewed day has due schedules for that goal, orphan workouts inferred into it /// (see `inferredGoal(for:)`), or both — so an ad hoc strength run surfaces a /// Strength header even on a day with no strength schedule due. private var goalGroups: [(kind: GoalKind, schedules: [Schedule], orphans: [Workout])] { let orphansByGoal = Dictionary(grouping: orphanWorkouts) { inferredGoal(for: $0) } return GoalKind.orderedCases().compactMap { kind in let matches = visibleSchedules.filter { $0.goalKind == kind } let orphans = orphansByGoal[kind] ?? [] return (matches.isEmpty && orphans.isEmpty) ? nil : (kind, matches, orphans) } } /// Schedules with no goal, or a goal raw value this version doesn't know. private var unassignedSchedules: [Schedule] { visibleSchedules.filter { $0.goalKind == nil } } /// Orphan workouts whose routine no schedule anywhere assigns a goal. private var unassignedOrphanWorkouts: [Workout] { orphanWorkouts.filter { inferredGoal(for: $0) == nil } } /// The goal an orphan workout belongs to: the goal of any schedule — due today or /// not — referencing the same routine (through the clone redirect on both sides). /// The workout was "ad hoc" only in how it was launched; the routine's schedule /// still tells us what the user trains it for. Multiple schedules with different /// goals tie-break by the user's goal order. Nil = truly unassigned. private func inferredGoal(for workout: Workout) -> GoalKind? { guard let rid = workout.routineID else { return nil } let liveID = sync.currentRoutineID(for: rid) let goals = schedules .filter { sync.currentRoutineID(for: $0.routineID) == liveID } .compactMap(\.goalKind) let order = GoalKind.orderedCases() return goals.min { (order.firstIndex(of: $0) ?? .max) < (order.firstIndex(of: $1) ?? .max) } } /// 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) } ForEach(group.orphans) { orphanWorkoutRow($0) } } header: { Label(group.kind.displayName, systemImage: group.kind.systemImage) .foregroundStyle(Color.color(from: group.kind.colorName)) } } if !unassignedSchedules.isEmpty || !unassignedOrphanWorkouts.isEmpty { Section("Unassigned") { ForEach(unassignedSchedules) { scheduleRow($0) } ForEach(unassignedOrphanWorkouts) { 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 { 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) } } }