Files
KisaniCal/KisaniCalWidgets/EventCountdownWidget.swift
kutesir f6a9358a86 recurrence: consolidate 3 duplicate implementations into one shared, tested engine (KC-69)
Before adding "every N weeks/months" support, found the recurrence
window math was independently hand-written in three places (main app's
TaskItem.isOccurrence, and two separate widget extensions' countdown
progress calculations) with no shared code to keep them in sync.

Extracted the math into Shared/RecurrenceRule.swift (pure Foundation,
no framework deps) compiled into both the app and widget extension
targets via a new project.yml Shared/ source path, and pointed all
three call sites at it. Verified every case is identical to prior
behavior at interval==1 by compiling and running a standalone test
driver directly against the real file with swiftc - 38/38 passing,
including Jan-31-monthly and Feb-29-leap-year-yearly edge cases.

Groundwork only - no recurrenceInterval field on TaskItem yet, the
actual interval feature comes next on top of this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00

366 lines
16 KiB
Swift

import WidgetKit
import SwiftUI
import AppIntents
// MARK: - Configuration Intent
//
// Each widget instance stores its own event. The user sets a name, the day the
// countdown starts ("Counting from" defaults to the day they add the widget,
// which acts as the creation anchor) and the event date. Because these values are
// persisted per-instance by WidgetKit, the widget is fully self-contained and does
// not depend on App Group data sharing.
// Which upcoming event to auto-track when the queue is on.
enum AutoSelectRank: Int, AppEnum {
case first = 1, second, third, fourth, fifth
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Auto Select"
static var caseDisplayRepresentations: [AutoSelectRank: DisplayRepresentation] = [
.first: "Nearest to finish",
.second: "2nd nearest to finish",
.third: "3rd nearest to finish",
.fourth: "4th nearest to finish",
.fifth: "5th nearest to finish",
]
}
struct EventConfigIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Event Countdown"
static var description = IntentDescription("Count down to an event with a dot grid.")
// When on, the widget auto-tracks one of your upcoming events. When off, you
// track a specific event you pick, or a fully custom name + date.
@Parameter(title: "Upcoming queue", default: true)
var upcomingQueue: Bool
@Parameter(title: "Auto select", default: .first)
var autoSelect: AutoSelectRank
@Parameter(title: "Track event")
var event: EventEntity?
@Parameter(title: "Or event name", default: "My Event")
var eventName: String
// Optional so the rows read "Choose" until the user actually picks a date
// a same-second default for both made the bar span zero and looked broken.
@Parameter(title: "Event date")
var targetDate: Date?
@Parameter(title: "Counting from")
var startDate: Date?
// Show only the relevant fields depending on the toggle like a clean,
// single-purpose config screen instead of every field at once.
static var parameterSummary: some ParameterSummary {
When(\.$upcomingQueue, .equalTo, true) {
Summary("Track upcoming event") {
\.$upcomingQueue
\.$autoSelect
}
} otherwise: {
Summary("Track a custom event") {
\.$upcomingQueue
\.$event
\.$eventName
\.$targetDate
\.$startDate
}
}
}
}
// MARK: - Event picker (reads your tasks from the App Group)
struct EventEntity: AppEntity {
let id: String // task UUID string
let title: String
let dueDate: Date?
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var createdAt: Date? = nil
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
static var defaultQuery = EventQuery()
var displayRepresentation: DisplayRepresentation {
if let d = dueDate {
let f = DateFormatter(); f.dateStyle = .medium
return DisplayRepresentation(title: "\(title)", subtitle: "\(f.string(from: d))")
}
return DisplayRepresentation(title: "\(title)")
}
}
struct EventQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [EventEntity] {
allEvents().filter { identifiers.contains($0.id) }
}
func suggestedEntities() async throws -> [EventEntity] {
allEvents()
}
private func allEvents() -> [EventEntity] {
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,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
createdAt: $0.createdAt) }
}
}
// MARK: - Entry
// Tapping the countdown label cycles this unit (shared by all event widgets).
let countdownUnitKey = "kisani.widget.countdownUnit" // 0 = days, 1 = weeks, 2 = time
struct CycleCountdownUnitIntent: AppIntent {
static var title: LocalizedStringResource = "Change Countdown Unit"
func perform() async throws -> some IntentResult {
let cur = UserDefaults.kisani.integer(forKey: countdownUnitKey)
UserDefaults.kisani.set((cur + 1) % 3, forKey: countdownUnitKey)
return .result()
}
}
struct EventEntry: TimelineEntry {
let date: Date
let eventName: String
let start: Date
let target: Date
var unitMode: Int = 0 // 0 days, 1 weeks, 2 time
/// Fraction of the countdown window that has elapsed (01).
var progress: Double {
let span = target.timeIntervalSince(start)
guard span > 0 else { return target <= date ? 1 : 0 }
return min(1, max(0, date.timeIntervalSince(start) / span))
}
/// Whole days from start to target (the number of dots in the grid).
var totalDays: Int {
let cal = Calendar.current
let d = cal.dateComponents([.day],
from: cal.startOfDay(for: start),
to: cal.startOfDay(for: target)).day ?? 0
return max(1, d)
}
/// Days remaining until the event (never negative).
var daysLeft: Int {
let cal = Calendar.current
let d = cal.dateComponents([.day],
from: cal.startOfDay(for: date),
to: cal.startOfDay(for: target)).day ?? 0
return max(0, d)
}
/// Granular countdown for the bars widget: days hr min as it nears.
var fineTimeLeft: String {
if target <= date { return "Today" }
let secs = target.timeIntervalSince(date)
let days = Int(secs / 86400)
let hrs = Int(secs.truncatingRemainder(dividingBy: 86400) / 3600)
let mins = Int(secs.truncatingRemainder(dividingBy: 3600) / 60)
if days >= 1 { return hrs > 0 ? "\(days)d, \(hrs) hr left" : "\(days) day\(days == 1 ? "" : "s") left" }
if hrs >= 1 { return "\(hrs) hr, \(mins) min left" }
return "\(mins) min left"
}
/// Countdown from the current date. Tapping the label cycles the unit:
/// days weeks time remaining.
var timeLeftLabel: String {
if target <= date { return "Today" }
let cal = Calendar.current
switch unitMode {
case 1: // weeks
let days = cal.dateComponents([.day], from: cal.startOfDay(for: date),
to: cal.startOfDay(for: target)).day ?? 0
if days < 7 { return "< 1 week left" }
let weeks = days / 7
return "\(weeks) week\(weeks == 1 ? "" : "s") left"
case 2: // time remaining (detailed)
let c = cal.dateComponents([.day, .hour], from: date, to: target)
let days = c.day ?? 0, hours = c.hour ?? 0
if days == 0 { return hours <= 0 ? "Due now" : "\(hours) hr left" }
return hours > 0 ? "\(days)d, \(hours) hr left" : "\(days) day\(days == 1 ? "" : "s") left"
default: // days
let days = cal.dateComponents([.day], from: cal.startOfDay(for: date),
to: cal.startOfDay(for: target)).day ?? 0
return "\(days) day\(days == 1 ? "" : "s") left"
}
}
}
// MARK: - Provider
struct EventProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> EventEntry {
// ~90-day window, ~60% elapsed a clear, legible dot grid in the gallery.
EventEntry(date: .now,
eventName: "IELTS",
start: Date().addingTimeInterval(-86400 * 54),
target: Date().addingTimeInterval(86400 * 36))
}
func snapshot(for configuration: EventConfigIntent, in context: Context) async -> EventEntry {
entry(for: configuration)
}
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
let now = Date()
let step: TimeInterval = 1800 // 30 minutes
let count = 24 // ~12-hour pre-render window
// Re-resolve the selection at EACH step's own time, so the moment the nearest
// event expires the next entry rolls to the next-nearest automatically.
let entries: [EventEntry] = (0..<count).map { i in
entry(for: configuration, asOf: now.addingTimeInterval(Double(i) * step))
}
// Refresh at the window end, or right when the current selection expires
// (whichever is sooner) so the next event gets a fresh full pre-render.
let windowEnd = now.addingTimeInterval(Double(count) * step)
var next = windowEnd
if let expiry = currentSelectionExpiry(for: configuration, asOf: now),
expiry > now, expiry < windowEnd {
next = expiry.addingTimeInterval(1)
}
return Timeline(entries: entries, policy: .after(next))
}
/// Upcoming events (incomplete tasks with a due date still in the future *as of `now`*),
/// nearest first. Passing `now` lets future timeline steps drop just-expired events.
private func upcomingEvents(asOf now: Date) -> [EventEntity] {
loadWidgetTasks()
.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,
createdAt: $0.createdAt) }
}
/// The tracked task ("Track Countdown"), but only while it's still live: not
/// completed, and either recurring or with a deadline still ahead of `now`.
/// Returns nil once it's done/expired so the widget advances to the next event.
private func trackedTask(asOf now: Date) -> WidgetTask? {
guard let trackedId = UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent"),
let task = loadWidgetTasks().first(where: { $0.id.uuidString == trackedId }),
let due = task.dueDate,
!task.isComplete,
task.isRecurring || due > now
else { return nil }
return task
}
/// Due date the currently-shown event expires at, so the timeline can refresh
/// the moment it's done. Covers both the tracked event and the auto-select queue.
private func currentSelectionExpiry(for config: EventConfigIntent, asOf now: Date) -> Date? {
if let task = trackedTask(asOf: now) {
return task.isRecurring ? nil : task.dueDate
}
guard config.upcomingQueue else { return nil }
let upcoming = upcomingEvents(asOf: now)
let idx = max(0, config.autoSelect.rawValue - 1)
guard idx < upcoming.count else { return nil }
return upcoming[idx].dueDate
}
/// 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 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?,
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)
}
private func entry(for config: EventConfigIntent, asOf now: Date = Date()) -> EventEntry {
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
// An event tracked from the app ("Track Countdown") bypasses the widget
// configuration entirely. Once it's completed or its deadline has passed
// (and it isn't recurring), stop pinning it and fall through to the
// next-nearest event below so the widget always advances.
if let task = trackedTask(asOf: now) {
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,
taskCreatedAt: task.createdAt)
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
}
// Configured (no tracked event)
if config.upcomingQueue {
let upcoming = upcomingEvents(asOf: now)
let idx = max(0, config.autoSelect.rawValue - 1)
guard idx < upcoming.count, let due = upcoming[idx].dueDate else {
return EventEntry(date: now, eventName: "No upcoming event",
start: now, target: now.addingTimeInterval(86400), unitMode: mode)
}
let ev = upcoming[idx]
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
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,
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).
let name = config.eventName.isEmpty ? "My Event" : config.eventName
let target = config.targetDate
?? Calendar.current.startOfDay(for: now).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)
}
/// First-seen date for a tracked event, persisted in the App Group so progress
/// is stable across refreshes (and resets naturally when the event changes).
private static func storedAnchor(key: String, target: Date) -> Date {
let ud = UserDefaults.kisani
let k = "kisani.widget.anchor.\(key)"
if let saved = ud.object(forKey: k) as? Date, saved < target { return saved }
let now = Date()
ud.set(now, forKey: k)
return now
}
/// Mode B window for a recurring event: the occurrence span containing "now"
/// (previous occurrence next occurrence), derived from the rule label.
/// The actual math lives in RecurrenceRule (Shared/) shared with the main
/// app's occurrence matching and the other countdown widget's copy of this
/// same calculation, so none of the three can independently drift.
private static func recurrenceSpan(label: String, due: Date) -> (prev: Date, next: Date)? {
RecurrenceRule.span(label: label, due: due, now: Date())
}
}
// MARK: - Event Countdown (Bars)
struct EventBarsWidget: Widget {
let kind = "KisaniEventBars"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: EventConfigIntent.self, provider: EventProvider()) { entry in
EventBarsView(entry: entry)
}
.configurationDisplayName("Event Countdown (Bars)")
.description("Count down to an event with a bar meter.")
.supportedFamilies([.systemMedium])
}
}