diff --git a/Packages/OnyxKit/Sources/CalendarProvider/AgendaGrouping.swift b/Packages/OnyxKit/Sources/CalendarProvider/AgendaGrouping.swift new file mode 100644 index 0000000..9fcc5bc --- /dev/null +++ b/Packages/OnyxKit/Sources/CalendarProvider/AgendaGrouping.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Teilt die Terminliste in Tage. +/// +/// **Ohne Tagesüberschrift ist die Liste unlesbar.** Zwei Zeilen „Nina +/// arbeiten, 8:00" untereinander sind zwei verschiedene Tage, und nichts +/// verrät welche. Die Uhrzeit allein beantwortet nur die halbe Frage. +public enum AgendaGrouping { + + public struct Day: Identifiable, Equatable, Sendable { + /// Der Tagesanfang — er ist zugleich die Kennung. + public let date: Date + public let events: [OnyxEvent] + public var id: Date { date } + } + + /// Gruppiert nach Anfangstag, in der Reihenfolge, in der die Tage kommen. + public static func group(_ events: [OnyxEvent], + calendar: Calendar = .autoupdatingCurrent) -> [Day] { + var order: [Date] = [] + var byDay: [Date: [OnyxEvent]] = [:] + + for event in events.sorted(by: Self.isEarlier) { + let day = calendar.startOfDay(for: event.start) + if byDay[day] == nil { order.append(day) } + byDay[day, default: []].append(event) + } + return order.map { Day(date: $0, events: byDay[$0] ?? []) } + } + + /// Ganztägige zuerst, dann nach Uhrzeit. + /// + /// Ein ganztägiger Termin beginnt um Mitternacht und stünde sonst zwischen + /// den frühen Terminen — dabei gilt er für den ganzen Tag und gehört über + /// sie. + private static func isEarlier(_ left: OnyxEvent, _ right: OnyxEvent) -> Bool { + if left.isAllDay != right.isAllDay { return left.isAllDay } + return left.start < right.start + } + + /// Welche Art von Tag — die Entscheidung getrennt von ihrer Beschriftung, + /// damit sie ohne Sprachkatalog prüfbar bleibt. + public enum DayKind: Equatable, Sendable { + case today + case tomorrow + case later + } + + public static func kind(for day: Date, today: Date = Date(), + calendar: Calendar = .autoupdatingCurrent) -> DayKind { + let start = calendar.startOfDay(for: day) + let reference = calendar.startOfDay(for: today) + switch calendar.dateComponents([.day], from: reference, to: start).day ?? 0 { + case 0: return .today + case 1: return .tomorrow + default: return .later + } + } + + /// „Heute", „Morgen" oder das Datum. + /// + /// Für die nächsten beiden Tage die Wörter: sie sind kürzer als jedes + /// Datum und beantworten die Frage sofort. Danach Wochentag und Datum — + /// der Wochentag allein wäre in drei Wochen mehrdeutig. + public static func label(for day: Date, today: Date = Date(), + calendar: Calendar = .autoupdatingCurrent) -> String { + switch kind(for: day, today: today, calendar: calendar) { + case .today: return String(localized: "widget.calendar.today", bundle: .module) + case .tomorrow: return String(localized: "widget.calendar.tomorrow", bundle: .module) + case .later: + return calendar.startOfDay(for: day) + .formatted(.dateTime.weekday(.abbreviated).day().month(.defaultDigits)) + } + } +} diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift index 57cb0d7..2d60363 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift @@ -411,9 +411,19 @@ private struct AgendaList: View { .foregroundStyle(Onyx.Color.textTertiary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } else { - VStack(alignment: .leading, spacing: 5) { - ForEach(events) { event in - EventRow(event: event) + // Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem. + let days = AgendaGrouping.group(events) + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(days.enumerated()), id: \.element.id) { index, day in + Text(AgendaGrouping.label(for: day.date)) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Onyx.Color.textSecondary) + .textCase(.uppercase) + // Luft nur zwischen den Tagen, nicht über dem ersten. + .padding(.top, index == 0 ? 0 : 3) + ForEach(day.events) { event in + EventRow(event: event) + } } Spacer(minLength: 0) } diff --git a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings index 8d32cb3..cc9bec0 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/CalendarProvider/Localizable.xcstrings @@ -479,6 +479,38 @@ } } }, + "widget.calendar.today": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Heute" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + } + } + }, + "widget.calendar.tomorrow": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Morgen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Tomorrow" + } + } + } + }, "widget.calendar.unreadable": { "localizations": { "de": { diff --git a/Packages/OnyxKit/Tests/CalendarProviderTests/AgendaGroupingTests.swift b/Packages/OnyxKit/Tests/CalendarProviderTests/AgendaGroupingTests.swift new file mode 100644 index 0000000..66e0f0d --- /dev/null +++ b/Packages/OnyxKit/Tests/CalendarProviderTests/AgendaGroupingTests.swift @@ -0,0 +1,92 @@ +import Testing +import Foundation +@testable import CalendarProvider + +@Suite("Terminliste nach Tagen") +struct AgendaGroupingTests { + + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Europe/Berlin")! + return calendar + } + + private func date(_ day: Int, _ hour: Int = 0) -> Date { + calendar.date(from: DateComponents(year: 2026, month: 8, day: day, + hour: hour))! + } + + private func event(_ title: String, day: Int, hour: Int = 0, + allDay: Bool = false) -> OnyxEvent { + OnyxEvent(id: "\(title)-\(day)-\(hour)", title: title, + start: date(day, hour), end: date(day, hour + 1), + isAllDay: allDay, colorHex: nil, location: nil, + calendarID: "c", calendarTitle: "C") + } + + @Test("Termine landen unter ihrem Tag") + func groupsByDay() { + // Der Fall aus der Praxis: zweimal „Nina arbeiten, 8:00" untereinander, + // und nichts verriet, welcher Tag gemeint war. + let days = AgendaGrouping.group([ + event("Nina arbeiten", day: 14, hour: 8), + event("Grillplausch", day: 15, hour: 18), + event("Nina arbeiten", day: 16, hour: 8), + ], calendar: calendar) + + #expect(days.count == 3) + #expect(days.map(\.events.count) == [1, 1, 1]) + #expect(days[0].date == date(14)) + } + + @Test("Die Tage kommen in ihrer Reihenfolge") + func daysStayInOrder() { + let days = AgendaGrouping.group([ + event("Später", day: 16), event("Früher", day: 14), + ], calendar: calendar) + #expect(days.map(\.date) == [date(14), date(16)]) + } + + @Test("Ganztägige Termine stehen über den Uhrzeiten") + func allDayComesFirst() { + // Sie beginnen um Mitternacht und stünden sonst zwischen den frühen + // Terminen — dabei gelten sie für den ganzen Tag. + let days = AgendaGrouping.group([ + event("Frühschicht", day: 14, hour: 6), + event("Urlaub", day: 14, allDay: true), + ], calendar: calendar) + #expect(days[0].events.map(\.title) == ["Urlaub", "Frühschicht"]) + } + + @Test("Heute, morgen und später sind auseinandergehalten") + func dayKinds() { + let today = date(14, 12) + #expect(AgendaGrouping.kind(for: date(14), today: today, calendar: calendar) == .today) + #expect(AgendaGrouping.kind(for: date(15), today: today, calendar: calendar) == .tomorrow) + #expect(AgendaGrouping.kind(for: date(20), today: today, calendar: calendar) == .later) + // Auch rückwärts: ein Termin von gestern ist nicht „heute". + #expect(AgendaGrouping.kind(for: date(13), today: today, calendar: calendar) == .later) + } + + @Test("Später steht das Datum in der Überschrift") + func laterDaysShowTheDate() { + // Der Wochentag allein wäre in drei Wochen mehrdeutig. + let label = AgendaGrouping.label(for: date(20), today: date(14, 12), calendar: calendar) + #expect(label.contains("20")) + } + + @Test("Eine späte Uhrzeit zählt noch zum selben Tag") + func lateHoursStayOnTheirDay() { + // Nicht selbstverständlich: mit UTC statt der lokalen Zone rutschte ein + // Termin um 23 Uhr auf den Folgetag. + let days = AgendaGrouping.group([ + event("Spät", day: 14, hour: 23), event("Früh", day: 14, hour: 7), + ], calendar: calendar) + #expect(days.count == 1) + } + + @Test("Ohne Termine gibt es keine Tage") + func emptyStaysEmpty() { + #expect(AgendaGrouping.group([], calendar: calendar).isEmpty) + } +}