Files
workouts/Workouts/Views/Progress/GoalTrackCard.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

138 lines
5.1 KiB
Swift

//
// GoalTrackCard.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// One goal's adherence card: a 12-week strip (each square filled by that week's
/// met/expected fraction), the weekly streak flame, and a this-week dot row per
/// member schedule. The goal is the ledger category — this track continues unbroken
/// across schedule rewrites.
struct GoalTrackCard: View {
let track: GoalTrack
private var color: Color {
track.goal.map { Color.color(from: $0.colorName) } ?? Color(.systemGray)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label(track.goal?.displayName ?? "Unassigned",
systemImage: track.goal?.systemImage ?? "tag")
.font(.headline)
.foregroundStyle(color)
Spacer()
if track.streakWeeks > 0 {
streakBadge
}
}
weekStrip
VStack(alignment: .leading, spacing: 10) {
ForEach(track.scheduleTracks, id: \.schedule.id) { scheduleTrack in
scheduleRow(scheduleTrack)
}
}
}
.progressCard()
}
/// The streak flame: orange while the current week is still meetable, dimmed the
/// moment it can no longer be (the streak is at stake, not yet erased).
private var streakBadge: some View {
HStack(spacing: 3) {
Image(systemName: "flame.fill")
Text("\(track.streakWeeks) wk")
.monospacedDigit()
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(track.streakAlive ? Color.orange : Color.secondary)
.accessibilityLabel("\(track.streakWeeks)-week streak\(track.streakAlive ? "" : ", at risk")")
}
private var weekStrip: some View {
HStack(spacing: 4) {
ForEach(Array(track.weeks.enumerated()), id: \.offset) { index, week in
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(fill(for: week))
.frame(height: 16)
.overlay {
// Outline the in-flight week so "unfilled" reads as "not over
// yet" rather than "missed".
if index == track.weeks.count - 1 {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.strokeBorder(color.opacity(0.8), lineWidth: 1.5)
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(stripSummary)
}
private func fill(for week: WeekMark) -> Color {
if week.isVacuous { return Color(.systemFill).opacity(0.4) }
if week.fraction == 0 { return Color(.systemFill) }
return color.opacity(0.25 + 0.75 * week.fraction)
}
private var stripSummary: String {
let hit = track.weeks.filter { !$0.isVacuous && $0.hit }.count
let active = track.weeks.filter { !$0.isVacuous }.count
return "Hit \(hit) of the last \(active) weeks"
}
private func scheduleRow(_ scheduleTrack: ScheduleTrack) -> some View {
let schedule = scheduleTrack.schedule
return HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text(schedule.routineName)
.font(.subheadline)
Text(schedule.recurrence.summary(
weekdays: schedule.weekdays,
date: schedule.onceDate))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
thisWeekDots(scheduleTrack)
}
}
/// This week for one schedule: `expected` dots, `met` of them filled. A broken
/// week adds a quiet "missed" tag — informative, not scolding.
@ViewBuilder
private func thisWeekDots(_ scheduleTrack: ScheduleTrack) -> some View {
if let week = scheduleTrack.currentWeek, !week.isVacuous {
HStack(spacing: 5) {
if scheduleTrack.currentWeekBroken {
Text("missed")
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(0..<week.expected, id: \.self) { index in
Circle()
.fill(index < week.met ? color : Color.clear)
.overlay {
if index >= week.met {
Circle().strokeBorder(color.opacity(0.4), lineWidth: 1.5)
}
}
.frame(width: 9, height: 9)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(week.met) of \(week.expected) this week")
} else {
Text("—")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}