Recurring tasks: occurrence engine on calendar + lists (KC-12)
Recurring tasks only ever showed on their single dueDate. Add a recurrence engine that expands occurrences per-date with independent per-occurrence completion. - TaskItem: recurrenceEnd + completedOccurrences (optional, safe decode). - TaskViewModel: isOccurrence/occurrenceComplete/toggleOccurrence/ occurrenceCopy/nextOccurrence/occurrences(on:); Daily/Weekly/Monthly/ Yearly/Every-Weekday; respects recurrenceEnd; works across years. - toggle() routes recurring rows to per-occurrence completion. - Date filters re-inject per-date occurrence copies; recurring excluded from overdue. Calendar dots + day detail use occurrences(on:). Also log KC-11 (year display) + KC-12. Pending: "repeat until" date UI (engine already honors recurrenceEnd). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -339,3 +339,59 @@ leaked onto all of those dates.
|
||||
finished. Future occurrences are never auto-completed.
|
||||
|
||||
Files: `ExerciseModels.swift`, `CalendarView.swift`, `TodayView.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-11 — Calendar appeared capped at 2026 (no year shown)
|
||||
|
||||
**Status:** Resolved
|
||||
**Reported by:** User
|
||||
**Area:** Calendar
|
||||
|
||||
### Description
|
||||
Users couldn't navigate/see dates beyond 2026.
|
||||
|
||||
### Root cause
|
||||
Navigation was actually unbounded, but every header showed only the month name
|
||||
(`"MMMM"`) — the Year view had no year label at all — so crossing a year boundary
|
||||
was invisible and future months felt unreachable (compounded by empty future
|
||||
months from the missing recurrence engine, KC-12).
|
||||
|
||||
### Fix
|
||||
Show the year: main header `"Month Year"`, date-picker grid `"MMMM yyyy"`, and a
|
||||
`◀ year ▶` navigator + label in the Year view (`navigateYear`).
|
||||
Files: `CalendarView.swift`, `TodayView.swift`. Committed in `7b2b61a`.
|
||||
|
||||
---
|
||||
|
||||
## KC-12 — Recurring tasks don't appear on future dates
|
||||
|
||||
**Status:** In Progress (core engine done & build-verified; "repeat until" UI pending)
|
||||
**Reported by:** User
|
||||
**Area:** Tasks / Calendar
|
||||
|
||||
### Description
|
||||
A task set to repeat (Daily/Weekly/Monthly/Yearly/Every-Weekday) only ever showed
|
||||
on its single original `dueDate`. No future occurrences, no per-occurrence
|
||||
completion.
|
||||
|
||||
### Root cause
|
||||
There was no recurrence engine — tasks carry `isRecurring` + `recurrenceLabel` but
|
||||
a single `dueDate`, and every surface matched by `isDate(dueDate, inSameDayAs:)`.
|
||||
|
||||
### Fix (core)
|
||||
- Model: `recurrenceEnd` + `completedOccurrences` (optional → safe decode of old data).
|
||||
- `TaskViewModel` engine: `isOccurrence(_:on:)`, `occurrenceComplete`,
|
||||
`toggleOccurrence`, `occurrenceCopy`, `nextOccurrence`, `occurrences(on:)`.
|
||||
- `toggle(_:)` routes recurring rows to per-occurrence completion (one day never
|
||||
affects another).
|
||||
- Date filters (`today`/`next3`/`upcoming`/`completedToday`) exclude recurring
|
||||
masters and re-inject per-date occurrence copies; recurring never goes "overdue".
|
||||
- Calendar dots + day detail use `occurrences(on:)`.
|
||||
- Works across year boundaries; respects `recurrenceEnd` when set.
|
||||
|
||||
Files: `TaskItem.swift`, `CalendarView.swift`.
|
||||
|
||||
### Pending
|
||||
- "Repeat until" date UI in the task date picker (thread `recurrenceEnd` through the
|
||||
add/edit sheets + `addTask`/`updateTask`). Engine already honors it.
|
||||
|
||||
@@ -20,6 +20,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
|
||||
var priority: Priority = .none
|
||||
var reminderDate: Date? = nil
|
||||
var constantReminder: Bool = false
|
||||
// Recurrence: optional so existing saved tasks decode safely (missing key → nil).
|
||||
var recurrenceEnd: Date? = nil // "repeat until" (nil = open-ended)
|
||||
var completedOccurrences: [String]? = nil // "yyyy-MM-dd" of completed occurrences
|
||||
}
|
||||
|
||||
enum Priority: String, Codable, CaseIterable, Identifiable {
|
||||
@@ -163,23 +166,33 @@ class TaskViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Recurring masters are excluded from the date filters and re-injected as
|
||||
// per-date occurrence copies, so each scheduled day is independent.
|
||||
var overdueTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return tasks.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
||||
// Non-recurring only — a recurring task is never "overdue"; each day stands alone.
|
||||
return tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
||||
}
|
||||
var todayTasks: [TaskItem] {
|
||||
let start = Calendar.current.startOfDay(for: Date())
|
||||
let end = Calendar.current.date(byAdding: .day, value: 1, to: start)!
|
||||
return tasks
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
let cal = Calendar.current
|
||||
let start = cal.startOfDay(for: Date())
|
||||
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
||||
let nonRec = tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end }
|
||||
let rec = tasks.filter { recurs($0) && isOccurrence($0, on: start) && !occurrenceComplete($0, on: start) }
|
||||
.map { occurrenceCopy($0, on: start) }
|
||||
return (nonRec + rec).sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
var upcomingTasks: [TaskItem] {
|
||||
let cal = Calendar.current
|
||||
let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))!
|
||||
return tasks
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
let nonRec = tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays }
|
||||
// Each recurring task contributes its next occurrence from 4 days out.
|
||||
let rec = tasks.compactMap { t -> TaskItem? in
|
||||
guard recurs(t), let next = nextOccurrence(t, onOrAfter: fourDays),
|
||||
!occurrenceComplete(t, on: next) else { return nil }
|
||||
return occurrenceCopy(t, on: next)
|
||||
}
|
||||
return (nonRec + rec).sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
var next3DaysTasks: [(date: Date, tasks: [TaskItem])] {
|
||||
let cal = Calendar.current
|
||||
@@ -187,19 +200,24 @@ class TaskViewModel: ObservableObject {
|
||||
return (1...3).compactMap { offset -> (Date, [TaskItem])? in
|
||||
guard let day = cal.date(byAdding: .day, value: offset, to: today),
|
||||
let nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil }
|
||||
let dayTasks = tasks.filter {
|
||||
!$0.isComplete &&
|
||||
let nonRec = tasks.filter {
|
||||
!recurs($0) && !$0.isComplete &&
|
||||
($0.dueDate ?? .distantFuture) >= day &&
|
||||
($0.dueDate ?? .distantFuture) < nextDay
|
||||
}.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
let rec = tasks.filter { recurs($0) && isOccurrence($0, on: day) && !occurrenceComplete($0, on: day) }
|
||||
.map { occurrenceCopy($0, on: day) }
|
||||
let dayTasks = (nonRec + rec).sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
return dayTasks.isEmpty ? nil : (day, dayTasks)
|
||||
}
|
||||
}
|
||||
var completedTodayTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return tasks
|
||||
.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay }
|
||||
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||||
let cal = Calendar.current
|
||||
let startOfDay = cal.startOfDay(for: Date())
|
||||
let nonRec = tasks.filter { !recurs($0) && $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay }
|
||||
let rec = tasks.filter { recurs($0) && isOccurrence($0, on: startOfDay) && occurrenceComplete($0, on: startOfDay) }
|
||||
.map { occurrenceCopy($0, on: startOfDay) }
|
||||
return (nonRec + rec).sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||||
}
|
||||
/// All completed tasks, most-recent first (for the Completed archive section).
|
||||
var completedTasks: [TaskItem] {
|
||||
@@ -209,6 +227,11 @@ class TaskViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func toggle(_ task: TaskItem) {
|
||||
// A recurring row carries its occurrence date in dueDate — toggle just that day.
|
||||
if recurs(task), let d = task.dueDate {
|
||||
toggleOccurrence(task, on: d)
|
||||
return
|
||||
}
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].isComplete.toggle()
|
||||
tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil
|
||||
@@ -220,6 +243,96 @@ class TaskViewModel: ObservableObject {
|
||||
.sorted { $0.isPinned && !$1.isPinned }
|
||||
}
|
||||
|
||||
// MARK: - Recurrence engine
|
||||
|
||||
private static let occFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||
}()
|
||||
func occurrenceKey(_ date: Date) -> String { Self.occFmt.string(from: date) }
|
||||
|
||||
/// Whether a task repeats on a schedule.
|
||||
func recurs(_ t: TaskItem) -> Bool {
|
||||
t.isRecurring && (t.recurrenceLabel ?? "None") != "None" && t.dueDate != nil
|
||||
}
|
||||
|
||||
/// Whether a recurring task has an occurrence on `date` — aligned to its rule,
|
||||
/// on/after its start, and on/before its "repeat until" (if set).
|
||||
func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
|
||||
guard recurs(t), let start = t.dueDate else { return false }
|
||||
let cal = Calendar.current
|
||||
let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start)
|
||||
guard d0 >= s0 else { return false }
|
||||
if let end = t.recurrenceEnd, d0 > cal.startOfDay(for: end) { return false }
|
||||
switch t.recurrenceLabel {
|
||||
case "Daily": return true
|
||||
case "Every Weekday":
|
||||
let wd = cal.component(.weekday, from: d0) // 1=Sun … 7=Sat
|
||||
return wd >= 2 && wd <= 6
|
||||
case "Weekly": return ((cal.dateComponents([.day], from: s0, to: d0).day ?? 0) % 7) == 0
|
||||
case "Monthly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
|
||||
case "Yearly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
|
||||
&& cal.component(.month, from: s0) == cal.component(.month, from: d0)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
|
||||
t.completedOccurrences?.contains(occurrenceKey(date)) ?? false
|
||||
}
|
||||
|
||||
/// Toggle one occurrence's completion — never affects other dates.
|
||||
func toggleOccurrence(_ t: TaskItem, on date: Date) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return }
|
||||
let key = occurrenceKey(date)
|
||||
var set = Set(tasks[idx].completedOccurrences ?? [])
|
||||
if set.contains(key) { set.remove(key) } else { set.insert(key) }
|
||||
tasks[idx].completedOccurrences = Array(set)
|
||||
save()
|
||||
}
|
||||
|
||||
/// A display copy of a recurring task pinned to one occurrence date, with that
|
||||
/// occurrence's completion baked into `isComplete` (so existing rows just work).
|
||||
func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem {
|
||||
var c = t
|
||||
let cal = Calendar.current
|
||||
if let due = t.dueDate {
|
||||
let hm = cal.dateComponents([.hour, .minute], from: due)
|
||||
c.dueDate = cal.date(bySettingHour: hm.hour ?? 0, minute: hm.minute ?? 0, second: 0, of: date) ?? date
|
||||
} else { c.dueDate = date }
|
||||
let done = occurrenceComplete(t, on: date)
|
||||
c.isComplete = done
|
||||
c.completedAt = done ? c.dueDate : nil
|
||||
return c
|
||||
}
|
||||
|
||||
/// Next occurrence on/after `date`, within the repeat-until.
|
||||
func nextOccurrence(_ t: TaskItem, onOrAfter date: Date) -> Date? {
|
||||
guard recurs(t), let start = t.dueDate else { return nil }
|
||||
let cal = Calendar.current
|
||||
var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: date))
|
||||
for _ in 0..<4000 {
|
||||
if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil }
|
||||
if isOccurrence(t, on: cur) { return cur }
|
||||
cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// All task instances on a given calendar day: non-recurring tasks due that day
|
||||
/// plus a copy of each recurring task that occurs that day. Used by the calendar.
|
||||
func occurrences(on date: Date) -> [TaskItem] {
|
||||
let cal = Calendar.current
|
||||
var out: [TaskItem] = []
|
||||
for t in tasks {
|
||||
if recurs(t) {
|
||||
if isOccurrence(t, on: date) { out.append(occurrenceCopy(t, on: date)) }
|
||||
} else if let d = t.dueDate, cal.isDate(d, inSameDayAs: date) {
|
||||
out.append(t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pin(_ task: TaskItem) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].isPinned.toggle()
|
||||
|
||||
@@ -463,9 +463,9 @@ struct CalendarView: View {
|
||||
dots.append(calColor)
|
||||
}
|
||||
}
|
||||
let taskDots = taskVM.tasks
|
||||
// Includes recurring occurrences that land on this date.
|
||||
let taskDots = taskVM.occurrences(on: date)
|
||||
.filter { !$0.isComplete }
|
||||
.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) }
|
||||
.prefix(max(0, 3 - dots.count))
|
||||
.map { $0.quadrant.color }
|
||||
dots.append(contentsOf: taskDots)
|
||||
@@ -581,10 +581,7 @@ struct CalendarView: View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
DayTimelineView(
|
||||
date: selectedDate,
|
||||
tasks: taskVM.tasks.filter { t in
|
||||
guard let d = t.dueDate else { return false }
|
||||
return cal.isDate(d, inSameDayAs: selectedDate)
|
||||
},
|
||||
tasks: taskVM.occurrences(on: selectedDate),
|
||||
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
|
||||
workout: workoutVM.program(for: selectedDate),
|
||||
workoutDone: workoutVM.isWorkoutComplete(on: selectedDate),
|
||||
|
||||
Reference in New Issue
Block a user