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:
kutesir
2026-06-09 01:41:15 +03:00
parent 7b2b61a228
commit 6817d3e24a
4 changed files with 196 additions and 26 deletions

View File

@@ -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.

View File

@@ -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()

View File

@@ -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),