Archived
Three things, one of which is not a watch problem at all. parseDate already treated a date-only string as all-day, but isAllDay came from the JSON flag alone. An event sent as "2026-09-12" with no flag therefore landed at midnight with isAllDay false, and every surface that formats a time put "00:00" in front of it — the widgets as much as the watch. Now derived from both. The date complication showed the day number and nothing else, because .accessoryCircularCapacity renders only currentValueLabel and the month sat in the label closure, where it is never drawn. It now stacks weekday over day number, which is what makes it read as a date rather than a number. The ring only appears while an appointment is running, and then shows how far through it we are. An empty track around an idle day said nothing, so there is no Gauge at all in that case. All-day events do not count as running: one would hold the ring at an arbitrary fraction for twenty-four hours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
132 lines
6.4 KiB
Swift
132 lines
6.4 KiB
Swift
import Foundation
|
|
import CalendarrCore
|
|
|
|
enum WatchComplicationSupport {
|
|
/// The first event that has not ended yet — including one running now,
|
|
/// which is what "what is next" means when you are already in a meeting.
|
|
/// Matches `WatchPushPolicy.nextEventIdentity`, deliberately.
|
|
static func nextEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? {
|
|
snapshot?.events.filter { $0.end > now }.min { $0.start < $1.start }
|
|
}
|
|
|
|
static func upcoming(in snapshot: CalendarrSnapshot?, at now: Date, limit: Int) -> [SnapshotEvent] {
|
|
guard let snapshot else { return [] }
|
|
return Array(snapshot.events.filter { $0.end > now }.sorted { $0.start < $1.start }.prefix(limit))
|
|
}
|
|
|
|
static func todayCount(in snapshot: CalendarrSnapshot?, at now: Date,
|
|
calendar: Calendar = Calendar(identifier: .gregorian)) -> Int {
|
|
guard let snapshot else { return 0 }
|
|
let dayStart = calendar.startOfDay(for: now)
|
|
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
|
|
return snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }.count
|
|
}
|
|
|
|
/// The appointment happening right now, if there is one. All-day events are
|
|
/// excluded on purpose: one of those is "running" for twenty-four hours and
|
|
/// would hold the ring at some arbitrary fraction all day, which says
|
|
/// nothing.
|
|
static func runningEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? {
|
|
snapshot?.events
|
|
.filter { !$0.isAllDay && $0.start <= now && $0.end > now }
|
|
.min { $0.end < $1.end }
|
|
}
|
|
|
|
/// How far through an appointment we are, 0 at its start and 1 at its end.
|
|
static func progress(of event: SnapshotEvent, at now: Date) -> Double {
|
|
let total = event.end.timeIntervalSince(event.start)
|
|
guard total > 0 else { return 0 }
|
|
return min(1, max(0, now.timeIntervalSince(event.start) / total))
|
|
}
|
|
|
|
static func weekdayAbbreviation(_ date: Date, language: String) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = WatchL10n.locale(language)
|
|
formatter.dateFormat = "EEE"
|
|
return formatter.string(from: date)
|
|
.replacingOccurrences(of: ".", with: "")
|
|
.uppercased()
|
|
}
|
|
|
|
/// What a countdown counts to: the start if it has not begun, otherwise the
|
|
/// end. Counting up from a start time that has passed reads as broken.
|
|
static func countdownTarget(_ event: SnapshotEvent, at now: Date) -> Date {
|
|
event.start > now ? event.start : event.end
|
|
}
|
|
|
|
/// A countdown narrow enough for a corner or a circular gauge: at most
|
|
/// three characters. `Text(style: .timer)` would be live and free of
|
|
/// timeline entries, but renders `1:23:45` and overlaps whatever sits
|
|
/// beside it — so the timeline carries minute-granular entries instead and
|
|
/// this is recomputed per entry.
|
|
static func compactCountdown(to target: Date, at now: Date, language: String) -> String {
|
|
let seconds = target.timeIntervalSince(now)
|
|
guard seconds > 0 else { return WatchL10n.t("watch.now", language) }
|
|
let minutes = Int(seconds / 60)
|
|
if minutes < 1 { return WatchL10n.t("watch.now", language) }
|
|
if minutes < 60 { return "\(minutes)m" }
|
|
let hours = minutes / 60
|
|
if hours < 24 { return "\(hours)h" }
|
|
return "\(hours / 24)d"
|
|
}
|
|
|
|
/// How full a draining ring should be, over a two-hour lead window.
|
|
static func gaugeFraction(to target: Date, at now: Date) -> Double {
|
|
let window: TimeInterval = 2 * 3600
|
|
return min(1, max(0, target.timeIntervalSince(now) / window))
|
|
}
|
|
|
|
/// How much of today is still ahead. Drains through the day, matching the
|
|
/// countdown ring rather than inventing a second direction.
|
|
static func dayRemainingFraction(at now: Date,
|
|
calendar: Calendar = Calendar(identifier: .gregorian)) -> Double {
|
|
let dayStart = calendar.startOfDay(for: now)
|
|
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
|
|
let total = dayEnd.timeIntervalSince(dayStart)
|
|
guard total > 0 else { return 0 }
|
|
return min(1, max(0, dayEnd.timeIntervalSince(now) / total))
|
|
}
|
|
|
|
/// The share of today's appointments still ahead. Zero when there are none,
|
|
/// which leaves the ring empty rather than full — an empty day should not
|
|
/// look like an untouched one.
|
|
static func todayRemainingFraction(in snapshot: CalendarrSnapshot?, at now: Date,
|
|
calendar: Calendar = Calendar(identifier: .gregorian)) -> Double {
|
|
guard let snapshot else { return 0 }
|
|
let dayStart = calendar.startOfDay(for: now)
|
|
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
|
|
let today = snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }
|
|
guard !today.isEmpty else { return 0 }
|
|
let ahead = today.filter { $0.end > now }.count
|
|
return min(1, max(0, Double(ahead) / Double(today.count)))
|
|
}
|
|
|
|
static func time(_ date: Date, language: String) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = WatchL10n.locale(language)
|
|
formatter.dateFormat = "HH:mm"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
static func monthAbbreviation(_ date: Date, language: String) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = WatchL10n.locale(language)
|
|
formatter.dateFormat = "LLL"
|
|
return formatter.string(from: date).uppercased()
|
|
}
|
|
|
|
/// The curved label that accompanies a circular or corner complication:
|
|
/// time plus title, or a plain "no events".
|
|
///
|
|
/// On a corner this follows the screen edge. On a face with a bezel slot —
|
|
/// Infograph — the same label arcs across the top, where the tick marks
|
|
/// otherwise are. That is the position ClockKit called `graphicBezel`, and
|
|
/// in WidgetKit it is reached by attaching `.widgetLabel` to an
|
|
/// `accessoryCircular` widget.
|
|
static func eventLabel(_ event: SnapshotEvent?, at now: Date, language: String) -> String {
|
|
guard let event else { return WatchL10n.t("watch.no_events", language) }
|
|
if event.isAllDay { return event.title }
|
|
return "\(time(event.start, language: language)) \(event.title)"
|
|
}
|
|
}
|