Read all-day events correctly, and make the date circle say a date

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>
This commit is contained in:
Scarriffle
2026-09-12 14:10:21 +02:00
co-authored by Claude Opus 5
parent 1a1230a796
commit 6e91eaaaa2
3 changed files with 71 additions and 14 deletions
+8 -1
View File
@@ -91,7 +91,14 @@ struct CalEvent: Identifiable, Hashable {
else if let n = json["id"] as? Int { id = String(n) } else if let n = json["id"] as? Int { id = String(n) }
else { return nil } else { return nil }
let isAllDay = json["allDay"] as? Bool ?? false // A date-only string is an all-day event whether or not the flag says
// so — `parseDate` already treats it that way. Trusting the flag alone
// is what put "00:00" in front of birthdays everywhere they are shown.
let startIsDateOnly: Bool = {
let clean = startStr.trimmingCharacters(in: .whitespaces)
return clean.count == 10 && !clean.contains("T")
}()
let isAllDay = (json["allDay"] as? Bool ?? false) || startIsDateOnly
let startDate = parseDate(startStr, allDay: isAllDay) let startDate = parseDate(startStr, allDay: isAllDay)
let endDate = parseDate(endStr, allDay: isAllDay) let endDate = parseDate(endStr, allDay: isAllDay)
guard let s = startDate, let e = endDate else { return nil } guard let s = startDate, let e = endDate else { return nil }
@@ -67,7 +67,10 @@ struct CountdownComplicationView: View {
} }
} }
/// Day number and month. No appointment information — a date replacement. /// Weekday and day number, the way a date is actually read. The ring appears
/// only while an appointment is running, and then shows how far through it we
/// are — an empty track around an idle day is noise, so there is no Gauge at
/// all in that case.
struct DateComplicationView: View { struct DateComplicationView: View {
let entry: WatchComplicationEntry let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family @Environment(\.widgetFamily) private var family
@@ -76,26 +79,47 @@ struct DateComplicationView: View {
Calendar(identifier: .gregorian).component(.day, from: entry.date) Calendar(identifier: .gregorian).component(.day, from: entry.date)
} }
private var weekday: String {
WatchComplicationSupport.weekdayAbbreviation(entry.date, language: entry.language)
}
var body: some View { var body: some View {
switch family { switch family {
case .accessoryInline: case .accessoryInline:
Label("\(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) \(day)", Label("\(weekday) \(day)", systemImage: "calendar")
systemImage: "calendar")
default: default:
// A closed gauge, filling the slot edge to edge — the WidgetKit circular
// equivalent of ClockKit's ClosedGaugeText, which no longer exists. }
// The ring is not decoration: it drains as the day does. }
Gauge(value: WatchComplicationSupport.dayRemainingFraction(at: entry.date)) {
Text(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) @ViewBuilder
private var circular: some View {
if let running = WatchComplicationSupport.runningEvent(in: entry.snapshot, at: entry.date) {
Gauge(value: WatchComplicationSupport.progress(of: running, at: entry.date)) {
Text(weekday)
} currentValueLabel: { } currentValueLabel: {
Text("\(day)") dateStack
.font(.system(size: 20, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.6)
} }
.gaugeStyle(.accessoryCircularCapacity) .gaugeStyle(.accessoryCircularCapacity)
.widgetAccentable()
.eventBezelLabel(entry) .eventBezelLabel(entry)
} else {
ZStack {
AccessoryWidgetBackground()
dateStack
}
.eventBezelLabel(entry)
}
}
private var dateStack: some View {
VStack(spacing: -2) {
Text(weekday)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
Text("\(day)")
.font(.system(size: 23, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.7)
} }
} }
} }
@@ -22,6 +22,32 @@ enum WatchComplicationSupport {
return snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }.count 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 /// 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. /// end. Counting up from a start time that has passed reads as broken.
static func countdownTarget(_ event: SnapshotEvent, at now: Date) -> Date { static func countdownTarget(_ event: SnapshotEvent, at now: Date) -> Date {