Matrix: TickTick-faithful placement — axis-split urgency, category windows, Manual override (KC-21)
Refines the Eisenhower Matrix toward TickTick's "view over attributes" model: - Split the single quadrant override into two axes: importance is always Priority (High = top row); urgency is date-derived unless manually pinned via the new `urgencyOverride` flag. Horizontal drags pin urgency instead of fabricating/destroying a real due date; vertical drags set Priority directly. - A drag only flags "Manual" when the target column disagrees with the date-derived urgency, so dropping into a task's natural column stays on Auto. - Tiles show a "Manual" badge; the task menu gains "Reset Matrix Urgency". - Urgency window is now 3 days by default, widened to 14 for prep-heavy event types (birthday/domain/annual + subscription/renewal/exam/deadline titles). - New tasks no longer default to today — no date stays no date (not urgent). - Stored `quadrant` is kept synced to the computed placement so task color and widget accents follow the Matrix; Settings stats count by displayQuadrant. - Completed tasks are hidden from the Matrix grid by default (toggle to show). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,9 +23,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
|
||||
// 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
|
||||
// Matrix: a manual drag pins the task to a quadrant (overrides auto-placement).
|
||||
// nil = auto-place from Priority × deadline. Optional so old saves decode safely.
|
||||
var matrixOverride: Quadrant? = nil
|
||||
// Matrix: horizontal manual moves override urgency only.
|
||||
// nil = auto from deadline, true = force urgent, false = force not-urgent.
|
||||
var urgencyOverride: Bool? = nil
|
||||
}
|
||||
|
||||
enum Priority: String, Codable, CaseIterable, Identifiable {
|
||||
@@ -351,19 +351,20 @@ class TaskViewModel: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Eisenhower Matrix (TickTick-style: Priority × deadline, with manual override)
|
||||
// MARK: - Eisenhower Matrix (TickTick-style: Priority × deadline, with urgency override)
|
||||
//
|
||||
// The Matrix is a VIEW of tasks, not a separate system. A task is auto-placed:
|
||||
// • Importance (top row) = Priority is High; Medium/Low/None = bottom row.
|
||||
// • Urgency (left column) = its (next active) due date is today/overdue/within
|
||||
// `urgentWindowDays`; undated or far-out = right column.
|
||||
// So Q1 = High + urgent, Q2 = High + not urgent, Q3 = not-High + urgent, Q4 = the rest.
|
||||
// A manual drag (`moveToQuadrant`) pins the task via `matrixOverride` and syncs its
|
||||
// Priority to the target row — the move sticks and is never forced back. Editing the
|
||||
// Priority or due date in the task view clears the override so it re-places live.
|
||||
// Vertical drag changes Priority directly. Horizontal drag sets `urgencyOverride`
|
||||
// because we should not fabricate/destroy a real deadline just to move columns.
|
||||
// Editing the due date clears urgency override so the task re-places live.
|
||||
// Recurring tasks roll forward (a completed occurrence is replaced by the next).
|
||||
|
||||
static let urgentWindowDays = 7
|
||||
static let urgentWindowDays = 3
|
||||
static let categoryUrgentWindowDays = 14
|
||||
|
||||
private func isImportant(_ t: TaskItem) -> Bool { t.priority == .high }
|
||||
|
||||
@@ -374,11 +375,32 @@ class TaskViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func isUrgent(_ t: TaskItem, on today: Date) -> Bool {
|
||||
t.urgencyOverride ?? urgentByDeadline(t, on: today)
|
||||
}
|
||||
|
||||
/// Date-derived urgency, ignoring any manual override (overdue/today/within window).
|
||||
private func urgentByDeadline(_ t: TaskItem, on today: Date = 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
|
||||
return days <= urgentWindow(for: t) // due soon / overdue
|
||||
}
|
||||
|
||||
private func urgentWindow(for t: TaskItem) -> Int {
|
||||
switch t.category {
|
||||
case .birthday, .domain, .annual:
|
||||
return Self.categoryUrgentWindowDays
|
||||
case .reminder, .custom:
|
||||
let title = t.title.lowercased()
|
||||
if title.contains("domain") || title.contains("subscription")
|
||||
|| title.contains("annual") || title.contains("renewal")
|
||||
|| title.contains("renew") || title.contains("exam")
|
||||
|| title.contains("deadline") {
|
||||
return Self.categoryUrgentWindowDays
|
||||
}
|
||||
return Self.urgentWindowDays
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-placement from Priority (importance) × deadline (urgency).
|
||||
@@ -391,9 +413,9 @@ class TaskViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a task appears in the Matrix now: a manual override wins, else auto-placement.
|
||||
/// Where a task appears in the Matrix now.
|
||||
func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant {
|
||||
t.matrixOverride ?? autoQuadrant(t, on: today)
|
||||
autoQuadrant(t, on: today)
|
||||
}
|
||||
|
||||
/// Matrix rows for a quadrant, grouped by the *displayed* (computed) quadrant.
|
||||
@@ -436,36 +458,47 @@ class TaskViewModel: ObservableObject {
|
||||
func setDate(_ task: TaskItem, date: Date?) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].dueDate = date
|
||||
tasks[idx].matrixOverride = nil // deadline changed → re-place from urgency
|
||||
tasks[idx].urgencyOverride = nil // deadline changed → re-place from urgency
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
func setCategory(_ task: TaskItem, category: TaskCategory) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].category = category
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
func setPriority(_ task: TaskItem, priority: Priority) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].priority = priority
|
||||
tasks[idx].matrixOverride = nil // priority changed → re-place from importance
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx]) // color follows placement
|
||||
save()
|
||||
}
|
||||
|
||||
/// Manual drag/move in the Matrix. Pins the task to the target quadrant and syncs
|
||||
/// its Priority to that row — top row (Q1/Q2) becomes High; moving down out of the
|
||||
/// top demotes High→Medium so it reads as unimportant. TickTick-style: it sticks.
|
||||
/// Manual drag/move in the Matrix. Vertical movement updates Priority directly.
|
||||
/// Horizontal movement sets an urgency override instead of mutating due dates —
|
||||
/// but only when the target column disagrees with the date-derived urgency, so a
|
||||
/// drag into a task's natural column leaves it on Auto (no false "Manual" flag).
|
||||
func moveToQuadrant(taskID: UUID, quadrant: Quadrant) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||||
tasks[idx].matrixOverride = quadrant
|
||||
tasks[idx].quadrant = quadrant // keep stored quadrant in sync (widget accent)
|
||||
let importantRow = (quadrant == .urgent || quadrant == .schedule)
|
||||
let urgentColumn = (quadrant == .urgent || quadrant == .delegate_)
|
||||
if importantRow {
|
||||
tasks[idx].priority = .high
|
||||
} else if tasks[idx].priority == .high {
|
||||
tasks[idx].priority = .medium
|
||||
}
|
||||
tasks[idx].urgencyOverride = urgentByDeadline(tasks[idx]) == urgentColumn ? nil : urgentColumn
|
||||
tasks[idx].quadrant = quadrant // keep stored quadrant in sync (color + widget accent)
|
||||
save()
|
||||
}
|
||||
|
||||
func resetMatrixUrgencyOverride(_ task: TaskItem) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].urgencyOverride = nil
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
@@ -500,6 +533,7 @@ class TaskViewModel: ObservableObject {
|
||||
priority: resolvedPriority, reminderDate: reminderDate,
|
||||
constantReminder: constantReminder)
|
||||
item.recurrenceEnd = recurrenceEnd
|
||||
item.quadrant = displayQuadrant(item) // color/widget identity follows placement
|
||||
tasks.append(item)
|
||||
save()
|
||||
}
|
||||
@@ -519,7 +553,8 @@ class TaskViewModel: ObservableObject {
|
||||
tasks[idx].reminderDate = reminderDate
|
||||
tasks[idx].constantReminder = constantReminder
|
||||
tasks[idx].recurrenceEnd = recurrenceEnd
|
||||
tasks[idx].matrixOverride = nil // editor saved a new deadline → re-place
|
||||
tasks[idx].urgencyOverride = nil // editor saved a new deadline → re-place
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user