// // 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, timesPerWeek: schedule.timesPerWeek, 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.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) } } }