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 id: String // task UUID string
|
||||||
let title: String
|
let title: String
|
||||||
let dueDate: Date?
|
let dueDate: Date?
|
||||||
|
var isRecurring: Bool = false
|
||||||
|
var recurrenceLabel: String? = nil
|
||||||
|
|
||||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
|
||||||
static var defaultQuery = EventQuery()
|
static var defaultQuery = EventQuery()
|
||||||
@@ -102,7 +104,8 @@ struct EventQuery: EntityQuery {
|
|||||||
loadWidgetTasks()
|
loadWidgetTasks()
|
||||||
.filter { !$0.isComplete && $0.dueDate != nil }
|
.filter { !$0.isComplete && $0.dueDate != nil }
|
||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
.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()
|
loadWidgetTasks()
|
||||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > Date() }
|
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > Date() }
|
||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
.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 {
|
private func entry(for config: EventConfigIntent) -> EventEntry {
|
||||||
@@ -237,58 +254,37 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
if let trackedId = UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent"),
|
if let trackedId = UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent"),
|
||||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == trackedId }),
|
let task = loadWidgetTasks().first(where: { $0.id.uuidString == trackedId }),
|
||||||
let due = task.dueDate {
|
let due = task.dueDate {
|
||||||
// Mode B — recurring (birthdays/anniversaries): previous → next occurrence.
|
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
|
||||||
if task.isRecurring, let label = task.recurrenceLabel,
|
let s = resolveSpan(due: due, isRecurring: task.isRecurring, label: task.recurrenceLabel,
|
||||||
let span = Self.recurrenceSpan(label: label, due: due) {
|
anchorKey: trackedId, explicitStart: since)
|
||||||
return EventEntry(date: .now, eventName: task.title,
|
return EventEntry(date: .now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var name: String
|
// ── Configured (no tracked event) ──
|
||||||
var target: Date
|
|
||||||
var anchorKey: String
|
|
||||||
|
|
||||||
if config.upcomingQueue {
|
if config.upcomingQueue {
|
||||||
// Auto-track the Nth nearest upcoming event.
|
|
||||||
let upcoming = upcomingEvents()
|
let upcoming = upcomingEvents()
|
||||||
let idx = max(0, config.autoSelect.rawValue - 1)
|
let idx = max(0, config.autoSelect.rawValue - 1)
|
||||||
if idx < upcoming.count, let due = upcoming[idx].dueDate {
|
guard idx < upcoming.count, let due = upcoming[idx].dueDate else {
|
||||||
name = upcoming[idx].title
|
return EventEntry(date: .now, eventName: "No upcoming event",
|
||||||
target = due
|
start: Date(), target: Date().addingTimeInterval(86400), unitMode: mode)
|
||||||
anchorKey = upcoming[idx].id
|
|
||||||
} else {
|
|
||||||
name = "No upcoming event"
|
|
||||||
target = Date().addingTimeInterval(86400)
|
|
||||||
anchorKey = "none"
|
|
||||||
}
|
}
|
||||||
} else if let ev = config.event, let due = ev.dueDate {
|
let ev = upcoming[idx]
|
||||||
// A specifically picked event supplies its own name + date.
|
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||||
name = ev.title
|
anchorKey: ev.id, explicitStart: nil)
|
||||||
target = due
|
return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||||
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))"
|
|
||||||
}
|
}
|
||||||
|
if let ev = config.event, let due = ev.dueDate {
|
||||||
// Counting anchor: an explicit "Counting from" wins when it's before the
|
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||||
// event; otherwise remember when this event was first tracked so the bar
|
anchorKey: ev.id, explicitStart: config.startDate)
|
||||||
// fills steadily instead of resetting to "now" on every refresh.
|
return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||||
let start: Date
|
|
||||||
if let s = config.startDate, s < target {
|
|
||||||
start = s
|
|
||||||
} else {
|
|
||||||
start = Self.storedAnchor(key: anchorKey, target: target)
|
|
||||||
}
|
}
|
||||||
|
// 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)
|
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user