Matrix: TickTick-style Priority×deadline view + Progress Dots widget (KC-21)

Matrix (KC-21): the Eisenhower Matrix is now a computed view of tasks.
Importance = Priority (High = top row), urgency = deadline proximity, so
tasks auto-place into Q1–Q4. Added a persisted `matrixOverride` so a manual
drag pins the task and syncs its Priority to that row (top → High, moving
down demotes High → Medium) and never gets forced back; editing Priority,
due date, or the editor clears the override to re-place live. New tasks get
KisaniCal priority defaults (birthday/domain/annual/exam/subscription → High,
workout → Medium). AddTaskSheet drops the manual quadrant picker for a
Priority menu (pick Priority + Date only).

Also includes in-progress Progress Dots widget work (day/week/month/custom
event modes, App-Group anchor, recurrence-aware spans).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-15 12:40:39 +03:00
parent 2b48509a35
commit 3592d2a9d8
11 changed files with 568 additions and 197 deletions

View File

@@ -23,6 +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
}
enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -332,18 +335,21 @@ class TaskViewModel: ObservableObject {
return nil
}
// MARK: - Eisenhower Matrix (classic: you set importance, time sets urgency)
// MARK: - Eisenhower Matrix (TickTick-style: Priority × deadline, with manual override)
//
// Importance is intrinsic taken from the task's assigned quadrant's row
// (Q1/Q2 = important, Q3/Q4 = unimportant) and only changes when you move it.
// Urgency is derived from the deadline: a task is urgent when its (next active)
// due date is within `urgentWindowDays`. The displayed quadrant = importance × urgency,
// so important tasks slide Q2Q1 as they near, unimportant slide Q4Q3, and
// recurring tasks roll forward (a completed occurrence is replaced by the next).
// 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.
// Recurring tasks roll forward (a completed occurrence is replaced by the next).
static let urgentWindowDays = 7
private func isImportantQuadrant(_ q: Quadrant) -> Bool { q == .urgent || q == .schedule }
private func isImportant(_ t: TaskItem) -> Bool { t.priority == .high }
/// The due date that drives urgency now: a recurring task's next active
/// occurrence, or a one-off task's own due date.
@@ -359,9 +365,9 @@ class TaskViewModel: ObservableObject {
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)) {
/// Auto-placement from Priority (importance) × deadline (urgency).
private func autoQuadrant(_ t: TaskItem, on today: Date) -> Quadrant {
switch (isImportant(t), isUrgent(t, on: today)) {
case (true, true): return .urgent
case (true, false): return .schedule
case (false, true): return .delegate_
@@ -369,6 +375,11 @@ class TaskViewModel: ObservableObject {
}
}
/// Where a task appears in the Matrix now: a manual override wins, else auto-placement.
func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant {
t.matrixOverride ?? autoQuadrant(t, on: today)
}
/// Matrix rows for a quadrant, grouped by the *displayed* (computed) quadrant.
/// Recurring tasks contribute their single next-active occurrence.
func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] {
@@ -409,6 +420,7 @@ 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
save()
}
@@ -421,14 +433,40 @@ class TaskViewModel: ObservableObject {
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
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 HighMedium so it reads as unimportant. TickTick-style: it sticks.
func moveToQuadrant(taskID: UUID, quadrant: Quadrant) {
guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return }
tasks[idx].quadrant = quadrant
tasks[idx].matrixOverride = quadrant
tasks[idx].quadrant = quadrant // keep stored quadrant in sync (widget accent)
let importantRow = (quadrant == .urgent || quadrant == .schedule)
if importantRow {
tasks[idx].priority = .high
} else if tasks[idx].priority == .high {
tasks[idx].priority = .medium
}
save()
}
/// KisaniCal priority defaults high-stakes events start High, workouts Medium.
/// Applied only when the user didn't pick a priority (== .none) at creation.
private func defaultPriority(category: TaskCategory, title: String) -> Priority {
switch category {
case .birthday, .domain, .annual: return .high
default: break
}
let t = title.lowercased()
if t.contains("birthday") || t.contains("exam") || t.contains("subscription")
|| t.contains("renew") || t.contains("domain") || t.contains("expir") { return .high }
if t.contains("workout") || t.contains("gym") || t.contains("exercise") { return .medium }
return .none
}
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
taskColor: TaskColor = .orange, isRecurring: Bool = false,
@@ -436,12 +474,14 @@ class TaskViewModel: ObservableObject {
endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none, reminderDate: Date? = nil,
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
let resolvedPriority = priority == .none
? defaultPriority(category: category, title: title) : priority
var item = TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
quadrant: quadrant, category: category,
taskColor: taskColor, isRecurring: isRecurring,
recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay,
priority: priority, reminderDate: reminderDate,
priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder)
item.recurrenceEnd = recurrenceEnd
tasks.append(item)
@@ -463,6 +503,7 @@ 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
save()
}
@@ -488,4 +529,3 @@ class TaskViewModel: ObservableObject {
save()
}
}