- 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)
550 lines
22 KiB
Swift
550 lines
22 KiB
Swift
//
|
||
// ProgressPlanner.swift
|
||
// Workouts
|
||
//
|
||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||
//
|
||
|
||
import Foundation
|
||
|
||
// The Progress tab's derivation engine — a pure, unit-testable planner (precedent:
|
||
// `SeedReconcilePlanner`). Everything here is a deterministic function of plain value
|
||
// snapshots: per-schedule weekly adherence, per-goal rollups with streaks, trend
|
||
// buckets for the charts, and the achievements grid. Nothing is ever persisted —
|
||
// streaks and badges are recomputed from workout documents on every read, so they
|
||
// survive cache rebuilds by construction (see UX-REDESIGN.md).
|
||
|
||
// MARK: - Inputs
|
||
|
||
/// A plain snapshot of a `Schedule` cache entity. `routineID` must already be
|
||
/// live-resolved through `SyncEngine.currentRoutineID(for:)` so seed clone-on-edit
|
||
/// redirects don't orphan history.
|
||
struct ScheduleFacts: Sendable, Equatable {
|
||
var id: String
|
||
var goal: GoalKind?
|
||
var recurrence: ScheduleRecurrence
|
||
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only
|
||
var onceDate: Date? // .once only
|
||
var createdAt: Date
|
||
var routineID: String // live-resolved
|
||
var routineName: String
|
||
}
|
||
|
||
/// A plain snapshot of a `Workout` cache entity. `routineID` live-resolved like
|
||
/// `ScheduleFacts.routineID`. `volume` is the caller's best value (recorded metric,
|
||
/// else derived from set entries); `activeMinutes` is the completed duration.
|
||
struct WorkoutFacts: Sendable, Equatable {
|
||
var routineID: String?
|
||
var start: Date
|
||
var end: Date?
|
||
var completed: Bool
|
||
var volume: Double
|
||
var activeMinutes: Double
|
||
var energyKcal: Double
|
||
}
|
||
|
||
// MARK: - Adherence outputs
|
||
|
||
/// One schedule-week (or goal-week) verdict: how many required units were met.
|
||
/// `expected == 0` marks a *vacuous* week — the schedule imposed nothing (created
|
||
/// mid-week past its fixed days, a one-off in another week); vacuous weeks neither
|
||
/// break nor extend streaks.
|
||
struct WeekMark: Sendable, Equatable {
|
||
var weekStart: Date
|
||
var met: Int
|
||
var expected: Int
|
||
|
||
var hit: Bool { met >= expected }
|
||
var isVacuous: Bool { expected == 0 }
|
||
|
||
/// Fill fraction for the week-strip squares (0…1).
|
||
var fraction: Double {
|
||
expected > 0 ? min(1, Double(met) / Double(expected)) : 0
|
||
}
|
||
}
|
||
|
||
/// One schedule's adherence across the track's week grid. `weeks` aligns 1:1 with the
|
||
/// grid (oldest → newest); `nil` = the schedule didn't exist / doesn't apply that week.
|
||
struct ScheduleTrack: Sendable, Equatable {
|
||
var schedule: ScheduleFacts
|
||
var weeks: [WeekMark?]
|
||
/// The current week can no longer be met (a daily's missed day, or too few days
|
||
/// left to cover a fixed day already missed). Distinct from "not yet hit".
|
||
var currentWeekBroken: Bool
|
||
|
||
var currentWeek: WeekMark? { weeks.last ?? nil }
|
||
}
|
||
|
||
/// A goal's rolled-up adherence: per-week sums across member schedules, plus the
|
||
/// streak. The goal is the ledger category — its track continues unbroken across
|
||
/// schedule rewrites (UX-REDESIGN.md).
|
||
struct GoalTrack: Sendable, Equatable, Identifiable {
|
||
var goal: GoalKind? // nil = the "Unassigned" group
|
||
var scheduleTracks: [ScheduleTrack]
|
||
var weeks: [WeekMark] // rollup, one per grid week
|
||
/// Consecutive fully-hit weeks ending now. The current week counts once hit;
|
||
/// merely "not yet hit but still possible" neither counts nor breaks.
|
||
var streakWeeks: Int
|
||
/// False when any member schedule's current week is already unmeetable.
|
||
var streakAlive: Bool
|
||
|
||
var id: String { goal?.rawValue ?? "unassigned" }
|
||
}
|
||
|
||
// MARK: - Trend outputs
|
||
|
||
/// Chart time windows, per the trend-visualization pattern (7D/30D/90D/1Y/All).
|
||
enum TrendRange: String, CaseIterable, Identifiable, Sendable {
|
||
case week = "7D"
|
||
case month = "30D"
|
||
case quarter = "90D"
|
||
case year = "1Y"
|
||
case all = "All"
|
||
|
||
var id: String { rawValue }
|
||
|
||
func startDate(now: Date, calendar: Calendar) -> Date? {
|
||
switch self {
|
||
case .week: calendar.date(byAdding: .day, value: -7, to: now)
|
||
case .month: calendar.date(byAdding: .day, value: -30, to: now)
|
||
case .quarter: calendar.date(byAdding: .day, value: -90, to: now)
|
||
case .year: calendar.date(byAdding: .year, value: -1, to: now)
|
||
case .all: nil
|
||
}
|
||
}
|
||
|
||
/// Bucket granularity: short ranges read daily, a quarter weekly, longer monthly.
|
||
var bucket: Calendar.Component {
|
||
switch self {
|
||
case .week, .month: .day
|
||
case .quarter: .weekOfYear
|
||
case .year, .all: .month
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One chart bucket (a day, week, or month depending on the range).
|
||
struct TrendPoint: Sendable, Equatable, Identifiable {
|
||
var date: Date // bucket start
|
||
var workouts: Int
|
||
var volume: Double
|
||
var activeMinutes: Double
|
||
var energyKcal: Double
|
||
|
||
var id: Date { date }
|
||
}
|
||
|
||
// MARK: - Achievements
|
||
|
||
/// A derived badge. `progress` is 0…1 toward earning (1 when earned) so locked badges
|
||
/// can show how close they are.
|
||
struct Achievement: Sendable, Equatable, Identifiable {
|
||
var id: String
|
||
var title: String
|
||
var detail: String
|
||
var systemImage: String
|
||
var colorName: String
|
||
var earned: Bool
|
||
var progress: Double
|
||
}
|
||
|
||
// MARK: - Planner
|
||
|
||
enum ProgressPlanner {
|
||
|
||
// MARK: Goal tracks
|
||
|
||
/// Roll schedules + workouts up into per-goal adherence tracks over the last
|
||
/// `weeksBack` calendar weeks (oldest → newest, ending at the current week).
|
||
/// Groups appear in `goalOrder`; schedules keep their input order within a group;
|
||
/// goal-less schedules land in a trailing `goal == nil` group. Goals with no
|
||
/// schedules produce no track.
|
||
static func goalTracks(
|
||
schedules: [ScheduleFacts],
|
||
workouts: [WorkoutFacts],
|
||
weeksBack: Int = 12,
|
||
goalOrder: [GoalKind] = GoalKind.allCases,
|
||
calendar: Calendar = .current,
|
||
now: Date = Date()
|
||
) -> [GoalTrack] {
|
||
let doneDays = completedDays(workouts: workouts, calendar: calendar)
|
||
let grid = weekGrid(weeksBack: weeksBack, calendar: calendar, now: now)
|
||
|
||
var groups: [(GoalKind?, [ScheduleFacts])] = goalOrder.map { kind in
|
||
(kind, schedules.filter { $0.goal == kind })
|
||
}
|
||
groups.append((nil, schedules.filter { $0.goal == nil }))
|
||
|
||
return groups.compactMap { goal, members in
|
||
guard !members.isEmpty else { return nil }
|
||
let tracks = members.map { schedule in
|
||
scheduleTrack(schedule: schedule, doneDays: doneDays[schedule.routineID] ?? [],
|
||
grid: grid, calendar: calendar, now: now)
|
||
}
|
||
let rollup = grid.enumerated().map { index, weekStart in
|
||
let marks = tracks.compactMap { $0.weeks[index] }
|
||
return WeekMark(weekStart: weekStart,
|
||
met: marks.reduce(0) { $0 + $1.met },
|
||
expected: marks.reduce(0) { $0 + $1.expected })
|
||
}
|
||
let alive = !tracks.contains { $0.currentWeekBroken }
|
||
return GoalTrack(goal: goal,
|
||
scheduleTracks: tracks,
|
||
weeks: rollup,
|
||
streakWeeks: streak(weeks: rollup, alive: alive),
|
||
streakAlive: alive)
|
||
}
|
||
}
|
||
|
||
/// One schedule's marks across the grid plus its current-week brokenness.
|
||
static func scheduleTrack(
|
||
schedule: ScheduleFacts,
|
||
doneDays: Set<Date>,
|
||
grid: [Date],
|
||
calendar: Calendar,
|
||
now: Date
|
||
) -> ScheduleTrack {
|
||
let weeks = grid.map { weekStart in
|
||
weekMark(schedule: schedule, doneDays: doneDays, weekStart: weekStart,
|
||
calendar: calendar, now: now)
|
||
}
|
||
let broken = grid.last.map { currentWeekStart in
|
||
currentWeekBroken(schedule: schedule, doneDays: doneDays,
|
||
weekStart: currentWeekStart, calendar: calendar, now: now)
|
||
} ?? false
|
||
return ScheduleTrack(schedule: schedule, weeks: weeks, currentWeekBroken: broken)
|
||
}
|
||
|
||
/// The verdict for one schedule-week, or nil when the schedule doesn't apply
|
||
/// (created after the week, a one-off in a different week, a future week).
|
||
///
|
||
/// Expectation semantics differ by recurrence so the *current* week reads as
|
||
/// "on track so far" rather than "not done yet":
|
||
/// - `.daily` / `.fixedDays`: expected counts only required days **up to today**;
|
||
/// met counts distinct completed days in that same window (so a Tuesday make-up
|
||
/// for a missed fixed Monday still counts — catch-ups are honored).
|
||
/// - `.once`: expected 1, only in the week containing its date.
|
||
static func weekMark(
|
||
schedule: ScheduleFacts,
|
||
doneDays: Set<Date>,
|
||
weekStart: Date,
|
||
calendar: Calendar,
|
||
now: Date
|
||
) -> WeekMark? {
|
||
guard let window = window(for: schedule, weekStart: weekStart,
|
||
calendar: calendar, clampToDay: calendar.startOfDay(for: now))
|
||
else { return nil }
|
||
|
||
let done = window.elapsedDays.filter(doneDays.contains).count
|
||
|
||
switch schedule.recurrence {
|
||
case .daily:
|
||
return WeekMark(weekStart: weekStart, met: done, expected: window.elapsedDays.count)
|
||
case .fixedDays:
|
||
let wanted = Set(schedule.weekdays ?? [])
|
||
let required = window.elapsedDays
|
||
.filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
|
||
return WeekMark(weekStart: weekStart, met: min(done, required), expected: required)
|
||
case .once:
|
||
guard let once = schedule.onceDate,
|
||
calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart
|
||
else { return nil }
|
||
return WeekMark(weekStart: weekStart, met: min(done, 1), expected: 1)
|
||
}
|
||
}
|
||
|
||
/// True when the current week can no longer be met: the days remaining (today
|
||
/// included) are fewer than the requirement still outstanding. A daily's missed
|
||
/// yesterday breaks immediately; fixed days with catch-up days still left don't.
|
||
static func currentWeekBroken(
|
||
schedule: ScheduleFacts,
|
||
doneDays: Set<Date>,
|
||
weekStart: Date,
|
||
calendar: Calendar,
|
||
now: Date
|
||
) -> Bool {
|
||
let today = calendar.startOfDay(for: now)
|
||
guard let window = window(for: schedule, weekStart: weekStart,
|
||
calendar: calendar, clampToDay: today)
|
||
else { return false }
|
||
|
||
let done = window.fullDays.filter(doneDays.contains).count
|
||
let daysLeft = window.fullDays.filter { $0 >= today }.count
|
||
|
||
let fullRequirement: Int
|
||
switch schedule.recurrence {
|
||
case .daily:
|
||
fullRequirement = window.fullDays.count
|
||
case .fixedDays:
|
||
let wanted = Set(schedule.weekdays ?? [])
|
||
fullRequirement = window.fullDays
|
||
.filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
|
||
case .once:
|
||
guard let once = schedule.onceDate,
|
||
calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart
|
||
else { return false }
|
||
fullRequirement = 1
|
||
}
|
||
return fullRequirement - done > daysLeft
|
||
}
|
||
|
||
// MARK: Trends
|
||
|
||
/// Completed workouts bucketed for the chart: daily for 7D/30D, weekly for 90D,
|
||
/// monthly for 1Y/All. Empty buckets are zero-filled from the range start (All:
|
||
/// from the first workout) through now, so gaps read as gaps rather than the line
|
||
/// skipping them. Empty input (or an empty window) returns [].
|
||
static func trendPoints(
|
||
workouts: [WorkoutFacts],
|
||
range: TrendRange,
|
||
calendar: Calendar = .current,
|
||
now: Date = Date()
|
||
) -> [TrendPoint] {
|
||
let cutoff = range.startDate(now: now, calendar: calendar)
|
||
let completed = workouts.filter { workout in
|
||
workout.completed && (cutoff.map { workout.start >= $0 } ?? true)
|
||
}
|
||
|
||
func bucketStart(_ date: Date) -> Date {
|
||
calendar.dateInterval(of: range.bucket, for: date)?.start ?? date
|
||
}
|
||
|
||
var byBucket: [Date: TrendPoint] = [:]
|
||
for workout in completed {
|
||
let key = bucketStart(workout.start)
|
||
var point = byBucket[key] ?? TrendPoint(date: key, workouts: 0, volume: 0,
|
||
activeMinutes: 0, energyKcal: 0)
|
||
point.workouts += 1
|
||
point.volume += workout.volume
|
||
point.activeMinutes += workout.activeMinutes
|
||
point.energyKcal += workout.energyKcal
|
||
byBucket[key] = point
|
||
}
|
||
|
||
// Zero-fill from the window start so every bucket exists.
|
||
let firstDate = cutoff ?? completed.map(\.start).min()
|
||
guard let firstDate else { return [] }
|
||
var points: [TrendPoint] = []
|
||
var cursor = bucketStart(firstDate)
|
||
let last = bucketStart(now)
|
||
while cursor <= last {
|
||
points.append(byBucket[cursor]
|
||
?? TrendPoint(date: cursor, workouts: 0, volume: 0, activeMinutes: 0, energyKcal: 0))
|
||
guard let next = calendar.date(byAdding: range.bucket, value: 1, to: cursor),
|
||
next > cursor else { break }
|
||
cursor = next
|
||
}
|
||
return points
|
||
}
|
||
|
||
// MARK: Achievements
|
||
|
||
/// The full badge set, earned and locked alike — every one a pure derivation of
|
||
/// history (recomputable after any cache rebuild). Streak badges read the goal
|
||
/// tracks' best run, so their reach is capped by the tracks' `weeksBack`.
|
||
static func achievements(
|
||
tracks: [GoalTrack],
|
||
workouts: [WorkoutFacts],
|
||
calendar: Calendar = .current,
|
||
now: Date = Date()
|
||
) -> [Achievement] {
|
||
let completed = workouts.filter(\.completed)
|
||
let count = completed.count
|
||
let totalVolume = completed.reduce(0) { $0 + $1.volume }
|
||
|
||
var badges: [Achievement] = []
|
||
|
||
// Session-count milestones.
|
||
let countMilestones: [(Int, String, String)] = [
|
||
(1, "First Rep", "figure.walk"),
|
||
(10, "Ten Strong", "10.circle.fill"),
|
||
(50, "Fifty Club", "50.circle.fill"),
|
||
(100, "Century", "medal.fill"),
|
||
(250, "Relentless", "flame.circle.fill"),
|
||
]
|
||
for (target, title, symbol) in countMilestones {
|
||
badges.append(Achievement(
|
||
id: "count-\(target)", title: title,
|
||
detail: "Complete \(target) workout\(target == 1 ? "" : "s")",
|
||
systemImage: symbol, colorName: "blue",
|
||
earned: count >= target,
|
||
progress: min(1, Double(count) / Double(target))))
|
||
}
|
||
|
||
// Weekly-streak milestones — best consecutive-hit run across any goal track.
|
||
let bestRun = tracks.map { bestHitRun(weeks: $0.weeks) }.max() ?? 0
|
||
let streakMilestones: [(Int, String)] = [
|
||
(4, "4-Week Rhythm"), (8, "8-Week Habit"), (12, "12-Week Lifestyle"),
|
||
]
|
||
for (target, title) in streakMilestones {
|
||
badges.append(Achievement(
|
||
id: "streak-\(target)", title: title,
|
||
detail: "Hit a goal every week for \(target) weeks straight",
|
||
systemImage: "flame.fill", colorName: "orange",
|
||
earned: bestRun >= target,
|
||
progress: min(1, Double(bestRun) / Double(target))))
|
||
}
|
||
|
||
// Consecutive active days.
|
||
let dayRun = longestDayRun(workouts: completed, calendar: calendar)
|
||
badges.append(Achievement(
|
||
id: "days-7", title: "Seven Straight",
|
||
detail: "Work out 7 days in a row",
|
||
systemImage: "calendar", colorName: "green",
|
||
earned: dayRun >= 7,
|
||
progress: min(1, Double(dayRun) / 7)))
|
||
|
||
// Lifetime volume.
|
||
let volumeMilestones: [(Double, String, String)] = [
|
||
(100_000, "Heavy Lifter", "scalemass.fill"),
|
||
(1_000_000, "Million Mover", "trophy.fill"),
|
||
]
|
||
for (target, title, symbol) in volumeMilestones {
|
||
badges.append(Achievement(
|
||
id: "volume-\(Int(target))", title: title,
|
||
detail: "Lift \(Int(target).formatted()) total volume",
|
||
systemImage: symbol, colorName: "purple",
|
||
earned: totalVolume >= target,
|
||
progress: min(1, totalVolume / target)))
|
||
}
|
||
|
||
// Time-of-day one-offs.
|
||
let hours = completed.map { calendar.component(.hour, from: $0.start) }
|
||
badges.append(Achievement(
|
||
id: "early-bird", title: "Early Bird",
|
||
detail: "Start a workout before 7 AM",
|
||
systemImage: "sunrise.fill", colorName: "yellow",
|
||
earned: hours.contains { $0 < 7 },
|
||
progress: hours.contains { $0 < 7 } ? 1 : 0))
|
||
badges.append(Achievement(
|
||
id: "night-owl", title: "Night Owl",
|
||
detail: "Start a workout after 9 PM",
|
||
systemImage: "moon.stars.fill", colorName: "indigo",
|
||
earned: hours.contains { $0 >= 21 },
|
||
progress: hours.contains { $0 >= 21 } ? 1 : 0))
|
||
|
||
// Clean sweep: some week in which every goal's requirement was fully hit.
|
||
let sweep = cleanSweepExists(tracks: tracks)
|
||
badges.append(Achievement(
|
||
id: "clean-sweep", title: "Clean Sweep",
|
||
detail: "Hit every goal in a single week",
|
||
systemImage: "checkmark.seal.fill", colorName: "teal",
|
||
earned: sweep, progress: sweep ? 1 : 0))
|
||
|
||
return badges
|
||
}
|
||
|
||
// MARK: - Shared helpers
|
||
|
||
/// Start-of-day set of every day with a completed workout, keyed by routine ID.
|
||
static func completedDays(workouts: [WorkoutFacts], calendar: Calendar) -> [String: Set<Date>] {
|
||
var result: [String: Set<Date>] = [:]
|
||
for workout in workouts where workout.completed {
|
||
guard let routineID = workout.routineID else { continue }
|
||
result[routineID, default: []].insert(calendar.startOfDay(for: workout.start))
|
||
}
|
||
return result
|
||
}
|
||
|
||
/// Week starts oldest → newest, ending at the week containing `now`.
|
||
static func weekGrid(weeksBack: Int, calendar: Calendar, now: Date) -> [Date] {
|
||
guard weeksBack > 0,
|
||
let currentStart = calendar.dateInterval(of: .weekOfYear, for: now)?.start
|
||
else { return [] }
|
||
return (0..<weeksBack).reversed().compactMap {
|
||
calendar.date(byAdding: .weekOfYear, value: -$0, to: currentStart)
|
||
}
|
||
}
|
||
|
||
/// The days of one schedule-week: `fullDays` is the whole week clipped to the
|
||
/// schedule's creation day; `elapsedDays` additionally clips to `clampToDay`
|
||
/// (today), for "so far" expectations. Nil when the schedule postdates the week.
|
||
private static func window(
|
||
for schedule: ScheduleFacts,
|
||
weekStart: Date,
|
||
calendar: Calendar,
|
||
clampToDay: Date
|
||
) -> (fullDays: [Date], elapsedDays: [Date])? {
|
||
guard let weekEnd = calendar.date(byAdding: .weekOfYear, value: 1, to: weekStart)
|
||
else { return nil }
|
||
let createdDay = calendar.startOfDay(for: schedule.createdAt)
|
||
let start = max(weekStart, createdDay)
|
||
guard start < weekEnd, start <= clampToDay else { return nil }
|
||
|
||
var fullDays: [Date] = []
|
||
var cursor = start
|
||
while cursor < weekEnd {
|
||
fullDays.append(cursor)
|
||
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor), next > cursor
|
||
else { break }
|
||
cursor = next
|
||
}
|
||
let elapsedDays = fullDays.filter { $0 <= clampToDay }
|
||
return (fullDays, elapsedDays)
|
||
}
|
||
|
||
/// Streak ending now: consecutive hit weeks scanned newest → oldest. A vacuous
|
||
/// week is transparent (skipped); the current week counts once hit, is skipped
|
||
/// while still meetable, and zeroes the streak when already broken.
|
||
static func streak(weeks: [WeekMark], alive: Bool) -> Int {
|
||
guard !weeks.isEmpty else { return 0 }
|
||
var remaining = weeks
|
||
let current = remaining.removeLast()
|
||
|
||
var count = 0
|
||
if !current.isVacuous {
|
||
if current.hit { count += 1 }
|
||
else if !alive { return 0 }
|
||
// else: still in play — neither counts nor breaks.
|
||
}
|
||
for week in remaining.reversed() {
|
||
if week.isVacuous { continue }
|
||
if week.hit { count += 1 } else { break }
|
||
}
|
||
return count
|
||
}
|
||
|
||
/// Longest run of consecutive hit weeks anywhere in the track (vacuous weeks are
|
||
/// transparent here too).
|
||
static func bestHitRun(weeks: [WeekMark]) -> Int {
|
||
var best = 0, run = 0
|
||
for week in weeks {
|
||
if week.isVacuous { continue }
|
||
if week.hit { run += 1; best = max(best, run) } else { run = 0 }
|
||
}
|
||
return best
|
||
}
|
||
|
||
/// Longest run of consecutive calendar days with at least one completed workout.
|
||
static func longestDayRun(workouts: [WorkoutFacts], calendar: Calendar) -> Int {
|
||
let days = Set(workouts.filter(\.completed).map { calendar.startOfDay(for: $0.start) })
|
||
.sorted()
|
||
var best = 0, run = 0
|
||
var previous: Date?
|
||
for day in days {
|
||
if let previous,
|
||
let next = calendar.date(byAdding: .day, value: 1, to: previous),
|
||
calendar.isDate(next, inSameDayAs: day) {
|
||
run += 1
|
||
} else {
|
||
run = 1
|
||
}
|
||
best = max(best, run)
|
||
previous = day
|
||
}
|
||
return best
|
||
}
|
||
|
||
/// True when some grid week saw every goal track's requirement fully hit —
|
||
/// non-vacuously for at least two goals (a single-goal "sweep" is just a hit week).
|
||
static func cleanSweepExists(tracks: [GoalTrack]) -> Bool {
|
||
guard let weekCount = tracks.first?.weeks.count, !tracks.isEmpty else { return false }
|
||
for index in 0..<weekCount {
|
||
let marks = tracks.compactMap { $0.weeks.indices.contains(index) ? $0.weeks[index] : nil }
|
||
let active = marks.filter { !$0.isVacuous }
|
||
if active.count >= 2 && marks.allSatisfy(\.hit) { return true }
|
||
}
|
||
return false
|
||
}
|
||
}
|