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.
105 lines
4.0 KiB
Swift
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
|
|
}
|
|
}
|