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

@@ -36,15 +36,232 @@ struct KisaniProvider: TimelineProvider {
// MARK: - Day Progress Widget ("Day done %")
enum ProgressDotMode: String, AppEnum {
case day
case week
case month
case customEvent
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
.day: "This Day",
.week: "This Week",
.month: "This Month",
.customEvent: "Custom Event",
]
}
struct ProgressDotConfigIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Progress Dots"
static var description = IntentDescription("Track day, week, month, or a KisaniCal event.")
@Parameter(title: "Mode", default: .month)
var mode: ProgressDotMode
@Parameter(title: "Event")
var event: EventEntity?
static var parameterSummary: some ParameterSummary {
When(\ProgressDotConfigIntent.$mode, .equalTo, ProgressDotMode.customEvent) {
Summary("Show \(\.$mode) for \(\.$event)")
} otherwise: {
Summary("Show \(\.$mode)")
}
}
}
struct ProgressDotEntry: TimelineEntry {
let date: Date
let title: String
let progress: Double
let totalDots: Int
}
struct ProgressDotProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> ProgressDotEntry {
ProgressDotEntry(date: .now, title: Self.dayTitle(for: .now), progress: 0.06, totalDots: 100)
}
func snapshot(for configuration: ProgressDotConfigIntent, in context: Context) async -> ProgressDotEntry {
entry(for: configuration, at: .now)
}
func timeline(for configuration: ProgressDotConfigIntent, in context: Context) async -> Timeline<ProgressDotEntry> {
let interval: TimeInterval = 5 * 60
let entries = (0..<72).map { step in
entry(for: configuration, at: Date().addingTimeInterval(Double(step) * interval))
}
return Timeline(entries: entries, policy: .after(Date().addingTimeInterval(Double(entries.count) * interval)))
}
private func entry(for configuration: ProgressDotConfigIntent, at date: Date) -> ProgressDotEntry {
switch configuration.mode {
case .day:
let start = Calendar.current.startOfDay(for: date)
let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? date
return ProgressDotEntry(date: date, title: Self.dayTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .week:
let start = Self.startOfISOWeek(containing: date)
let end = Calendar(identifier: .iso8601).date(byAdding: .weekOfYear, value: 1, to: start) ?? date
return ProgressDotEntry(date: date, title: Self.weekTitle(for: start),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .month:
let interval = Calendar.current.dateInterval(of: .month, for: date)
let start = interval?.start ?? date
let end = interval?.end ?? date
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .customEvent:
guard let event = configuration.event,
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
let due = task.dueDate
else {
return ProgressDotEntry(date: date, title: "Select Event", progress: 0, totalDots: 100)
}
let start = Self.eventStartDate(for: task, due: due, now: date)
let computedProgress = Self.progress(now: date, start: start, end: due)
let storedProgress = task.progress.map(Self.clamp) ?? 0
return ProgressDotEntry(date: date, title: task.title,
progress: max(storedProgress, computedProgress),
totalDots: 100)
}
}
private static func progress(now: Date, start: Date, end: Date) -> Double {
let total = end.timeIntervalSince(start)
guard total > 0 else { return end <= now ? 1 : 0 }
return clamp(now.timeIntervalSince(start) / total)
}
private static func clamp(_ value: Double) -> Double {
min(1, max(0, value))
}
private static func eventStartDate(for task: WidgetTask, due: Date, now: Date) -> Date {
if let span = recurrenceSpan(for: task, due: due, now: now) {
return span.prev
}
let start = [task.createdAt, task.reminderDate]
.compactMap { $0 }
.filter { $0 < due }
.min()
let stored = storedAnchor(key: task.id.uuidString, target: due, now: now)
return bestStartDate(explicit: start, stored: stored, target: due, now: now)
}
private static func storedAnchor(key: String, target: Date, now: Date) -> Date {
let userDefaults = UserDefaults.kisani
let key = "kisani.widget.progressDots.anchor.\(key)"
if let saved = userDefaults.object(forKey: key) as? Date, saved < target {
return saved
}
userDefaults.set(now, forKey: key)
return now
}
private static func bestStartDate(explicit: Date?, stored: Date, target: Date, now: Date) -> Date {
let candidate = explicit ?? stored
guard candidate < target else { return inferredLegacyStart(target: target, now: now) }
let remaining = target.timeIntervalSince(now)
let elapsed = now.timeIntervalSince(candidate)
let total = target.timeIntervalSince(candidate)
let progress = total > 0 ? elapsed / total : 0
let looksLikeUpgradeAnchor = remaining > 14 * 86400
&& elapsed >= 0
&& elapsed < 3 * 86400
&& progress < (1.0 / 100.0)
return looksLikeUpgradeAnchor ? inferredLegacyStart(target: target, now: now) : candidate
}
private static func inferredLegacyStart(target: Date, now: Date) -> Date {
let remainingDays = max(1, Int(ceil(target.timeIntervalSince(now) / 86400)))
let totalDays = min(365, max(30, remainingDays * 2))
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
}
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
let category = task.category.lowercased()
if category == "birthday" || category == "annual" {
return recurrenceSpan(label: "Yearly", due: due, now: now)
}
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
return recurrenceSpan(label: label, due: due, now: now)
}
private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? {
let cal = Calendar.current
let comp: Calendar.Component
let step: Int
switch label {
case "Daily", "Every Weekday": comp = .day; step = 1
case "Weekly": comp = .day; step = 7
case "Monthly": comp = .month; step = 1
case "Yearly": comp = .year; step = 1
default: return nil
}
var next = due
var guardRail = 0
if next > now {
while let prev = cal.date(byAdding: comp, value: -step, to: next),
prev > now, guardRail < 4000 {
next = prev
guardRail += 1
}
} else {
while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next),
guardRail < 4000 {
next = candidate
guardRail += 1
}
}
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next)
}
private static func startOfISOWeek(containing date: Date) -> Date {
let cal = Calendar(identifier: .iso8601)
return cal.dateInterval(of: .weekOfYear, for: date)?.start ?? cal.startOfDay(for: date)
}
private static func dayTitle(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE d"
return formatter.string(from: date)
}
private static func weekTitle(for start: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return "Week of \(formatter.string(from: start))"
}
private static func monthTitle(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: date)
}
}
struct DayProgressWidget: Widget {
let kind = "KisaniDayProgress"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in
ProgressDotView(period: .day, accent: WidgetTheme.accent)
AppIntentConfiguration(kind: kind, intent: ProgressDotConfigIntent.self, provider: ProgressDotProvider()) { entry in
ProgressDotView(entry: entry, accent: WidgetTheme.accent)
}
.configurationDisplayName("Day Progress")
.description("How much of today has elapsed, as a dot grid.")
.supportedFamilies([.systemSmall, .systemMedium])
.configurationDisplayName("Progress Dots")
.description("Track day, week, month, or a selected event as a dot grid.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
}
}