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)
This commit is contained in:
@@ -43,14 +43,13 @@ struct ProgressPlannerTests {
|
||||
goal: GoalKind? = .strength,
|
||||
recurrence: ScheduleRecurrence,
|
||||
weekdays: [Int]? = nil,
|
||||
timesPerWeek: Int? = nil,
|
||||
onceDate: Date? = nil,
|
||||
createdAt: Date,
|
||||
routineID: String = "R-1",
|
||||
routineName: String = "Push Day"
|
||||
) -> ScheduleFacts {
|
||||
ScheduleFacts(id: id, goal: goal, recurrence: recurrence, weekdays: weekdays,
|
||||
timesPerWeek: timesPerWeek, onceDate: onceDate, createdAt: createdAt,
|
||||
onceDate: onceDate, createdAt: createdAt,
|
||||
routineID: routineID, routineName: routineName)
|
||||
}
|
||||
|
||||
@@ -149,37 +148,6 @@ struct ProgressPlannerTests {
|
||||
#expect(current?.met == 2)
|
||||
}
|
||||
|
||||
// MARK: - weekMark: frequency
|
||||
|
||||
@Test func weekMarkFrequency() {
|
||||
let now = day("2026-07-08") // Wednesday; current week Mon 07-06 – Sun 07-12
|
||||
let currentWeek = day("2026-07-06")
|
||||
|
||||
// Expected is the full weekly target even mid-week — NOT clamped down to the
|
||||
// 3 days that have elapsed so far.
|
||||
let twicePerWeek = schedule(recurrence: .frequency, timesPerWeek: 2, createdAt: day("2026-06-01"))
|
||||
let untouched = ProgressPlanner.weekMark(schedule: twicePerWeek, doneDays: [],
|
||||
weekStart: currentWeek, calendar: cal, now: now)
|
||||
#expect(untouched?.expected == 2)
|
||||
#expect(untouched?.met == 0)
|
||||
|
||||
// Met is capped at the target even when more sessions were logged.
|
||||
let threeDone: Set<Date> = [day("2026-07-06"), day("2026-07-07"), day("2026-07-08")]
|
||||
let overMet = ProgressPlanner.weekMark(schedule: twicePerWeek, doneDays: threeDone,
|
||||
weekStart: currentWeek, calendar: cal, now: now)
|
||||
#expect(overMet?.met == 2)
|
||||
#expect(overMet?.expected == 2)
|
||||
#expect(overMet?.hit == true)
|
||||
|
||||
// Target clamps to the days the schedule existed in its creation week: created
|
||||
// Wednesday leaves only 5 days (Wed–Sun) in that week, so a 7x/week target
|
||||
// clamps down to 5, not 7.
|
||||
let createdMidWeek = schedule(recurrence: .frequency, timesPerWeek: 7, createdAt: day("2026-07-08"))
|
||||
let clamped = ProgressPlanner.weekMark(schedule: createdMidWeek, doneDays: [],
|
||||
weekStart: currentWeek, calendar: cal, now: day("2026-07-12"))
|
||||
#expect(clamped?.expected == 5)
|
||||
}
|
||||
|
||||
// MARK: - weekMark: once
|
||||
|
||||
@Test func weekMarkOnce() {
|
||||
@@ -247,25 +215,6 @@ struct ProgressPlannerTests {
|
||||
weekStart: weekStart, calendar: cal, now: now))
|
||||
}
|
||||
|
||||
@Test func currentWeekBrokenFrequencyDaysRemainingVsRequirement() {
|
||||
let twicePerWeek = schedule(recurrence: .frequency, timesPerWeek: 2, createdAt: day("2026-06-01"))
|
||||
let weekStart = day("2026-07-06")
|
||||
let oneDone: Set<Date> = [day("2026-07-07")]
|
||||
|
||||
// Saturday: 2 days left (Sat, Sun). 1 done, 1 still needed — achievable.
|
||||
#expect(!ProgressPlanner.currentWeekBroken(schedule: twicePerWeek, doneDays: oneDone,
|
||||
weekStart: weekStart, calendar: cal, now: day("2026-07-11")))
|
||||
|
||||
// Sunday: only 1 day left (today itself). 1 done, 1 still needed — needs
|
||||
// exactly 1 ≤ 1 day left, so still achievable.
|
||||
#expect(!ProgressPlanner.currentWeekBroken(schedule: twicePerWeek, doneDays: oneDone,
|
||||
weekStart: weekStart, calendar: cal, now: day("2026-07-12")))
|
||||
|
||||
// Sunday again, but nothing done yet: 2 still needed with only 1 day left — broken.
|
||||
#expect(ProgressPlanner.currentWeekBroken(schedule: twicePerWeek, doneDays: [],
|
||||
weekStart: weekStart, calendar: cal, now: day("2026-07-12")))
|
||||
}
|
||||
|
||||
@Test func currentWeekBrokenFixedDaysCatchUpDaysRemainingNotBroken() {
|
||||
// Mon/Wed/Fri required. Monday (a fixed day) was missed, but it's only
|
||||
// Tuesday — catch-up days (Wed, Fri, or any other day this week) still remain.
|
||||
|
||||
@@ -31,7 +31,6 @@ struct ScheduleDocumentTests {
|
||||
goal: GoalKind.strength.rawValue,
|
||||
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
||||
weekdays: [2, 5],
|
||||
timesPerWeek: nil,
|
||||
order: 3,
|
||||
createdAt: Self.created,
|
||||
updatedAt: Self.updated
|
||||
@@ -46,11 +45,10 @@ struct ScheduleDocumentTests {
|
||||
#expect(decoded.isReadable)
|
||||
#expect(decoded.goalKind == .strength)
|
||||
#expect(decoded.recurrenceEnum == .fixedDays)
|
||||
#expect(decoded.timesPerWeek == nil)
|
||||
}
|
||||
|
||||
/// The nil-heavy cases round-trip too: a `.daily` schedule with no goal, no weekdays,
|
||||
/// and no frequency count. Absent optionals decode back to nil (not phantom values).
|
||||
/// The nil-heavy cases round-trip too: a `.daily` schedule with no goal and no
|
||||
/// weekdays. Absent optionals decode back to nil (not phantom values).
|
||||
@Test func encodeDecodeRoundTripDailyWithNils() throws {
|
||||
let original = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
@@ -60,7 +58,6 @@ struct ScheduleDocumentTests {
|
||||
goal: nil,
|
||||
recurrence: ScheduleRecurrence.daily.rawValue,
|
||||
weekdays: nil,
|
||||
timesPerWeek: nil,
|
||||
order: 0,
|
||||
createdAt: Self.created,
|
||||
updatedAt: Self.updated
|
||||
@@ -72,7 +69,6 @@ struct ScheduleDocumentTests {
|
||||
// quoted key — "updatedAt" contains it as a substring.)
|
||||
#expect(!json.contains("goal"))
|
||||
#expect(!json.contains("weekdays"))
|
||||
#expect(!json.contains("timesPerWeek"))
|
||||
#expect(!json.contains("\"date\""))
|
||||
|
||||
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data)
|
||||
@@ -80,7 +76,6 @@ struct ScheduleDocumentTests {
|
||||
#expect(decoded.goal == nil)
|
||||
#expect(decoded.goalKind == nil)
|
||||
#expect(decoded.weekdays == nil)
|
||||
#expect(decoded.timesPerWeek == nil)
|
||||
#expect(decoded.date == nil)
|
||||
}
|
||||
|
||||
@@ -91,7 +86,7 @@ struct ScheduleDocumentTests {
|
||||
id: ULID.make(), routineID: "R", routineName: "Trail Run",
|
||||
goal: GoalKind.cardio.rawValue,
|
||||
recurrence: ScheduleRecurrence.once.rawValue,
|
||||
weekdays: nil, timesPerWeek: nil, date: Self.onceDay, order: 2,
|
||||
weekdays: nil, date: Self.onceDay, order: 2,
|
||||
createdAt: Self.created, updatedAt: Self.updated
|
||||
)
|
||||
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original))
|
||||
@@ -99,23 +94,6 @@ struct ScheduleDocumentTests {
|
||||
#expect(decoded.recurrenceEnum == .once)
|
||||
#expect(decoded.date == Self.onceDay)
|
||||
#expect(decoded.weekdays == nil)
|
||||
#expect(decoded.timesPerWeek == nil)
|
||||
}
|
||||
|
||||
/// A `.frequency` schedule carries its count and drops weekdays.
|
||||
@Test func encodeDecodeRoundTripFrequency() throws {
|
||||
let original = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: ULID.make(), routineID: "R", routineName: "Cardio",
|
||||
goal: GoalKind.cardio.rawValue,
|
||||
recurrence: ScheduleRecurrence.frequency.rawValue,
|
||||
weekdays: nil, timesPerWeek: 3, order: 1,
|
||||
createdAt: Self.created, updatedAt: Self.updated
|
||||
)
|
||||
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original))
|
||||
#expect(decoded == original)
|
||||
#expect(decoded.recurrenceEnum == .frequency)
|
||||
#expect(decoded.timesPerWeek == 3)
|
||||
}
|
||||
|
||||
// MARK: - Forward-compatibility gate
|
||||
@@ -127,7 +105,7 @@ struct ScheduleDocumentTests {
|
||||
func doc(schema: Int) -> ScheduleDocument {
|
||||
ScheduleDocument(schemaVersion: schema, id: "01X", routineID: "R", routineName: "N",
|
||||
goal: nil, recurrence: ScheduleRecurrence.daily.rawValue,
|
||||
weekdays: nil, timesPerWeek: nil, order: 0,
|
||||
weekdays: nil, order: 0,
|
||||
createdAt: Self.created, updatedAt: Self.updated)
|
||||
}
|
||||
#expect(doc(schema: ScheduleDocument.currentSchemaVersion).isReadable)
|
||||
@@ -145,38 +123,34 @@ struct ScheduleDocumentTests {
|
||||
|
||||
// Once — the one-off day, medium-date formatted (routing check; the format
|
||||
// itself is `Date.formatDate()`'s). Degenerate nil date → the display name.
|
||||
#expect(summary(.once, weekdays: nil, timesPerWeek: nil, date: Self.onceDay) == Self.onceDay.formatDate())
|
||||
#expect(summary(.once, weekdays: nil, timesPerWeek: nil) == "Once")
|
||||
#expect(summary(.once, weekdays: nil, date: Self.onceDay) == Self.onceDay.formatDate())
|
||||
#expect(summary(.once, weekdays: nil) == "Once")
|
||||
|
||||
// Daily.
|
||||
#expect(summary(.daily, weekdays: nil, timesPerWeek: nil) == "Daily")
|
||||
#expect(summary(.daily, weekdays: nil) == "Daily")
|
||||
|
||||
// Fixed days — sorted, joined with "&"; weekday 2 = Mon, 5 = Thu.
|
||||
#expect(summary(.fixedDays, weekdays: [5, 2], timesPerWeek: nil) == "\(sym[1]) & \(sym[4])")
|
||||
#expect(summary(.fixedDays, weekdays: [5, 2]) == "\(sym[1]) & \(sym[4])")
|
||||
// Single day → just the name.
|
||||
#expect(summary(.fixedDays, weekdays: [2], timesPerWeek: nil) == sym[1])
|
||||
#expect(summary(.fixedDays, weekdays: [2]) == sym[1])
|
||||
// Three days → Oxford-style "A, B & C".
|
||||
#expect(summary(.fixedDays, weekdays: [2, 4, 6], timesPerWeek: nil) == "\(sym[1]), \(sym[3]) & \(sym[5])")
|
||||
#expect(summary(.fixedDays, weekdays: [2, 4, 6]) == "\(sym[1]), \(sym[3]) & \(sym[5])")
|
||||
// Degenerate (no weekdays) → falls back to the recurrence display name.
|
||||
#expect(summary(.fixedDays, weekdays: [], timesPerWeek: nil) == "Fixed Days")
|
||||
#expect(summary(.fixedDays, weekdays: nil, timesPerWeek: nil) == "Fixed Days")
|
||||
#expect(summary(.fixedDays, weekdays: []) == "Fixed Days")
|
||||
#expect(summary(.fixedDays, weekdays: nil) == "Fixed Days")
|
||||
// Out-of-range weekday numbers are ignored.
|
||||
#expect(summary(.fixedDays, weekdays: [0, 2, 9], timesPerWeek: nil) == sym[1])
|
||||
|
||||
// Frequency.
|
||||
#expect(summary(.frequency, weekdays: nil, timesPerWeek: 2) == "2× per week")
|
||||
#expect(summary(.frequency, weekdays: nil, timesPerWeek: nil) == "0× per week")
|
||||
#expect(summary(.fixedDays, weekdays: [0, 2, 9]) == sym[1])
|
||||
}
|
||||
|
||||
private func summary(_ r: ScheduleRecurrence, weekdays: [Int]?, timesPerWeek: Int?, date: Date? = nil) -> String {
|
||||
private func summary(_ r: ScheduleRecurrence, weekdays: [Int]?, date: Date? = nil) -> String {
|
||||
let doc = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion, id: "X", routineID: "R", routineName: "N",
|
||||
goal: nil, recurrence: r.rawValue, weekdays: weekdays, timesPerWeek: timesPerWeek,
|
||||
goal: nil, recurrence: r.rawValue, weekdays: weekdays,
|
||||
date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated
|
||||
)
|
||||
// The entity must format identically to the document — this is the shared-logic check.
|
||||
let entity = Schedule(id: "X", routineID: "R", routineName: "N", goalRaw: nil,
|
||||
recurrenceRaw: r.rawValue, weekdays: weekdays, timesPerWeek: timesPerWeek,
|
||||
recurrenceRaw: r.rawValue, weekdays: weekdays,
|
||||
date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated,
|
||||
jsonRelativePath: "")
|
||||
#expect(doc.recurrenceSummary == entity.recurrenceSummary)
|
||||
@@ -205,7 +179,6 @@ struct ScheduleDocumentTests {
|
||||
goal: GoalKind.mobility.rawValue,
|
||||
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
||||
weekdays: [2, 4, 6],
|
||||
timesPerWeek: nil,
|
||||
order: 7,
|
||||
createdAt: Self.created,
|
||||
updatedAt: Self.updated
|
||||
@@ -222,7 +195,6 @@ struct ScheduleDocumentTests {
|
||||
#expect(entity.recurrenceRaw == original.recurrence)
|
||||
#expect(entity.recurrenceEnum == .fixedDays)
|
||||
#expect(entity.weekdays == [2, 4, 6])
|
||||
#expect(entity.timesPerWeek == nil)
|
||||
#expect(entity.order == 7)
|
||||
#expect(entity.jsonRelativePath == "Schedules/\(original.id).json")
|
||||
|
||||
@@ -240,7 +212,7 @@ struct ScheduleDocumentTests {
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: "01SCHEDULEONCE000000000001", routineID: "R", routineName: "Trail Run",
|
||||
goal: nil, recurrence: ScheduleRecurrence.once.rawValue,
|
||||
weekdays: nil, timesPerWeek: nil, date: Self.onceDay, order: 0,
|
||||
weekdays: nil, date: Self.onceDay, order: 0,
|
||||
createdAt: Self.created, updatedAt: Self.updated
|
||||
)
|
||||
CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context)
|
||||
@@ -267,7 +239,7 @@ struct ScheduleDocumentTests {
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: "01SCHEDULEREUPSERT0000001", routineID: "R", routineName: "Old Name",
|
||||
goal: nil, recurrence: ScheduleRecurrence.daily.rawValue,
|
||||
weekdays: nil, timesPerWeek: nil, order: 0,
|
||||
weekdays: nil, order: 0,
|
||||
createdAt: Self.created, updatedAt: Self.created
|
||||
)
|
||||
CacheMapper.upsertSchedule(first, relativePath: first.relativePath, into: context)
|
||||
@@ -276,8 +248,8 @@ struct ScheduleDocumentTests {
|
||||
var second = first
|
||||
second.routineName = "New Name"
|
||||
second.goal = GoalKind.strength.rawValue
|
||||
second.recurrence = ScheduleRecurrence.frequency.rawValue
|
||||
second.timesPerWeek = 4
|
||||
second.recurrence = ScheduleRecurrence.fixedDays.rawValue
|
||||
second.weekdays = [2, 4]
|
||||
second.updatedAt = Self.updated
|
||||
CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context)
|
||||
try context.save()
|
||||
@@ -287,8 +259,8 @@ struct ScheduleDocumentTests {
|
||||
let entity = try #require(all.first)
|
||||
#expect(entity.routineName == "New Name")
|
||||
#expect(entity.goalKind == .strength)
|
||||
#expect(entity.recurrenceEnum == .frequency)
|
||||
#expect(entity.timesPerWeek == 4)
|
||||
#expect(entity.recurrenceEnum == .fixedDays)
|
||||
#expect(entity.weekdays == [2, 4])
|
||||
#expect(ScheduleDocument(from: entity) == second)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user