Bars widget: recurrence-aware progress in ALL paths (KC-20 follow-up)
The real bug behind "Leona's Birthday, 5 days left" showing 0% filled: Mode B (recurring span: previous -> next occurrence) only ran for app-tracked events, while the upcoming-queue / picked-event paths still anchored from first-seen (so a yearly birthday started at 0%). - EventEntity now carries isRecurring/recurrenceLabel (populated from the shared task data) so the auto-select and picker paths know the rule. - resolveSpan() centralizes the window: recurring -> prev/next occurrence everywhere; one-off -> explicit start / tracking date / first-seen. - A yearly birthday 5 days away now reads ~98% (≈360/365 days elapsed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,8 @@ struct EventEntity: AppEntity {
|
||||
let id: String // task UUID string
|
||||
let title: String
|
||||
let dueDate: Date?
|
||||
var isRecurring: Bool = false
|
||||
var recurrenceLabel: String? = nil
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
|
||||
static var defaultQuery = EventQuery()
|
||||
@@ -102,7 +104,8 @@ struct EventQuery: EntityQuery {
|
||||
loadWidgetTasks()
|
||||
.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) }
|
||||
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
||||
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +228,21 @@ struct EventProvider: AppIntentTimelineProvider {
|
||||
loadWidgetTasks()
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > Date() }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate) }
|
||||
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
||||
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
|
||||
}
|
||||
|
||||
/// 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.
|
||||
private func resolveSpan(due: Date, isRecurring: Bool, label: String?,
|
||||
anchorKey: String, explicitStart: Date?) -> (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) }
|
||||
return (Self.storedAnchor(key: anchorKey, target: due), due)
|
||||
}
|
||||
|
||||
private func entry(for config: EventConfigIntent) -> EventEntry {
|
||||
@@ -237,58 +254,37 @@ struct EventProvider: AppIntentTimelineProvider {
|
||||
if let trackedId = UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent"),
|
||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == trackedId }),
|
||||
let due = task.dueDate {
|
||||
// Mode B — recurring (birthdays/anniversaries): previous → next occurrence.
|
||||
if task.isRecurring, let label = task.recurrenceLabel,
|
||||
let span = Self.recurrenceSpan(label: label, due: due) {
|
||||
return EventEntry(date: .now, eventName: task.title,
|
||||
start: span.prev, target: span.next, unitMode: mode)
|
||||
}
|
||||
// Mode A — one-off: tracking date → event date.
|
||||
let since = (UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date)
|
||||
?? Self.storedAnchor(key: trackedId, target: due)
|
||||
return EventEntry(date: .now, eventName: task.title,
|
||||
start: min(since, due), target: due, unitMode: mode)
|
||||
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
|
||||
let s = resolveSpan(due: due, isRecurring: task.isRecurring, label: task.recurrenceLabel,
|
||||
anchorKey: trackedId, explicitStart: since)
|
||||
return EventEntry(date: .now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
|
||||
var name: String
|
||||
var target: Date
|
||||
var anchorKey: String
|
||||
|
||||
// ── Configured (no tracked event) ──
|
||||
if config.upcomingQueue {
|
||||
// Auto-track the Nth nearest upcoming event.
|
||||
let upcoming = upcomingEvents()
|
||||
let idx = max(0, config.autoSelect.rawValue - 1)
|
||||
if idx < upcoming.count, let due = upcoming[idx].dueDate {
|
||||
name = upcoming[idx].title
|
||||
target = due
|
||||
anchorKey = upcoming[idx].id
|
||||
} else {
|
||||
name = "No upcoming event"
|
||||
target = Date().addingTimeInterval(86400)
|
||||
anchorKey = "none"
|
||||
guard idx < upcoming.count, let due = upcoming[idx].dueDate else {
|
||||
return EventEntry(date: .now, eventName: "No upcoming event",
|
||||
start: Date(), target: Date().addingTimeInterval(86400), unitMode: mode)
|
||||
}
|
||||
} else if let ev = config.event, let due = ev.dueDate {
|
||||
// A specifically picked event supplies its own name + date.
|
||||
name = ev.title
|
||||
target = due
|
||||
anchorKey = ev.id
|
||||
} else {
|
||||
// Fully custom event. No date picked yet → count to tomorrow as a stub.
|
||||
name = config.eventName.isEmpty ? "My Event" : config.eventName
|
||||
target = config.targetDate
|
||||
?? Calendar.current.startOfDay(for: Date()).addingTimeInterval(2 * 86400)
|
||||
anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))"
|
||||
let ev = upcoming[idx]
|
||||
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||
anchorKey: ev.id, explicitStart: nil)
|
||||
return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
|
||||
// Counting anchor: an explicit "Counting from" wins when it's before the
|
||||
// event; otherwise remember when this event was first tracked so the bar
|
||||
// fills steadily instead of resetting to "now" on every refresh.
|
||||
let start: Date
|
||||
if let s = config.startDate, s < target {
|
||||
start = s
|
||||
} else {
|
||||
start = Self.storedAnchor(key: anchorKey, target: target)
|
||||
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)
|
||||
return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
// Fully custom event (no task behind it).
|
||||
let name = config.eventName.isEmpty ? "My Event" : config.eventName
|
||||
let target = config.targetDate
|
||||
?? Calendar.current.startOfDay(for: Date()).addingTimeInterval(2 * 86400)
|
||||
let anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))"
|
||||
let start = (config.startDate.map { $0 < target ? $0 : nil } ?? nil)
|
||||
?? Self.storedAnchor(key: anchorKey, target: target)
|
||||
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user