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:
@@ -166,51 +166,65 @@ class TaskViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recurring masters are excluded from the date filters and re-injected as
|
// MARK: - Active list rows (single occurrence per task — TickTick model)
|
||||||
// per-date occurrence copies, so each scheduled day is independent.
|
//
|
||||||
var overdueTasks: [TaskItem] {
|
// A recurring task contributes exactly ONE row: its next active (incomplete)
|
||||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
// occurrence. Completing it advances to the next occurrence — there are never
|
||||||
// Non-recurring only — a recurring task is never "overdue"; each day stands alone.
|
// multiple future copies of the same task across sections. (The calendar grid
|
||||||
return tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
// still shows every occurrence per-date; that's a date grid, not a list.)
|
||||||
}
|
|
||||||
var todayTasks: [TaskItem] {
|
/// 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 cal = Calendar.current
|
||||||
let start = cal.startOfDay(for: Date())
|
var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: from))
|
||||||
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
for _ in 0..<4000 {
|
||||||
let nonRec = tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end }
|
if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil }
|
||||||
let rec = tasks.filter { recurs($0) && isOccurrence($0, on: start) && !occurrenceComplete($0, on: start) }
|
if isOccurrence(t, on: cur) && !occurrenceComplete(t, on: cur) { return cur }
|
||||||
.map { occurrenceCopy($0, on: start) }
|
cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return (nonRec + rec).sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
return nil
|
||||||
}
|
}
|
||||||
var next3DaysTasks: [(date: Date, tasks: [TaskItem])] {
|
|
||||||
let cal = Calendar.current
|
/// One representative active row per task: non-recurring incomplete tasks, plus a
|
||||||
let today = cal.startOfDay(for: Date())
|
/// single next-active occurrence copy for each recurring task.
|
||||||
return (1...3).compactMap { offset -> (Date, [TaskItem])? in
|
private func activeRows() -> [TaskItem] {
|
||||||
guard let day = cal.date(byAdding: .day, value: offset, to: today),
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
let nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil }
|
var out: [TaskItem] = []
|
||||||
let nonRec = tasks.filter {
|
for t in tasks {
|
||||||
!recurs($0) && !$0.isComplete &&
|
if recurs(t) {
|
||||||
($0.dueDate ?? .distantFuture) >= day &&
|
if let occ = nextActiveOccurrence(t, from: today) { out.append(occurrenceCopy(t, on: occ)) }
|
||||||
($0.dueDate ?? .distantFuture) < nextDay
|
} 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 2–7 days
|
||||||
|
var laterTasks: [TaskItem] { bucket(from: 8, to: nil) } // beyond 7 days
|
||||||
|
|
||||||
var completedTodayTasks: [TaskItem] {
|
var completedTodayTasks: [TaskItem] {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let startOfDay = cal.startOfDay(for: Date())
|
let startOfDay = cal.startOfDay(for: Date())
|
||||||
@@ -318,6 +332,58 @@ class TaskViewModel: ObservableObject {
|
|||||||
return nil
|
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
|
/// 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.
|
/// plus a copy of each recurring task that occurs that day. Used by the calendar.
|
||||||
func occurrences(on date: Date) -> [TaskItem] {
|
func occurrences(on date: Date) -> [TaskItem] {
|
||||||
|
|||||||
@@ -124,16 +124,33 @@ struct TodayView: View {
|
|||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Next 3 Days ──
|
// ── Tomorrow ──
|
||||||
let next3 = taskVM.next3DaysTasks
|
if !taskVM.tomorrowTasks.isEmpty {
|
||||||
if !next3.isEmpty {
|
UpcomingSection(
|
||||||
Next3DaysSection(tasksByDay: next3, onToggle: { taskVM.toggle($0) })
|
title: "Tomorrow",
|
||||||
|
tasks: taskVM.tomorrowTasks,
|
||||||
|
onToggle: { taskVM.toggle($0) },
|
||||||
|
onPostpone: { taskVM.postpone($0) },
|
||||||
|
onDelete: { taskVM.delete($0) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Upcoming (4+ days) ──
|
// ── Next 7 Days (2–7 days out) ──
|
||||||
if !taskVM.upcomingTasks.isEmpty {
|
if !taskVM.next7DaysTasks.isEmpty {
|
||||||
UpcomingSection(
|
UpcomingSection(
|
||||||
tasks: taskVM.upcomingTasks,
|
title: "Next 7 Days",
|
||||||
|
tasks: taskVM.next7DaysTasks,
|
||||||
|
onToggle: { taskVM.toggle($0) },
|
||||||
|
onPostpone: { taskVM.postpone($0) },
|
||||||
|
onDelete: { taskVM.delete($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Later (beyond 7 days) ──
|
||||||
|
if !taskVM.laterTasks.isEmpty {
|
||||||
|
UpcomingSection(
|
||||||
|
title: "Later",
|
||||||
|
tasks: taskVM.laterTasks,
|
||||||
onToggle: { taskVM.toggle($0) },
|
onToggle: { taskVM.toggle($0) },
|
||||||
onPostpone: { taskVM.postpone($0) },
|
onPostpone: { taskVM.postpone($0) },
|
||||||
onDelete: { taskVM.delete($0) }
|
onDelete: { taskVM.delete($0) }
|
||||||
@@ -151,8 +168,9 @@ struct TodayView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty
|
let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty
|
||||||
|| !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty
|
|| !taskVM.tomorrowTasks.isEmpty || !taskVM.next7DaysTasks.isEmpty
|
||||||
|| !taskVM.next3DaysTasks.isEmpty || !todayCalEvents.isEmpty
|
|| !taskVM.laterTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty
|
||||||
|
|| !todayCalEvents.isEmpty
|
||||||
|| workoutVM.program(for: Date()) != nil
|
|| workoutVM.program(for: Date()) != nil
|
||||||
if !hasAnything {
|
if !hasAnything {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
@@ -889,12 +907,13 @@ private struct SessionSectionHeader: View {
|
|||||||
|
|
||||||
struct UpcomingSection: View {
|
struct UpcomingSection: View {
|
||||||
@Environment(\.colorScheme) private var cs
|
@Environment(\.colorScheme) private var cs
|
||||||
|
var title: String = "Upcoming"
|
||||||
let tasks: [TaskItem]
|
let tasks: [TaskItem]
|
||||||
let onToggle: (TaskItem) -> Void
|
let onToggle: (TaskItem) -> Void
|
||||||
let onPostpone: (TaskItem) -> Void
|
let onPostpone: (TaskItem) -> Void
|
||||||
let onDelete: (TaskItem) -> Void
|
let onDelete: (TaskItem) -> Void
|
||||||
|
|
||||||
@AppStorage("upcomingCollapsed") private var collapsed = false
|
@State private var collapsed = false
|
||||||
@State private var showAll = false
|
@State private var showAll = false
|
||||||
|
|
||||||
private let pageSize = 5
|
private let pageSize = 5
|
||||||
@@ -908,7 +927,7 @@ struct UpcomingSection: View {
|
|||||||
|
|
||||||
// ── Header (tappable to collapse) ──
|
// ── Header (tappable to collapse) ──
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text("Upcoming")
|
Text(title)
|
||||||
.font(AppFonts.sans(10.5, weight: .semibold))
|
.font(AppFonts.sans(10.5, weight: .semibold))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
|
||||||
@@ -1173,7 +1192,7 @@ struct TaskRowView: View {
|
|||||||
.foregroundColor(AppColors.text(cs))
|
.foregroundColor(AppColors.text(cs))
|
||||||
if let d = task.dueDate, showDetails {
|
if let d = task.dueDate, showDetails {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · Annual" : "")")
|
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")")
|
||||||
.font(AppFonts.mono(9.5))
|
.font(AppFonts.mono(9.5))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
Text("·")
|
Text("·")
|
||||||
@@ -1188,6 +1207,20 @@ struct TaskRowView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// ⏰ reminder · 🔁 recurring indicators
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
if task.reminderDate != nil || task.constantReminder {
|
||||||
|
Image(systemName: "alarm")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
if task.isRecurring {
|
||||||
|
Image(systemName: "repeat")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TagChip(
|
TagChip(
|
||||||
text: task.category.rawValue,
|
text: task.category.rawValue,
|
||||||
color: task.quadrant.color,
|
color: task.quadrant.color,
|
||||||
|
|||||||
Reference in New Issue
Block a user