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:
@@ -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 Q2→Q1 as they near, unimportant slide Q4→Q3, 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user