diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index d9130c6..17d4c4d 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -2292,3 +2292,59 @@ parens (one pre-existing stray `(` predates this change, confirmed earlier in KC-62). Existing tasks (created before this fix) still have no real creation date and will keep using the anchor/inferred fallback — there's no way to retroactively know when they were actually created. + +## KC-66 — Live Activity build error + second countdown widget missing createdAt + +Status: Implemented (not build-verified — no iOS runtime here) +Reported by: User, Xcode error screenshot + follow-up request to audit all +widgets for creation-date usage +Area: Live Activities, Widgets + +### 1. Build error: `end(_:dismissalPolicy:)` needs iOS 16.2, file targets 16.1 +KC-64's fix picked the non-deprecated ActivityKit overload, but +`LiveActivityManager.end(taskID:)` is only gated `@available(iOS 16.1, *)` +— matching Live Activities' own minimum — so the call didn't compile for +16.1.x. Branched on `#available(iOS 16.2, *)`: the new overload when +available, falling back to the deprecated `end(using:dismissalPolicy:)` +below that. `StopCountdownIntent.swift` needed no change — it's already +gated `@available(iOS 17.0, *)`, well above 16.2. + +Files: `KisaniCal/Managers/LiveActivityManager.swift`. + +### 2. Audit: does every countdown widget use the task's real creation date? +KC-65 fixed `KisaniCalWidgets.swift`'s `ProgressDotProvider` (the "Track +Countdown" dot-grid widget, driven by App Group task data) to prefer +`task.createdAt`. Auditing the rest turned up a second, separate countdown +widget — `EventCountdownWidget.swift`'s `EventBarsWidget`/`EventProvider` +(the "Event Countdown (Bars)" widget family, config-driven via +`EventConfigIntent`/`EventEntity`) — whose `resolveSpan` had its own +independent start-date resolution that never looked at `createdAt` at all: +explicit start (tracked-since date, or the widget's configured "Counting +from") → `storedAnchor` (first-seen fallback) only. Confirmed via `grep +createdAt EventCountdownWidget.swift` returning nothing before this fix. +`WidgetViews.swift`'s progress-rendering views (`ProgressDotView`, +`BarGrid`, etc.) only consume an already-computed `progress` value, so +they needed no change — the two `TimelineProvider`s are the only places +that resolve a start date, and both are now fixed. + +### Change +- Added `createdAt: Date? = nil` to `EventEntity` (the App-Group-backed + event picker's entity type), populated in both places that build it: + `EventQuery.allEvents()` and `EventProvider.upcomingEvents(asOf:)`. +- `resolveSpan(...)` gained a `taskCreatedAt: Date? = nil` parameter, + checked after `explicitStart` (an explicit user choice, e.g. the + widget's "Counting from" field, still wins) and before `storedAnchor` + (the last-resort fallback for tasks created before KC-65, or fully + custom events with no task behind them at all). +- All three call sites that resolve a real task's span (tracked event, + upcoming-queue auto-select, manually configured event) now pass + `taskCreatedAt:`. The fully-custom-event branch (no task, just a typed + name + date) is unaffected — there's no task to have a creation date. + +Files: `KisaniCalWidgets/EventCountdownWidget.swift`. + +### Note +Not build-verified (no iOS runtime here). Self-reviewed: balanced braces/ +parens confirmed per-file. Same caveat as KC-65: tasks created before that +fix still have no real `createdAt` and fall through to the anchor/inferred +heuristics in both widgets. diff --git a/KisaniCal/Managers/LiveActivityManager.swift b/KisaniCal/Managers/LiveActivityManager.swift index 2756377..270d53f 100644 --- a/KisaniCal/Managers/LiveActivityManager.swift +++ b/KisaniCal/Managers/LiveActivityManager.swift @@ -61,7 +61,14 @@ enum LiveActivityManager { Task { for activity in Activity.activities where activity.attributes.taskID == taskID { - await activity.end(nil, dismissalPolicy: .immediate) + // end(_:dismissalPolicy:) needs iOS 16.2; this file supports + // 16.1 (Live Activities' own minimum), so fall back to the + // deprecated using:dismissalPolicy: overload below that. + if #available(iOS 16.2, *) { + await activity.end(nil, dismissalPolicy: .immediate) + } else { + await activity.end(using: nil, dismissalPolicy: .immediate) + } } } } diff --git a/KisaniCalWidgets/EventCountdownWidget.swift b/KisaniCalWidgets/EventCountdownWidget.swift index bbed562..42ac431 100644 --- a/KisaniCalWidgets/EventCountdownWidget.swift +++ b/KisaniCalWidgets/EventCountdownWidget.swift @@ -78,6 +78,7 @@ struct EventEntity: AppEntity { let dueDate: Date? var isRecurring: Bool = false var recurrenceLabel: String? = nil + var createdAt: Date? = nil static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event" static var defaultQuery = EventQuery() @@ -105,7 +106,8 @@ struct EventQuery: EntityQuery { .filter { !$0.isComplete && $0.dueDate != nil } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate, - isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) } + isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel, + createdAt: $0.createdAt) } } } @@ -235,7 +237,8 @@ struct EventProvider: AppIntentTimelineProvider { .filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate, - isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) } + isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel, + createdAt: $0.createdAt) } } /// The tracked task ("Track Countdown"), but only while it's still live: not @@ -267,13 +270,16 @@ struct EventProvider: AppIntentTimelineProvider { /// Resolve the progress window (start → target) for any event. Recurring events /// (Mode B) span the current occurrence period: previous → next occurrence — /// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count - /// from an explicit start, the tracking date, or first-seen, up to the due date. + /// from an explicit start, the task's real creation date, the tracking date, + /// or first-seen, up to the due date — in that priority order. private func resolveSpan(due: Date, isRecurring: Bool, label: String?, - anchorKey: String, explicitStart: Date?) -> (start: Date, target: Date) { + anchorKey: String, explicitStart: Date?, + taskCreatedAt: Date? = nil) -> (start: Date, target: Date) { if isRecurring, let label, let span = Self.recurrenceSpan(label: label, due: due) { return (span.prev, span.next) } if let s = explicitStart, s < due { return (s, due) } + if let created = taskCreatedAt, created < due { return (created, due) } return (Self.storedAnchor(key: anchorKey, target: due), due) } @@ -288,7 +294,8 @@ struct EventProvider: AppIntentTimelineProvider { let trackedId = task.id.uuidString let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring, - label: task.recurrenceLabel, anchorKey: trackedId, explicitStart: since) + label: task.recurrenceLabel, anchorKey: trackedId, explicitStart: since, + taskCreatedAt: task.createdAt) return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode) } @@ -302,12 +309,13 @@ struct EventProvider: AppIntentTimelineProvider { } let ev = upcoming[idx] let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, - anchorKey: ev.id, explicitStart: nil) + anchorKey: ev.id, explicitStart: nil, taskCreatedAt: ev.createdAt) return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) } if let ev = config.event, let due = ev.dueDate { let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, - anchorKey: ev.id, explicitStart: config.startDate) + anchorKey: ev.id, explicitStart: config.startDate, + taskCreatedAt: ev.createdAt) return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) } // Fully custom event (no task behind it).