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>
210 lines
8.0 KiB
Swift
210 lines
8.0 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Creator (or owner, in the group combined view) of an event.
|
|
/// `id` is nil for imported events.
|
|
struct EventPerson: Hashable {
|
|
let id: Int?
|
|
let displayName: String
|
|
|
|
static func from(_ json: Any?) -> EventPerson? {
|
|
guard let obj = json as? [String: Any],
|
|
let name = obj["display_name"] as? String, !name.isEmpty else { return nil }
|
|
let id: Int?
|
|
if let n = obj["id"] as? Int { id = n }
|
|
else if let s = obj["id"] as? String { id = Int(s) }
|
|
else { id = nil }
|
|
return EventPerson(id: id, displayName: name)
|
|
}
|
|
}
|
|
|
|
/// A partial-sync failure reported alongside an otherwise-successful
|
|
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
|
|
/// expired credentials) even though it's still enabled. Distinct from a
|
|
/// hard fetch failure (`CalendarStore.lastError`) — the request itself
|
|
/// succeeded, just not every source within it.
|
|
struct SyncError: Hashable {
|
|
let source: String
|
|
let calendarId: String? // nil on older server responses without calendar_id
|
|
let name: String
|
|
let message: String
|
|
|
|
static func from(json: [String: Any]) -> SyncError? {
|
|
guard
|
|
let source = json["source"] as? String,
|
|
let name = json["name"] as? String,
|
|
let message = json["message"] as? String
|
|
else { return nil }
|
|
let calendarId = json["calendar_id"].map { "\($0)" }
|
|
return SyncError(source: source, calendarId: calendarId, name: name, message: message)
|
|
}
|
|
}
|
|
|
|
struct CalEvent: Identifiable, Hashable {
|
|
let id: String
|
|
let url: String
|
|
var title: String
|
|
var startDate: Date
|
|
var endDate: Date
|
|
var isAllDay: Bool
|
|
var location: String
|
|
var notes: String
|
|
var color: String?
|
|
var calendarId: String
|
|
var calendarName: String
|
|
var calendarColor: String
|
|
var source: String
|
|
var creator: EventPerson? = nil
|
|
var isPrivate: Bool = false
|
|
// Only set in the group combined view:
|
|
var owner: EventPerson? = nil
|
|
var isGroupEvent: Bool = false
|
|
var displayColor: String? = nil
|
|
// Server-decorated title for the group combined view (group icon / owner
|
|
// prefix); rendered in group mode while `title` stays raw for editing.
|
|
var displayTitle: String? = nil
|
|
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
|
|
var reminders: [Int] = []
|
|
// True for events from a calendar shared with the user read-only.
|
|
var readOnly: Bool = false
|
|
// True for events from a birthday calendar — clients show a cake icon and the
|
|
// server bakes the age into `displayTitle`.
|
|
var isBirthday: Bool = false
|
|
|
|
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
|
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
|
|
|
// Title to render: the server-decorated one (birthday age, group prefix) wins
|
|
// over the raw title, which is kept for editing.
|
|
var renderTitle: String { displayTitle ?? title }
|
|
|
|
static func from(json: [String: Any]) -> CalEvent? {
|
|
guard
|
|
let title = json["title"] as? String,
|
|
let startStr = json["start"] as? String,
|
|
let endStr = json["end"] as? String
|
|
else { return nil }
|
|
|
|
// id can be String (local UUID) or Int (CalDAV numeric)
|
|
let id: String
|
|
if let s = json["id"] as? String { id = s }
|
|
else if let n = json["id"] as? Int { id = String(n) }
|
|
else { return nil }
|
|
|
|
// 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 endDate = parseDate(endStr, allDay: isAllDay)
|
|
guard let s = startDate, let e = endDate else { return nil }
|
|
|
|
return CalEvent(
|
|
id: id,
|
|
url: json["url"] as? String ?? "",
|
|
title: title,
|
|
startDate: s,
|
|
endDate: e,
|
|
isAllDay: isAllDay,
|
|
location: json["location"] as? String ?? "",
|
|
notes: json["description"] as? String ?? "",
|
|
color: (json["color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
|
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
|
calendarName: json["calendar_name"] as? String ?? "",
|
|
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
|
|
source: json["source"] as? String ?? "local",
|
|
creator: EventPerson.from(json["creator"]),
|
|
isPrivate: json["private"] as? Bool ?? false,
|
|
owner: EventPerson.from(json["owner"]),
|
|
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
|
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
|
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
|
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
|
|
readOnly: json["read_only"] as? Bool ?? false,
|
|
isBirthday: json["is_birthday"] as? Bool ?? false
|
|
)
|
|
}
|
|
}
|
|
|
|
private let isoFull: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
|
|
private let isoBasic: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
|
|
private let dateOnly: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
f.timeZone = .current
|
|
return f
|
|
}()
|
|
|
|
// Handles all date formats the backend may produce:
|
|
// "2026-05-17" "2026-05-17T10:00:00Z" "2026-05-17T10:00:00+02:00"
|
|
// "2026-05-17T10:00:00.000Z" "2026-05-17T10:00:00" "2026-05-17 10:00:00+00:00"
|
|
private let noTZFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
|
f.timeZone = TimeZone(abbreviation: "UTC")
|
|
return f
|
|
}()
|
|
|
|
private let spaceSepFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.dateFormat = "yyyy-MM-dd HH:mm:ssZ"
|
|
return f
|
|
}()
|
|
|
|
func parseDate(_ s: String, allDay: Bool) -> Date? {
|
|
let clean = s.trimmingCharacters(in: .whitespaces)
|
|
if allDay || (clean.count == 10 && !clean.contains("T")) {
|
|
return dateOnly.date(from: String(clean.prefix(10)))
|
|
}
|
|
// Try each formatter in order of likelihood
|
|
if let d = isoFull.date(from: clean) { return d }
|
|
if let d = isoBasic.date(from: clean) { return d }
|
|
// Python isoformat uses space separator: "2026-05-17 10:00:00+00:00"
|
|
if let d = spaceSepFormatter.date(from: clean) { return d }
|
|
// No timezone → treat as UTC
|
|
if let d = noTZFormatter.date(from: String(clean.prefix(19))) { return d }
|
|
// Last resort: just parse the date part
|
|
return dateOnly.date(from: String(clean.prefix(10)))
|
|
}
|
|
|
|
func formatISO(_ date: Date, allDay: Bool) -> String {
|
|
if allDay {
|
|
return dateOnly.string(from: date)
|
|
}
|
|
return isoBasic.string(from: date)
|
|
}
|
|
|
|
/// Inline label for an event in a calendar grid: a leading birthday (cake) icon
|
|
/// when the event is a birthday, followed by the render title (which carries the
|
|
/// server-computed age). Icon and text inherit the surrounding font/colour, so
|
|
/// the label matches whatever bar it's dropped into.
|
|
struct EventLabel: View {
|
|
let event: CalEvent
|
|
var body: some View {
|
|
if event.isBirthday {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "birthday.cake.fill").imageScale(.small)
|
|
Text(event.renderTitle)
|
|
}
|
|
} else {
|
|
Text(event.renderTitle)
|
|
}
|
|
}
|
|
}
|