diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 146090e..e3ccc21 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; }; B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; }; + CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; }; EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; /* End PBXBuildFile section */ @@ -41,6 +42,7 @@ EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */, 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */, B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */, + CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -116,6 +118,7 @@ 3A32CAE6CC4D01D660F0016F /* OnyxNotch */, E66577A40689DF225BCED184 /* OnyxWidgetKit */, A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */, + A3E3949D664131D593CBEEDC /* CalendarProvider */, ); productName = Onyx; productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; @@ -393,6 +396,10 @@ isa = XCSwiftPackageProductDependency; productName = OnyxDesign; }; + A3E3949D664131D593CBEEDC /* CalendarProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = CalendarProvider; + }; A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */ = { isa = XCSwiftPackageProductDependency; productName = OnyxMenuBar; diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 5d77b93..7201bbd 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -5,6 +5,7 @@ import OnyxDesign import OnyxNotch import OnyxMenuBar import OnyxWidgetKit +import CalendarProvider @main struct OnyxApp: App { @@ -33,6 +34,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var coordinator: NotchCoordinator? private var menuBar: MenuBarController? private var onyxItem: NSStatusItem? + private var calendarModel: CalendarModel? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -46,7 +48,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } - PlaceholderWidgets.registerAll() + registerWidgets() let model = AppModel() self.model = model @@ -83,6 +85,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menuBar?.stop() } + /// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt. + private func registerWidgets() { + let calendarModel = CalendarModel(source: EventKitSource()) + self.calendarModel = calendarModel + WidgetRegistry.shared.register(CalendarWidget(model: calendarModel)) + + PlaceholderWidgets.registerAll() + } + private func applyContentSize() { guard let model, let coordinator else { return } let size = model.panelContentSize diff --git a/Onyx/PlaceholderWidgets.swift b/Onyx/PlaceholderWidgets.swift index c7dbf52..b3fa9f7 100644 --- a/Onyx/PlaceholderWidgets.swift +++ b/Onyx/PlaceholderWidgets.swift @@ -39,10 +39,11 @@ struct PlaceholderWidget: OnyxWidget { @MainActor enum PlaceholderWidgets { + /// Registriert nur noch die Karten, deren echte Fassung fehlt. Sobald ein + /// Widget umgesetzt ist, verschwindet es hier — die Kennung bleibt gleich, + /// damit gespeicherte Layouts weitergelten. static func registerAll() { let widgets: [PlaceholderWidget] = [ - .init(id: "calendar", displayName: "Kalender", symbolName: "calendar", - supportedSizes: [.medium, .large], phase: "Phase 3"), .init(id: "weather", displayName: "Wetter", symbolName: "cloud.sun", supportedSizes: [.small, .medium], phase: "Phase 3"), .init(id: "media", displayName: "Medien", symbolName: "play.circle", diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index 683f42b..47f06a5 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -13,6 +13,7 @@ let package = Package( .library(name: "OnyxNotch", targets: ["OnyxNotch"]), .library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]), .library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]), + .library(name: "CalendarProvider", targets: ["CalendarProvider"]), ], targets: [ // Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt @@ -31,5 +32,9 @@ let package = Package( .target(name: "OnyxMenuBar", dependencies: ["OnyxDesign", "OnyxWidgetKit"]), .testTarget(name: "OnyxMenuBarTests", dependencies: ["OnyxMenuBar"]), + + .target(name: "CalendarProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], + resources: [.process("Localizable.xcstrings")]), + .testTarget(name: "CalendarProviderTests", dependencies: ["CalendarProvider"]), ] ) diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarSource.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSource.swift new file mode 100644 index 0000000..5d4df09 --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSource.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Ein Termin, unabhängig davon, woher er stammt. +public struct OnyxEvent: Identifiable, Equatable, Sendable { + public let id: String + public let title: String + public let start: Date + public let end: Date + public let isAllDay: Bool + /// `#RRGGBB` der Quelle. Kalenderfarben sind die eine Stelle, an der Onyx + /// fremde Farben übernimmt statt eigene zu setzen — sonst erkennt man + /// seine Kalender nicht wieder. + public let colorHex: String? + public let location: String? + public let calendarID: String + public let calendarTitle: String + + public init(id: String, title: String, start: Date, end: Date, isAllDay: Bool, + colorHex: String?, location: String?, + calendarID: String, calendarTitle: String) { + self.id = id + self.title = title + self.start = start + self.end = end + self.isAllDay = isAllDay + self.colorHex = colorHex + self.location = location + self.calendarID = calendarID + self.calendarTitle = calendarTitle + } +} + +public struct OnyxCalendar: Identifiable, Equatable, Sendable { + public let id: String + public let title: String + public let colorHex: String? + + public init(id: String, title: String, colorHex: String?) { + self.id = id + self.title = title + self.colorHex = colorHex + } +} + +/// Termine **samt der Angabe, wofür überhaupt Daten vorliegen.** +/// +/// Das ist der Kern und nicht ein Detail: 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. `coverage` trennt beides. +public struct EventWindow: Equatable, Sendable { + public let events: [OnyxEvent] + /// Zeitraum mit gesicherter Information. `nil` bedeutet unbegrenzt — + /// so verhält sich der Apple-Kalender, weil er direkt abgefragt wird. + public let coverage: DateInterval? + /// Stand der Daten, falls die Quelle zwischengespeichert liefert. + public let asOf: Date? + + public init(events: [OnyxEvent], coverage: DateInterval? = nil, asOf: Date? = nil) { + self.events = events + self.coverage = coverage + self.asOf = asOf + } + + public static let empty = EventWindow(events: []) +} + +/// Warum gerade keine Termine da sind. Jeder Fall braucht eine eigene Antwort +/// in der Oberfläche — „nichts anzuzeigen" ist keine. +public enum CalendarSourceState: Equatable, Sendable { + case ready(EventWindow) + /// Berechtigung nicht erteilt. + case denied + /// Die Quelle wurde eingerichtet, hat aber noch nie geschrieben. + case neverWritten + /// Angemeldet sein wäre nötig. + case loggedOut(asOf: Date?) + /// Die Quelle ist neuer als das, was Onyx lesen kann. + case incompatible(found: Int, maxSupported: Int) + case unreadable(reason: String) +} + +/// Eine Quelle für Termine. Apple-Kalender und Calendarr liefern beide dies. +public protocol CalendarSource: Sendable { + var id: String { get } + func requestAccess() async -> Bool + func calendars() async -> [OnyxCalendar] + func load(_ interval: DateInterval) async -> CalendarSourceState +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift new file mode 100644 index 0000000..7f450f9 --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift @@ -0,0 +1,296 @@ +import SwiftUI +import OnyxDesign +import OnyxWidgetKit + +/// Hält die Termine für das Widget und lädt sie nach. +@MainActor +@Observable +public final class CalendarModel { + + public private(set) var state: CalendarSourceState = .ready(.empty) + public private(set) var isLoading = false + + private let source: CalendarSource + private var calendar: Calendar + private var refreshTask: Task? + + public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) { + self.source = source + self.calendar = calendar + } + + public var window: EventWindow? { + if case .ready(let window) = state { return window } + return nil + } + + /// Termine für den sichtbaren Monat plus Puffer nach beiden Seiten, damit + /// die Randtage der Nachbarmonate im Raster nicht fälschlich leer wirken. + public func refresh(around date: Date = Date()) { + refreshTask?.cancel() + refreshTask = Task { [weak self] in + guard let self else { return } + isLoading = true + defer { isLoading = false } + + guard await source.requestAccess() else { + state = .denied + return + } + let start = calendar.date(byAdding: .day, value: -14, + to: calendar.startOfDay(for: date))! + let end = calendar.date(byAdding: .day, value: 60, to: start)! + guard !Task.isCancelled else { return } + state = await source.load(DateInterval(start: start, end: end)) + } + } + + public func stop() { + refreshTask?.cancel() + refreshTask = nil + } + + public var upcoming: [OnyxEvent] { + guard let window else { return [] } + let now = Date() + return window.events + .filter { $0.end >= now } + .prefix(6) + .map { $0 } + } +} + +// MARK: - Widget + +public struct CalendarWidget: OnyxWidget { + public let id = "calendar" + public var displayName: String { String(localized: "widget.calendar.name", bundle: .module) } + public let symbolName = "calendar" + public let supportedSizes: [WidgetSize] = [.medium, .large] + + private let model: CalendarModel + + public init(model: CalendarModel) { + self.model = model + } + + public func makeView(size: WidgetSize) -> AnyView { + AnyView(CalendarWidgetView(model: model, size: size)) + } +} + +private struct CalendarWidgetView: View { + let model: CalendarModel + let size: WidgetSize + + var body: some View { + Group { + switch model.state { + case .ready(let window): + if size == .large { + LargeCalendar(window: window, upcoming: model.upcoming) + } else { + AgendaList(events: model.upcoming, coverage: window.coverage) + } + case .denied: + CalendarNotice(symbol: "lock", text: "widget.calendar.denied") + case .neverWritten: + CalendarNotice(symbol: "clock.arrow.circlepath", text: "widget.calendar.neverWritten") + case .loggedOut: + CalendarNotice(symbol: "person.crop.circle.badge.xmark", text: "widget.calendar.loggedOut") + case .incompatible: + CalendarNotice(symbol: "exclamationmark.triangle", text: "widget.calendar.incompatible") + case .unreadable: + CalendarNotice(symbol: "questionmark.folder", text: "widget.calendar.unreadable") + } + } + .task { model.refresh() } + .onDisappear { model.stop() } + } +} + +/// Kein Platzhalter, sondern eine Antwort: jeder Zustand sagt, was zu tun ist. +private struct CalendarNotice: View { + let symbol: String + let text: LocalizedStringKey + + var body: some View { + VStack(spacing: 6) { + Image(systemName: symbol) + .font(.system(size: 16)) + .foregroundStyle(Onyx.Color.textTertiary) + Text(text, bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Mini-Monat + +private struct LargeCalendar: View { + let window: EventWindow + let upcoming: [OnyxEvent] + + private var calendar: Calendar { .autoupdatingCurrent } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + MiniMonth(window: window) + Divider().overlay(Onyx.Color.hairline) + AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage) + } + } +} + +private struct MiniMonth: View { + let window: EventWindow + + private var calendar: Calendar { .autoupdatingCurrent } + + var body: some View { + let grid = MonthGrid(containing: Date(), calendar: calendar) + let busy = window.events.busyDays(calendar: calendar) + let today = calendar.startOfDay(for: Date()) + + VStack(spacing: 3) { + HStack(spacing: 0) { + ForEach(weekdaySymbols, id: \.self) { symbol in + Text(symbol) + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(Onyx.Color.textTertiary) + .frame(maxWidth: .infinity) + } + } + ForEach(0..<(grid.days.count / 7), id: \.self) { week in + HStack(spacing: 0) { + ForEach(grid.days[(week * 7)..<(week * 7 + 7)]) { day in + DayCell(day: day, + status: grid.status(for: day.date, + coverage: window.coverage, + busyDays: busy), + isToday: day.date == today) + } + } + } + } + } + + /// Wochentagskürzel ab dem eingestellten Wochenanfang. + private var weekdaySymbols: [String] { + let symbols = calendar.veryShortStandaloneWeekdaySymbols + let shift = calendar.firstWeekday - 1 + return Array(symbols[shift...] + symbols[..> 16) & 0xFF) / 255, + green: Double((number >> 8) & 0xFF) / 255, + blue: Double(number & 0xFF) / 255) + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/EventKitSource.swift b/Packages/OnyxKit/Sources/CalendarProvider/EventKitSource.swift new file mode 100644 index 0000000..8049c49 --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/EventKitSource.swift @@ -0,0 +1,95 @@ +import Foundation +import EventKit +import AppKit + +/// Termine aus dem Apple-Kalender. Ausschließlich lesend. +public final class EventKitSource: CalendarSource, @unchecked Sendable { + + public let id = "eventkit" + private let store = EKEventStore() + + /// Kalender, die der Nutzer ausgeblendet hat. Leer heißt: alle anzeigen. + public var excludedCalendarIDs: Set = [] + + public init() {} + + public func requestAccess() async -> Bool { + switch EKEventStore.authorizationStatus(for: .event) { + case .fullAccess: + return true + case .denied, .restricted: + return false + case .notDetermined, .writeOnly: + // `writeOnly` ist für uns wertlos — Onyx zeigt nur an. Der Nutzer + // muss den Vollzugriff geben, sonst bleibt das Widget leer. + return (try? await store.requestFullAccessToEvents()) ?? false + @unknown default: + return false + } + } + + public func calendars() async -> [OnyxCalendar] { + guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { return [] } + return store.calendars(for: .event).map { + OnyxCalendar(id: $0.calendarIdentifier, + title: $0.title, + colorHex: $0.cgColor.map(Self.hex(from:))) + } + } + + public func load(_ interval: DateInterval) async -> CalendarSourceState { + guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { + return .denied + } + + let calendars = store.calendars(for: .event) + .filter { !excludedCalendarIDs.contains($0.calendarIdentifier) } + + // Ohne Kalender liefert EventKit alle statt keiner — der Unterschied + // zwischen „nichts ausgewählt" und „nicht gefiltert" geht sonst verloren. + guard !calendars.isEmpty else { + return .ready(EventWindow(events: [], coverage: interval)) + } + + let predicate = store.predicateForEvents(withStart: interval.start, + end: interval.end, + calendars: calendars) + let events = store.events(matching: predicate).map(Self.convert) + + // EventKit wird direkt abgefragt: was hier zurückkommt, ist vollständig. + // Die Abdeckung ist deshalb genau der angefragte Zeitraum — anders als + // bei Calendarr, das aus einem begrenzten Zwischenspeicher liest. + return .ready(EventWindow(events: events.sorted { $0.start < $1.start }, + coverage: interval, + asOf: Date())) + } + + // MARK: - Umwandlung + + private static func convert(_ event: EKEvent) -> OnyxEvent { + OnyxEvent( + // `eventIdentifier` ist bei Serienterminen für alle Vorkommen + // gleich. Zusammen mit dem Beginn wird daraus eine eindeutige + // Kennung — sonst kollabiert eine Terminliste mit einer täglichen + // Serie auf einen einzigen Eintrag. + id: "\(event.eventIdentifier ?? UUID().uuidString)@\(event.startDate.timeIntervalSince1970)", + title: event.title ?? "", + start: event.startDate, + end: event.endDate, + isAllDay: event.isAllDay, + colorHex: event.calendar.cgColor.map(hex(from:)), + location: event.location?.isEmpty == false ? event.location : nil, + calendarID: event.calendar.calendarIdentifier, + calendarTitle: event.calendar.title) + } + + private static func hex(from color: CGColor) -> String { + guard let converted = NSColor(cgColor: color)?.usingColorSpace(.sRGB) else { + return "#8E8E93" + } + return String(format: "#%02X%02X%02X", + Int(round(converted.redComponent * 255)), + Int(round(converted.greenComponent * 255)), + Int(round(converted.blueComponent * 255))) + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings new file mode 100644 index 0000000..763fbf2 --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings @@ -0,0 +1,62 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "widget.calendar.name" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Kalender" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Calendar" } } + } + }, + "widget.calendar.noUpcoming" : { + "comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist", + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Nichts mehr für heute" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing left today" } } + } + }, + "widget.calendar.allDay" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ganztägig" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "All day" } } + } + }, + "widget.calendar.day.unknown" : { + "comment" : "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine", + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Für diesen Tag liegen keine Daten vor" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "No data available for this day" } } + } + }, + "widget.calendar.denied" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "No calendar access.\nAllow it in System Settings." } } + } + }, + "widget.calendar.neverWritten" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Open Calendarr once\nso events become available." } } + } + }, + "widget.calendar.loggedOut" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "In Calendarr anmelden" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Sign in to Calendarr" } } + } + }, + "widget.calendar.incompatible" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Onyx is too old for this\nCalendarr version. Please update." } } + } + }, + "widget.calendar.unreadable" : { + "localizations" : { + "de" : { "stringUnit" : { "state" : "translated", "value" : "Termine nicht lesbar.\nKonfigurationsfehler." } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Events unreadable.\nConfiguration error." } } + } + } + }, + "version" : "1.0" +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/MonthGrid.swift b/Packages/OnyxKit/Sources/CalendarProvider/MonthGrid.swift new file mode 100644 index 0000000..6685e6c --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/MonthGrid.swift @@ -0,0 +1,104 @@ +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..) -> 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 { + var days = Set() + 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 + } +} diff --git a/Packages/OnyxKit/Tests/CalendarProviderTests/MonthGridTests.swift b/Packages/OnyxKit/Tests/CalendarProviderTests/MonthGridTests.swift new file mode 100644 index 0000000..ff45074 --- /dev/null +++ b/Packages/OnyxKit/Tests/CalendarProviderTests/MonthGridTests.swift @@ -0,0 +1,138 @@ +import Testing +import Foundation +@testable import CalendarProvider + +@Suite("Monatsraster") +struct MonthGridTests { + + private var calendar: Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Berlin")! + c.firstWeekday = 2 // Montag + return c + } + + private func date(_ string: String) -> Date { + let f = DateFormatter() + f.calendar = calendar + f.timeZone = calendar.timeZone + f.dateFormat = "yyyy-MM-dd" + return f.date(from: string)! + } + + // MARK: - Aufbau + + @Test("August 2026 beginnt an einem Samstag und braucht deshalb fünf Vortage") + func leadingDaysForAugust2026() { + let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar) + + // 1. August 2026 ist ein Samstag; bei Wochenstart Montag stehen davor + // Mo–Fr aus dem Juli. + #expect(grid.days.first?.date == date("2026-07-27")) + #expect(grid.days.prefix(5).allSatisfy { !$0.isInMonth }) + } + + @Test("Das Raster besteht immer aus vollen Wochen") + func gridIsWholeWeeks() { + for month in ["2026-01-15", "2026-02-15", "2026-08-10", "2026-12-01"] { + let grid = MonthGrid(containing: date(month), calendar: calendar) + #expect(grid.days.count % 7 == 0, "Monat \(month) ergibt keine vollen Wochen") + } + } + + @Test("Februar 2027 hat 28 Tage im Monat, egal wie viele Zellen das Raster hat") + func februaryHasCorrectDayCount() { + let grid = MonthGrid(containing: date("2027-02-10"), calendar: calendar) + #expect(grid.days.filter(\.isInMonth).count == 28) + } + + @Test("Ein Schaltjahr wird korrekt behandelt") + func leapYear() { + let grid = MonthGrid(containing: date("2028-02-10"), calendar: calendar) + #expect(grid.days.filter(\.isInMonth).count == 29) + } + + @Test("Bei Wochenstart Sonntag verschiebt sich die erste Zeile") + func sundayStart() { + var sundayFirst = calendar + sundayFirst.firstWeekday = 1 + let grid = MonthGrid(containing: date("2026-08-10"), calendar: sundayFirst) + + #expect(grid.days.first?.date == date("2026-07-26")) + } + + @Test("Tage außerhalb des Monats sind als solche markiert") + func outOfMonthDaysAreMarked() { + let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar) + let inMonth = grid.days.filter(\.isInMonth) + + #expect(inMonth.count == 31) + #expect(grid.days.count > inMonth.count) + } + + // MARK: - Abdeckung: der Unterschied zwischen „frei" und „unbekannt" + + @Test("Ohne Abdeckungsgrenze gilt jeder Tag als bekannt") + func unlimitedCoverageKnowsEverything() { + let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar) + let status = grid.status(for: date("2026-08-10"), coverage: nil, busyDays: []) + + #expect(status == .free) + } + + @Test("Ein Tag innerhalb der Abdeckung ohne Termine ist frei") + func dayInCoverageWithoutEventsIsFree() { + let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar) + let coverage = DateInterval(start: date("2026-08-01"), end: date("2026-09-01")) + + #expect(grid.status(for: date("2026-08-10"), coverage: coverage, busyDays: []) == .free) + } + + @Test("Ein Tag AUSSERHALB der Abdeckung ist unbekannt, nicht frei") + func dayOutsideCoverageIsUnknown() { + // Der Kernpunkt. Ein leerer März sieht überzeugend echt aus und ist + // falsch, wenn die Quelle dort gar nichts weiß. + let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar) + let coverage = DateInterval(start: date("2026-08-01"), end: date("2026-08-15")) + + #expect(grid.status(for: date("2026-08-20"), coverage: coverage, busyDays: []) == .unknown) + } + + @Test("Der letzte Tag der Abdeckung gehört nicht mehr dazu") + func coverageEndIsExclusive() { + // [start, end) — so ist es in der Calendarr-Anleitung festgelegt. + let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar) + let coverage = DateInterval(start: date("2026-08-01"), end: date("2026-08-15")) + + #expect(grid.status(for: date("2026-08-14"), coverage: coverage, busyDays: []) == .free) + #expect(grid.status(for: date("2026-08-15"), coverage: coverage, busyDays: []) == .unknown) + } + + @Test("Ein Tag vor Beginn der Abdeckung ist ebenfalls unbekannt") + func dayBeforeCoverageIsUnknown() { + let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar) + let coverage = DateInterval(start: date("2026-08-05"), end: date("2026-08-30")) + + #expect(grid.status(for: date("2026-08-01"), coverage: coverage, busyDays: []) == .unknown) + } + + @Test("Ein Tag mit Terminen ist belegt") + func dayWithEventsIsBusy() { + let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar) + let busy: Set = [calendar.startOfDay(for: date("2026-08-10"))] + + #expect(grid.status(for: date("2026-08-10"), coverage: nil, busyDays: busy) == .busy) + } + + @Test("Termine außerhalb der Abdeckung ändern nichts — sie dürften gar nicht existieren") + func busyOutsideCoverageStaysUnknown() { + // Widersprüchliche Eingabe: die Quelle liefert einen Termin für einen + // Tag, den sie nach eigener Angabe nicht abdeckt. Die Abdeckung gewinnt, + // sonst behauptet die Anzeige mehr zu wissen, als belegt ist. + let grid = MonthGrid(containing: date("2026-08-10"), calendar: calendar) + let coverage = DateInterval(start: date("2026-08-01"), end: date("2026-08-15")) + let busy: Set = [calendar.startOfDay(for: date("2026-08-20"))] + + #expect(grid.status(for: date("2026-08-20"), coverage: coverage, busyDays: busy) == .unknown) + } +} diff --git a/project.yml b/project.yml index 650a141..7c34d75 100644 --- a/project.yml +++ b/project.yml @@ -39,6 +39,8 @@ targets: product: OnyxWidgetKit - package: OnyxKit product: OnyxMenuBar + - package: OnyxKit + product: CalendarProvider settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.onyx