Tasks: single-occurrence recurrence + Today/Tomorrow/Next 7/Later + icons

Switch lists from calendar-expansion (every occurrence in every section)
to the TickTick model: one task contributes ONE next-active occurrence.

- VM: nextActiveOccurrence + activeRows; replace todayTasks/next3/upcoming
  with todayTasks/tomorrowTasks/next7DaysTasks/laterTasks buckets. A
  recurring task shows a single row; completing it advances to the next.
- TodayView: sections are now Today / Tomorrow / Next 7 Days / Later
  (UpcomingSection gained a title param).
- TaskRowView: add  (reminder) and 🔁 (recurring) indicators on the right;
  fix the hardcoded "Annual" label to show the real frequency.

Calendar grid keeps per-date occurrences (it's a date grid, not a list).
Matrix dynamic promotion (Problem 4) handled separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-09 19:36:10 +03:00
parent 0870359ae8
commit b4e36db763
2 changed files with 150 additions and 51 deletions

View File

@@ -166,51 +166,65 @@ 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())
// 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] {
// MARK: - Active list rows (single occurrence per task TickTick model)
//
// A recurring task contributes exactly ONE row: its next active (incomplete)
// occurrence. Completing it advances to the next occurrence there are never
// multiple future copies of the same task across sections. (The calendar grid
// still shows every occurrence per-date; that's a date grid, not a list.)
/// Earliest occurrence on/after `from` not yet completed the task's one active
/// occurrence. Daily today (or tomorrow once today's done); Yearly the next
/// uncompleted yearly date; etc.
func nextActiveOccurrence(_ t: TaskItem, from: Date = Date()) -> Date? {
guard recurs(t), let start = t.dueDate else { return nil }
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()))!
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)
var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: from))
for _ in 0..<4000 {
if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil }
if isOccurrence(t, on: cur) && !occurrenceComplete(t, on: cur) { return cur }
cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur
}
return (nonRec + rec).sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
return nil
}
var next3DaysTasks: [(date: Date, tasks: [TaskItem])] {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
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 nonRec = tasks.filter {
!recurs($0) && !$0.isComplete &&
($0.dueDate ?? .distantFuture) >= day &&
($0.dueDate ?? .distantFuture) < nextDay
/// One representative active row per task: non-recurring incomplete tasks, plus a
/// single next-active occurrence copy for each recurring task.
private func activeRows() -> [TaskItem] {
let today = Calendar.current.startOfDay(for: Date())
var out: [TaskItem] = []
for t in tasks {
if recurs(t) {
if let occ = nextActiveOccurrence(t, from: today) { out.append(occurrenceCopy(t, on: occ)) }
} else if !t.isComplete {
out.append(t)
}
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)
}
return out
}
/// Active rows whose due date falls in [today+lo, today+hi) days (hi nil = open).
private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] {
let cal = Calendar.current; let base = cal.startOfDay(for: Date())
let start = cal.date(byAdding: .day, value: lo, to: base)!
let end = hi.flatMap { cal.date(byAdding: .day, value: $0, to: base) }
return activeRows().filter {
let d = $0.dueDate ?? .distantFuture
return d >= start && (end == nil || d < end!)
}.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
}
var overdueTasks: [TaskItem] {
let today = Calendar.current.startOfDay(for: Date())
// Non-recurring past-due only recurring surfaces its next occurrence instead.
return tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) < today }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
}
var todayTasks: [TaskItem] { bucket(from: 0, to: 1) } // due today
var tomorrowTasks: [TaskItem] { bucket(from: 1, to: 2) } // due tomorrow
var next7DaysTasks: [TaskItem] { bucket(from: 2, to: 8) } // due in 27 days
var laterTasks: [TaskItem] { bucket(from: 8, to: nil) } // beyond 7 days
var completedTodayTasks: [TaskItem] {
let cal = Calendar.current
let startOfDay = cal.startOfDay(for: Date())
@@ -318,6 +332,58 @@ class TaskViewModel: ObservableObject {
return nil
}
// MARK: - Eisenhower Matrix planning window
//
// The Matrix is a priority view, not a date-filtered list, so far-future
// recurring occurrences must be hidden deliberately. A recurring task surfaces
// exactly one "current actionable occurrence" and only within its planning
// window; completing it rolls forward and it leaves the quadrant until the next
// occurrence's window opens. Completed occurrences never move to another quadrant.
private func matrixLeadDays(_ label: String?) -> Int {
switch label {
case "Daily", "Every Weekday": return 0 // today only
case "Weekly": return 6 // the days leading into the due weekday
case "Monthly": return 7
case "Yearly": return 30
default: return 0
}
}
/// The single occurrence a recurring task should show in the Matrix now, or nil
/// if it's outside its planning window or already done for the current period.
/// Deadlines (monthly/yearly) also keep surfacing a missed past occurrence until
/// it's completed; habits (daily/weekly) only consider the current window.
func matrixActiveOccurrence(_ t: TaskItem, on today: Date = Date()) -> Date? {
guard recurs(t) else { return nil }
let cal = Calendar.current
let t0 = cal.startOfDay(for: today)
let lead = matrixLeadDays(t.recurrenceLabel)
let isDeadline = (t.recurrenceLabel == "Monthly" || t.recurrenceLabel == "Yearly")
let back = isDeadline ? 400 : 0
guard let windowEnd = cal.date(byAdding: .day, value: lead, to: t0) else { return nil }
var cur = cal.date(byAdding: .day, value: -back, to: t0) ?? t0
while cur <= windowEnd {
if isOccurrence(t, on: cur) && !occurrenceComplete(t, on: cur) { return cur }
cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur
}
return nil
}
/// Matrix rows for a quadrant: non-recurring tasks as-is, plus each recurring
/// task's current actionable occurrence (hidden once done for its period).
func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] {
var out: [TaskItem] = []
for t in tasks where t.quadrant == quadrant {
if recurs(t) {
if let occ = matrixActiveOccurrence(t, on: today) { out.append(occurrenceCopy(t, on: occ)) }
} else {
out.append(t)
}
}
return out.sorted { $0.isPinned && !$1.isPinned }
}
/// 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] {