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:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user