Matrix: classic Eisenhower — you set importance, deadline sets urgency

Quadrant is now computed (importance x urgency) instead of a fixed
assignment. Importance comes from the task's assigned row (Q1/Q2 vs
Q3/Q4); urgency is derived from the (next active) due date being within
urgentWindowDays (7). So important tasks slide Q2->Q1 as they near,
unimportant slide Q4->Q3, and recurring tasks roll forward (a completed
occurrence is replaced by the next, never piling up in a quadrant).

- VM: displayQuadrant/isUrgent/effectiveDue; matrixTasks groups by the
  computed quadrant and uses each recurring task's single next occurrence.
- MatrixView grid + drill-down (overdue/later/completed) group by it.

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

View File

@@ -332,52 +332,53 @@ class TaskViewModel: ObservableObject {
return nil return nil
} }
// MARK: - Eisenhower Matrix planning window // MARK: - Eisenhower Matrix (classic: you set importance, time sets urgency)
// //
// The Matrix is a priority view, not a date-filtered list, so far-future // Importance is intrinsic taken from the task's assigned quadrant's row
// recurring occurrences must be hidden deliberately. A recurring task surfaces // (Q1/Q2 = important, Q3/Q4 = unimportant) and only changes when you move it.
// exactly one "current actionable occurrence" and only within its planning // Urgency is derived from the deadline: a task is urgent when its (next active)
// window; completing it rolls forward and it leaves the quadrant until the next // due date is within `urgentWindowDays`. The displayed quadrant = importance × urgency,
// occurrence's window opens. Completed occurrences never move to another quadrant. // so important tasks slide Q2Q1 as they near, unimportant slide Q4Q3, and
// recurring tasks roll forward (a completed occurrence is replaced by the next).
private func matrixLeadDays(_ label: String?) -> Int { static let urgentWindowDays = 7
switch label {
case "Daily", "Every Weekday": return 0 // today only private func isImportantQuadrant(_ q: Quadrant) -> Bool { q == .urgent || q == .schedule }
case "Weekly": return 6 // the days leading into the due weekday
case "Monthly": return 7 /// The due date that drives urgency now: a recurring task's next active
case "Yearly": return 30 /// occurrence, or a one-off task's own due date.
default: return 0 private func effectiveDue(_ t: TaskItem, on today: Date) -> Date? {
recurs(t) ? nextActiveOccurrence(t, from: today) : t.dueDate
}
private func isUrgent(_ t: TaskItem, on today: Date) -> Bool {
guard let due = effectiveDue(t, on: today) else { return false } // undated never urgent
let days = Calendar.current.dateComponents([.day],
from: Calendar.current.startOfDay(for: today),
to: Calendar.current.startOfDay(for: due)).day ?? 0
return days <= Self.urgentWindowDays // due soon / overdue
}
/// Where a task should appear in the Matrix right now (importance × urgency).
func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant {
switch (isImportantQuadrant(t.quadrant), isUrgent(t, on: today)) {
case (true, true): return .urgent
case (true, false): return .schedule
case (false, true): return .delegate_
case (false, false): return .eliminate
} }
} }
/// The single occurrence a recurring task should show in the Matrix now, or nil /// Matrix rows for a quadrant, grouped by the *displayed* (computed) quadrant.
/// if it's outside its planning window or already done for the current period. /// Recurring tasks contribute their single next-active occurrence.
/// 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] { func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] {
var out: [TaskItem] = [] var out: [TaskItem] = []
for t in tasks where t.quadrant == quadrant { for t in tasks {
if recurs(t) { if recurs(t) {
if let occ = matrixActiveOccurrence(t, on: today) { out.append(occurrenceCopy(t, on: occ)) } guard let occ = nextActiveOccurrence(t, from: today) else { continue }
} else { let copy = occurrenceCopy(t, on: occ)
if displayQuadrant(copy, on: today) == quadrant { out.append(copy) }
} else if displayQuadrant(t, on: today) == quadrant {
out.append(t) out.append(t)
} }
} }

View File

@@ -56,7 +56,7 @@ struct MatrixView: View {
quadrant: .urgent, quadrant: .urgent,
roman: "I", label: "Urgent & Important", roman: "I", label: "Urgent & Important",
badgeBg: AppColors.accentSoft, badgeColor: AppColors.accent, badgeBg: AppColors.accentSoft, badgeColor: AppColors.accent,
tasks: taskVM.tasks(for: .urgent).filter { !hideCompleted || !$0.isComplete }, tasks: taskVM.matrixTasks(for: .urgent).filter { !hideCompleted || !$0.isComplete },
isDropTarget: dropTarget == .urgent, isDropTarget: dropTarget == .urgent,
onToggle: { taskVM.toggle($0) }, onToggle: { taskVM.toggle($0) },
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .urgent); dropTarget = nil } }, onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .urgent); dropTarget = nil } },
@@ -74,7 +74,7 @@ struct MatrixView: View {
quadrant: .schedule, quadrant: .schedule,
roman: "II", label: "Not Urgent & Important", roman: "II", label: "Not Urgent & Important",
badgeBg: AppColors.yellowSoft, badgeColor: AppColors.yellow, badgeBg: AppColors.yellowSoft, badgeColor: AppColors.yellow,
tasks: taskVM.tasks(for: .schedule).filter { !hideCompleted || !$0.isComplete }, tasks: taskVM.matrixTasks(for: .schedule).filter { !hideCompleted || !$0.isComplete },
isDropTarget: dropTarget == .schedule, isDropTarget: dropTarget == .schedule,
onToggle: { taskVM.toggle($0) }, onToggle: { taskVM.toggle($0) },
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .schedule); dropTarget = nil } }, onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .schedule); dropTarget = nil } },
@@ -96,7 +96,7 @@ struct MatrixView: View {
quadrant: .delegate_, quadrant: .delegate_,
roman: "III", label: "Urgent & Unimportant", roman: "III", label: "Urgent & Unimportant",
badgeBg: AppColors.blueSoft, badgeColor: AppColors.blue, badgeBg: AppColors.blueSoft, badgeColor: AppColors.blue,
tasks: taskVM.tasks(for: .delegate_).filter { !hideCompleted || !$0.isComplete }, tasks: taskVM.matrixTasks(for: .delegate_).filter { !hideCompleted || !$0.isComplete },
isDropTarget: dropTarget == .delegate_, isDropTarget: dropTarget == .delegate_,
onToggle: { taskVM.toggle($0) }, onToggle: { taskVM.toggle($0) },
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .delegate_); dropTarget = nil } }, onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .delegate_); dropTarget = nil } },
@@ -114,7 +114,7 @@ struct MatrixView: View {
quadrant: .eliminate, quadrant: .eliminate,
roman: "IV", label: "Not Urgent & Unimportant", roman: "IV", label: "Not Urgent & Unimportant",
badgeBg: AppColors.greenSoft, badgeColor: AppColors.green, badgeBg: AppColors.greenSoft, badgeColor: AppColors.green,
tasks: taskVM.tasks(for: .eliminate).filter { !hideCompleted || !$0.isComplete }, tasks: taskVM.matrixTasks(for: .eliminate).filter { !hideCompleted || !$0.isComplete },
isDropTarget: dropTarget == .eliminate, isDropTarget: dropTarget == .eliminate,
onToggle: { taskVM.toggle($0) }, onToggle: { taskVM.toggle($0) },
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .eliminate); dropTarget = nil } }, onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .eliminate); dropTarget = nil } },
@@ -408,21 +408,23 @@ struct QuadrantDetailView: View {
@State private var showAllCompleted = false @State private var showAllCompleted = false
private let completedLimit = 5 private let completedLimit = 5
// Grouped by the *computed* quadrant (importance × deadline urgency); recurring
// tasks contribute their single next-active occurrence.
private var overdueTasks: [TaskItem] { private var overdueTasks: [TaskItem] {
let startOfDay = Calendar.current.startOfDay(for: Date()) let startOfDay = Calendar.current.startOfDay(for: Date())
return taskVM.tasks return taskVM.matrixTasks(for: quadrant)
.filter { $0.quadrant == quadrant && !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay } .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
} }
private var laterTasks: [TaskItem] { private var laterTasks: [TaskItem] {
let startOfDay = Calendar.current.startOfDay(for: Date()) let startOfDay = Calendar.current.startOfDay(for: Date())
return taskVM.tasks return taskVM.matrixTasks(for: quadrant)
.filter { $0.quadrant == quadrant && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= startOfDay } .filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= startOfDay }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
} }
private var completedTasks: [TaskItem] { private var completedTasks: [TaskItem] {
taskVM.tasks taskVM.matrixTasks(for: quadrant)
.filter { $0.quadrant == quadrant && $0.isComplete } .filter { $0.isComplete }
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
} }
private var allEmpty: Bool { overdueTasks.isEmpty && laterTasks.isEmpty && completedTasks.isEmpty } private var allEmpty: Bool { overdueTasks.isEmpty && laterTasks.isEmpty && completedTasks.isEmpty }