diff --git a/KisaniCalWidgets/EventCountdownWidget.swift b/KisaniCalWidgets/EventCountdownWidget.swift index f6f3fce..d5a1207 100644 --- a/KisaniCalWidgets/EventCountdownWidget.swift +++ b/KisaniCalWidgets/EventCountdownWidget.swift @@ -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) }