- 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)
182 lines
9.7 KiB
Swift
182 lines
9.7 KiB
Swift
#if DEBUG
|
||
import IndieSync
|
||
import Foundation
|
||
import SwiftData
|
||
|
||
/// DEBUG-only sample data + launch-arg plumbing for App Store screenshot capture.
|
||
/// Activated by launching with `--screenshot`; `--screen <name>` picks which screen
|
||
/// the app renders (see each target's screenshot root). Never compiled into release.
|
||
enum ScreenshotSeed {
|
||
static var isActive: Bool { CommandLine.arguments.contains("--screenshot") }
|
||
|
||
static func screen(default def: String) -> String {
|
||
let args = CommandLine.arguments
|
||
if let i = args.firstIndex(of: "--screen"), i + 1 < args.count { return args[i + 1] }
|
||
return def
|
||
}
|
||
|
||
/// Insert a believable in-progress workout, a few finished sessions (so charts have
|
||
/// a trend), and the starter routines. Returns the in-progress workout to display.
|
||
@MainActor
|
||
@discardableResult
|
||
static func populate(_ context: ModelContext) -> Workout? {
|
||
// Idempotent: clear any prior seed so repeated launches don't stack duplicates
|
||
// (cascade deletes take the child exercises/logs with them).
|
||
try? context.delete(model: Workout.self)
|
||
try? context.delete(model: Routine.self)
|
||
try? context.delete(model: Schedule.self)
|
||
|
||
let cal = Calendar(identifier: .gregorian)
|
||
let today = Date()
|
||
func daysAgo(_ n: Int) -> Date { cal.date(byAdding: .day, value: -n, to: today) ?? today }
|
||
|
||
// ---- Routines (with exercises) ------------------------------------------
|
||
// Names match the bundled Exercise Library rigs so every screen shows its
|
||
// animated figure (an unknown name would leave the figure slot empty).
|
||
let routines: [(String, String, String, [(String, Int, Int, Int)])] = [
|
||
("Upper Body", "blue", "figure.strengthtraining.traditional", [
|
||
("Lat Pull Down", 4, 10, 110), ("Seated Row", 4, 10, 90),
|
||
("Shoulder Press", 4, 10, 40), ("Arm Curl", 3, 12, 40),
|
||
]),
|
||
("Lower Body", "green", "figure.run", [
|
||
("Leg Press", 4, 10, 140), ("Leg Curl", 4, 10, 40),
|
||
("Leg Extension", 4, 10, 80), ("Calfs", 4, 15, 100),
|
||
]),
|
||
("Core", "orange", "figure.core.training", [
|
||
("Plank", 3, 0, 0), ("Abdominal", 4, 10, 40),
|
||
("Rotary", 4, 10, 40),
|
||
]),
|
||
]
|
||
|
||
var created: [Routine] = []
|
||
for (sIndex, s) in routines.enumerated() {
|
||
let routine = Routine(id: ULID.make(), name: s.0, color: s.1, systemImage: s.2,
|
||
order: sIndex, createdAt: today, updatedAt: today, jsonRelativePath: "")
|
||
context.insert(routine)
|
||
created.append(routine)
|
||
for (eIndex, e) in s.3.enumerated() {
|
||
let isDuration = e.0 == "Plank"
|
||
let ex = Exercise(id: ULID.make(), name: e.0, order: eIndex, sets: e.1, reps: e.2,
|
||
weight: Double(e.3), loadType: (isDuration ? LoadType.duration : .weight).rawValue,
|
||
durationTotalSeconds: isDuration ? 45 : 0)
|
||
ex.routine = routine
|
||
context.insert(ex)
|
||
}
|
||
}
|
||
|
||
// ---- Schedules (the Today board's rows, spanning two goal groups) -------
|
||
let schedulePlans: [(routine: Routine, goal: GoalKind, recurrence: ScheduleRecurrence,
|
||
weekdays: [Int]?)] = [
|
||
(created[0], .strength, .fixedDays, [2, 5]), // Upper Body · Mon & Thu
|
||
(created[1], .strength, .fixedDays, [3, 7]), // Lower Body · Tue & Sat
|
||
(created[2], .mobility, .daily, nil), // Core · daily
|
||
]
|
||
// Schedules predate the history below, so the Progress tab's adherence
|
||
// tracks cover the seeded weeks instead of starting "today".
|
||
let scheduledSince = daysAgo(70)
|
||
for (i, plan) in schedulePlans.enumerated() {
|
||
context.insert(Schedule(
|
||
id: ULID.make(), routineID: plan.routine.id, routineName: plan.routine.name,
|
||
goalRaw: plan.goal.rawValue, recurrenceRaw: plan.recurrence.rawValue,
|
||
weekdays: plan.weekdays, order: i,
|
||
createdAt: scheduledSince, updatedAt: scheduledSince, jsonRelativePath: ""))
|
||
}
|
||
|
||
let upper = routines[0]
|
||
let upperID = created[0].id
|
||
|
||
// ---- Past finished sessions ---------------------------------------------
|
||
// Ten weeks of believable history across all three routines so Progress has
|
||
// living goal tracks, streaks, trends, and history: Upper Body lands on its
|
||
// Mon & Thu slots (one missed), Lower Body twice a week (one missed), Core
|
||
// most mornings — unbroken in the last four weeks, so the Mobility streak is
|
||
// alive. Upper's Lat Pull Down ramps toward 110 for the progression chart.
|
||
// All deterministic — patterns, not randomness.
|
||
func addCompleted(routine: Routine, spec: [(String, Int, Int, Int)],
|
||
on day: Date, hour: Int, lengthMinutes: Int, latPullDown: Double?) {
|
||
let start = cal.date(bySettingHour: hour, minute: 15, second: 0, of: day) ?? day
|
||
let end = start.addingTimeInterval(Double(lengthMinutes) * 60)
|
||
let workout = Workout(id: ULID.make(), routineID: routine.id, routineName: routine.name,
|
||
start: start, end: end, statusRaw: WorkoutStatus.completed.rawValue,
|
||
createdAt: start, updatedAt: end, jsonRelativePath: "")
|
||
context.insert(workout)
|
||
for (eIndex, e) in spec.enumerated() {
|
||
let isDuration = e.0 == "Plank"
|
||
let weight = e.0 == "Lat Pull Down" ? (latPullDown ?? Double(e.3)) : Double(e.3)
|
||
let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1,
|
||
reps: e.2, weight: isDuration ? 0 : weight,
|
||
loadType: (isDuration ? LoadType.duration : .weight).rawValue,
|
||
durationTotalSeconds: isDuration ? 45 : 0, currentStateIndex: e.1,
|
||
statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: start,
|
||
setEntries: isDuration ? nil
|
||
: actualEntries(sets: e.1, reps: e.2, weight: weight, at: start))
|
||
log.workout = workout
|
||
context.insert(log)
|
||
}
|
||
}
|
||
|
||
var upperSlot = 0, lowerSlot = 0
|
||
for d in stride(from: 69, through: 1, by: -1) {
|
||
let day = daysAgo(d)
|
||
let weekday = cal.component(.weekday, from: day)
|
||
|
||
// Upper Body — Mon & Thu; the 5th slot is the believable miss.
|
||
if weekday == 2 || weekday == 5 {
|
||
upperSlot += 1
|
||
if upperSlot != 5 {
|
||
let lift = min(110, 87.5 + 2.5 * Double((upperSlot + 1) / 2))
|
||
addCompleted(routine: created[0], spec: routines[0].3,
|
||
on: day, hour: 17, lengthMinutes: 40, latPullDown: lift)
|
||
}
|
||
}
|
||
// Lower Body — Tue & Sat, its 2×-per-week rhythm; the 4th slot missed.
|
||
if weekday == 3 || weekday == 7 {
|
||
lowerSlot += 1
|
||
if lowerSlot != 4 {
|
||
addCompleted(routine: created[1], spec: routines[1].3,
|
||
on: day, hour: 18, lengthMinutes: 35, latPullDown: nil)
|
||
}
|
||
}
|
||
// Core — daily mornings; Sundays skipped beyond the last four weeks.
|
||
if d <= 28 || weekday != 1 {
|
||
addCompleted(routine: created[2], spec: routines[2].3,
|
||
on: day, hour: 6, lengthMinutes: 15, latPullDown: nil)
|
||
}
|
||
}
|
||
|
||
// ---- The current in-progress session -----------------------------------
|
||
// Carries the routine's id so the Today board's schedule row shows it as
|
||
// today's in-progress run.
|
||
let workout = Workout(id: ULID.make(), routineID: upperID, routineName: upper.0, start: today,
|
||
end: nil, statusRaw: WorkoutStatus.inProgress.rawValue,
|
||
createdAt: today, updatedAt: today, jsonRelativePath: "")
|
||
context.insert(workout)
|
||
// Lat Pull Down done, Seated Row partway, the rest to go.
|
||
let progress = [(4, WorkoutStatus.completed), (2, .inProgress), (0, .notStarted), (0, .notStarted)]
|
||
for (eIndex, e) in upper.3.enumerated() {
|
||
let (idx, status) = progress[eIndex]
|
||
let weight = e.0 == "Lat Pull Down" ? 110 : Double(e.3)
|
||
let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1, reps: e.2,
|
||
weight: weight, loadType: LoadType.weight.rawValue,
|
||
durationTotalSeconds: 0, currentStateIndex: idx,
|
||
statusRaw: status.rawValue, notes: nil, date: today,
|
||
setEntries: idx > 0 ? actualEntries(sets: idx, reps: e.2, weight: weight, at: today) : nil)
|
||
log.workout = workout
|
||
context.insert(log)
|
||
}
|
||
|
||
try? context.save()
|
||
return workout
|
||
}
|
||
|
||
/// Plausible per-set actuals for a weighted log: plan values, with the last
|
||
/// set a couple of reps short so screenshots show real-looking variation.
|
||
private static func actualEntries(sets: Int, reps: Int, weight: Double, at date: Date) -> [SetEntry] {
|
||
(0..<sets).map { i in
|
||
SetEntry(reps: i == sets - 1 ? max(1, reps - 2) : reps, weight: weight,
|
||
completedAt: date.addingTimeInterval(Double(i) * 150))
|
||
}
|
||
}
|
||
}
|
||
#endif
|