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:
kutesir
2026-06-11 23:20:46 +03:00
parent b6eaba78ba
commit 2b48509a35

View File

@@ -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)
}