Files
Guido Schmit 70d4d67cca Phase 3: Kalender-Widget mit echten EventKit-Daten
Die Unterscheidung zwischen "keine Termine" und "keine Information" steckt von
Anfang an in den Grundlagen, nicht als Nachtrag für Calendarr. Ein Kalender
ohne Termine und ein Kalender, über den nichts bekannt ist, sehen in einer
Liste identisch aus — nämlich leer. Ein leerer März ist überzeugend und
schlicht falsch, wenn die Quelle nur 49 Tage abdeckt.

EventWindow trägt deshalb eine coverage mit. Bei EventKit ist sie der
angefragte Zeitraum, weil direkt abgefragt wird; bei Calendarr wird sie später
aus dem Snapshot kommen. Tage außerhalb werden im Mini-Monat ausgegraut und
tragen einen erklärenden Hinweis, statt als frei durchzugehen.

Die Abdeckung gewinnt auch gegen widersprüchliche Eingaben: liefert eine Quelle
einen Termin für einen Tag, den sie nach eigener Angabe nicht abdeckt, bleibt
der Tag unbekannt. Sonst behauptet die Anzeige mehr zu wissen, als belegt ist.
Als Test festgehalten.

CalendarSourceState statt eines Optionals: denied, neverWritten, loggedOut,
incompatible und unreadable bekommen jeweils eine eigene Antwort in der
Oberfläche. "Nichts anzuzeigen" ist keine.

Serientermine bekommen eine aus Kennung und Beginn zusammengesetzte ID —
EKEvent.eventIdentifier ist für alle Vorkommen gleich, eine Terminliste mit
einer täglichen Serie fiele sonst auf einen Eintrag zusammen.

Monatsraster testgetrieben, inklusive Schaltjahr, Wochenstart Montag wie
Sonntag und der Regel, dass das Ende der Abdeckung nicht mehr dazugehört.

93 Tests grün.
2026-08-10 18:07:44 +02:00

105 lines
4.0 KiB
Swift

import Foundation
/// Was über einen Tag bekannt ist.
public enum DayStatus: Equatable, Sendable {
/// Keine Termine und das ist gesicherte Information.
case free
/// Mindestens ein Termin.
case busy
/// Die Quelle deckt diesen Tag nicht ab. **Nicht dasselbe wie frei.**
case unknown
}
/// Ein Tag im Monatsraster.
public struct MonthDay: Identifiable, Equatable, Sendable {
public let date: Date
/// Gehört der Tag zum dargestellten Monat oder füllt er nur die Woche auf?
public let isInMonth: Bool
public var id: Date { date }
}
/// Das Raster eines Monats, immer aus vollen Wochen.
///
/// Die Wochen füllen sich mit Tagen aus den Nachbarmonaten auf. Eine
/// angeschnittene erste Zeile würde die Spaltenausrichtung brechen, und dann
/// steht der Dienstag nicht mehr unter Di".
public struct MonthGrid: Equatable, Sendable {
public let month: Date
public let days: [MonthDay]
private let calendar: Calendar
public init(containing date: Date, calendar: Calendar) {
self.calendar = calendar
let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: date))!
self.month = startOfMonth
let dayCount = calendar.range(of: .day, in: .month, for: startOfMonth)!.count
// Wie viele Tage aus dem Vormonat vorne stehen müssen, damit die erste
// Zeile am eingestellten Wochenanfang beginnt.
let weekdayOfFirst = calendar.component(.weekday, from: startOfMonth)
let leading = (weekdayOfFirst - calendar.firstWeekday + 7) % 7
var days: [MonthDay] = []
for offset in 0..<leading {
let d = calendar.date(byAdding: .day, value: offset - leading, to: startOfMonth)!
days.append(MonthDay(date: calendar.startOfDay(for: d), isInMonth: false))
}
for offset in 0..<dayCount {
let d = calendar.date(byAdding: .day, value: offset, to: startOfMonth)!
days.append(MonthDay(date: calendar.startOfDay(for: d), isInMonth: true))
}
// Hinten auf volle Wochen auffüllen.
while days.count % 7 != 0 {
let d = calendar.date(byAdding: .day, value: 1, to: days.last!.date)!
days.append(MonthDay(date: calendar.startOfDay(for: d), isInMonth: false))
}
self.days = days
}
/// Was über diesen Tag gesagt werden darf.
///
/// Die Abdeckung entscheidet zuerst. Liegt der Tag außerhalb, ist das
/// Ergebnis `unknown` auch dann, wenn Termine dafür geliefert wurden.
/// Andernfalls behauptet die Anzeige mehr zu wissen, als die Quelle belegt.
public func status(for date: Date,
coverage: DateInterval?,
busyDays: Set<Date>) -> DayStatus {
let day = calendar.startOfDay(for: date)
if let coverage {
// Halboffenes Intervall [start, end) so legt es die
// Calendarr-Anleitung fest, und `DateInterval.contains` schließt
// das Ende ein, deshalb der eigene Vergleich.
guard day >= calendar.startOfDay(for: coverage.start),
day < calendar.startOfDay(for: coverage.end) else { return .unknown }
}
return busyDays.contains(day) ? .busy : .free
}
public static func == (lhs: MonthGrid, rhs: MonthGrid) -> Bool {
lhs.month == rhs.month && lhs.days == rhs.days
}
}
public extension Collection where Element == OnyxEvent {
/// Die Tage, an denen etwas stattfindet für das Monatsraster.
/// Mehrtägige Termine markieren jeden berührten Tag.
func busyDays(calendar: Calendar) -> Set<Date> {
var days = Set<Date>()
for event in self {
var day = calendar.startOfDay(for: event.start)
let last = calendar.startOfDay(for: event.end)
while day <= last {
days.insert(day)
guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break }
day = next
}
}
return days
}
}