Compare commits

..

5 Commits

Author SHA1 Message Date
Scarriffle
ee848b86cc Das Aufnahmesymbol kam vom Mixer, nicht vom Pegel
Gemessen über kAudioProcessPropertyIsRunningInput: mit laufendem Mixer nimmt
Onyx auf, ohne ihn nicht. Ein Process Tap ist für macOS eine Tonaufnahme —
auch wenn damit nur die Lautstärke geregelt wird. Solange der Mixer Chrome und
Firefox abgreift, steht das orange Symbol in der Leiste.

Der Mixer stand seit der Fehlersuche am Wochenende auf „automatisch starten",
deshalb war es dauerhaft. Das Häkchen steht jetzt wieder aus.

Ein Hinweis unter dem Häkchen sagt das jetzt, wo die Entscheidung fällt: wer
das Symbol ohne Erklärung sieht, sucht den Fehler bei sich oder hält die App
für unsauber.

Dazu ein echter Fund an derselben Stelle: der Mikrofonpegel hing an
`onDisappear` der View — und das bleibt bei einem NSPopover aus. Dessen
Controller lebt nach dem Schließen weiter, die View verschwindet nie aus der
Hierarchie. Die Messung wäre also beim ersten Öffnen angegangen und nie wieder
aus. Sie hängt jetzt am Popover selbst: `MenuBarModule` bekommt
`popoverDidOpen`/`popoverDidClose`, gemeldet auch dann, wenn das Popover von
selbst zugeht — beim Wechsel in eine andere App etwa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:20:07 +02:00
Scarriffle
c94c6166f5 Der Farbwechsel kommt jetzt auch an
Farbton und Symbolfarbe standen beim **Erzeugen** der Fenster, also außerhalb
jeder View. Damit wird der Wert genau einmal gelesen und SwiftUI erfährt nie,
dass er sich geändert hat: die Reiter, Knöpfe und Symbole blieben in der
Farbe stehen, die beim Öffnen galt, während der Farbwähler daneben schon die
neue zeigte.

Jetzt stehen beide im Rumpf der jeweiligen View — Panel, Einstellungen,
Einrichtung. Dort registriert SwiftUI den Zugriff und zeichnet neu.

Bei den Popovers blieb es beim Erzeugen: die entstehen bei jedem Öffnen neu,
lesen also ohnehin den aktuellen Wert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:18:06 +02:00
Scarriffle
1432ef3a6f Der Systemfarbknopf zeigt, welche Farbe er nimmt
Er tut, was draufsteht — nur weiß niemand auswendig, welche Farbe das System
gerade führt. Dann wirkt das Ergebnis wie ein Fehler statt wie eine Übernahme.
Jetzt sitzt der Farbfleck daneben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:00:56 +02:00
Scarriffle
9975694d6a Terminliste: an welchem Tag denn?
Zweimal „Nina arbeiten, 8:00" untereinander sind zwei verschiedene Tage, und
nichts verriet welche. Die Uhrzeit beantwortet nur die halbe Frage.

Jetzt steht über jedem Tag eine Überschrift: „Heute", „Morgen", danach
Wochentag und Datum. Die Wörter für die nächsten beiden Tage, weil sie kürzer
sind als jedes Datum und sofort beantworten, was man wissen will. Der
Wochentag allein wäre in drei Wochen mehrdeutig, deshalb steht das Datum
dabei.

Ganztägige Termine stehen jetzt über den Uhrzeiten desselben Tages. Sie
beginnen um Mitternacht und standen deshalb zwischen den frühen Terminen —
dabei gelten sie für den ganzen Tag.

Die Entscheidung „heute/morgen/später" liegt getrennt von ihrer Beschriftung,
damit sie ohne Sprachkatalog prüfbar bleibt: im Testlauf steht sonst der rohe
Schlüssel statt des Wortes, und der Test prüft die Übersetzung statt der
Logik.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:52:16 +02:00
Scarriffle
0df566d531 Symbole folgen der Akzentfarbe, nicht der des Systems
SwiftUI zeichnet Symbole in Listen und Formularen auf macOS in der
Akzentfarbe des Systems — nicht in der des Fensters. `.tint` an der Wurzel
half deshalb nur der Reiterleiste; die Widget-Liste blieb systemgrün neben
einer blauen Leiste, obwohl beides derselben Einstellung folgen soll.

Ein eigener Label-Stil setzt die Farbe am Symbol. Er vererbt sich über die
Umgebung, also genügt eine Zeile an denselben vier Wurzeln, an denen auch der
Farbton gesetzt wird: Panel, Popovers, Einstellungen, Einrichtung.

Ein Symbol, das seine Farbe selbst mitbringt, behält sie — die innere Angabe
schlägt die äußere. Der grüne Haken für „erteilt" bringt sie jetzt am Symbol
mit statt am ganzen Label: Grün heißt dort „erteilt" und ist keine
Geschmacksfrage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:48:57 +02:00
16 changed files with 375 additions and 23 deletions

View File

@@ -42,8 +42,16 @@ struct ColorSettingsView: View {
Spacer(minLength: 0)
}
// Mit dem Farbfleck daneben: welche Farbe das System gerade
// führt, weiß man nicht auswendig und dann wirkt das
// Ergebnis wie ein Fehler statt wie eine Übernahme.
Button { theme.adoptSystemAccent() } label: {
Text("color.adoptSystem")
HStack(spacing: 6) {
Text("color.adoptSystem")
Circle()
.fill(Color(nsColor: .controlAccentColor))
.frame(width: 11, height: 11)
}
}
Text("color.accent.hint")
.font(.caption).foregroundStyle(.secondary)

View File

@@ -33,6 +33,15 @@ final class MixerMenuBarModule: MenuBarModule {
return view
}
/// Pegel nur, solange das Popover offen ist.
///
/// Der Mikrofonpegel schaltet den orangen Punkt an das darf keine
/// Sekunde länger dauern als das Hinsehen. An der View ließ es sich nicht
/// festmachen: `onDisappear` bleibt beim Popover aus, und der Punkt blieb
/// dauerhaft stehen.
func popoverDidOpen() { system.levels.addConsumer() }
func popoverDidClose() { system.levels.removeConsumer() }
func makePopoverView() -> AnyView {
AnyView(
ScrollView {

View File

@@ -82,6 +82,9 @@ struct OnboardingView: View {
footer
}
.frame(width: 520, height: 520)
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
.task {
calendarGranted = calendarModel.hasAccess
fanControl.refreshInstallState()

View File

@@ -44,8 +44,7 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
rootView: OnboardingView(
calendarModel: calendarModel, weatherModel: weatherModel,
mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin,
onFinish: { [weak self] in self?.finish() })
.tint(Onyx.Color.accent))
onFinish: { [weak self] in self?.finish() }))
let window = NSWindow(contentViewController: hosting)
window.title = "Onyx"

View File

@@ -76,8 +76,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NotchHostingView(
rootView: NotchContainer(presentation: presentation) {
NotchPanelContent(model: model)
}
.tint(Onyx.Color.accent),
},
presentation: presentation)
}
coordinator.start()
@@ -365,5 +364,8 @@ private struct NotchPanelContent: View {
var body: some View {
WidgetGrid(placements: model.layout, availableWidth: AppModel.availableWidth)
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
}
}

View File

@@ -71,6 +71,14 @@ struct SettingsView: View {
// Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent
// auseinander. Sichtbar wurde das als Kante quer durchs Fenster.
.background(Color(nsColor: .windowBackgroundColor))
// Farbton und Symbolfarbe hier und nicht beim Erzeugen des Fensters.
//
// Beim Erzeugen wird der Wert **einmal** gelesen, außerhalb jeder
// View SwiftUI erfährt nie, dass er sich geändert hat. Die Reiter
// und Knöpfe blieben deshalb in der Farbe stehen, die beim Öffnen
// galt, während der Farbwähler daneben schon die neue zeigte.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
// wachsen, der Inhalt aber nicht mit.
.frame(minWidth: 740, minHeight: 460)
@@ -231,9 +239,17 @@ private struct PermissionRow: View {
Spacer()
switch state {
case .granted:
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(Onyx.Color.positive)
.labelStyle(.titleAndIcon)
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
// Akzentstil färbt sonst darüber. Grün heißt hier erteilt"
// und ist keine Geschmacksfrage.
Label {
Text("settings.permissions.granted")
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Onyx.Color.positive)
}
.foregroundStyle(Onyx.Color.positive)
.labelStyle(.titleAndIcon)
case .undetermined:
Button("settings.permissions.request") { Task { await request() } }
case .denied:

View File

@@ -61,11 +61,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
showOnboarding: showOnboarding,
selectedTab: Binding(
get: { [weak self] in self?.selectedTab ?? .widgets },
set: { [weak self] in self?.selectedTab = $0 }))
// Ohne das folgen Haken, Regler und Auswahlfelder der
// Systemfarbe statt der eingestellten. Genau daher kam das
// Grün, das in Onyx sonst nirgends vorkommt.
.tint(Onyx.Color.accent))
set: { [weak self] in self?.selectedTab = $0 })))
let window = NSWindow(contentViewController: hosting)
window.title = "Onyx"

View File

@@ -196,6 +196,22 @@
}
}
},
"mixer.recordingHint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Solange der Mixer läuft, zeigt macOS das orange Aufnahmesymbol: Ton abgreifen heißt für das System aufnehmen, auch wenn nur die Lautstärke geregelt wird."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "While the mixer runs, macOS shows the orange recording dot: tapping audio counts as recording to the system, even when only the volume is adjusted."
}
}
}
},
"mixer.release": {
"localizations": {
"de": {

View File

@@ -86,11 +86,10 @@ public struct MixerView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.animation(Onyx.Motion.value, value: mixer.groups.count)
// Die Pegel laufen nur, solange die Ansicht sichtbar ist. Der
// Mikrofonpegel schaltet dabei den orangen Punkt an ein Messgerät,
// das im Hintergrund weiterläuft, wäre hier das Falsche.
.onAppear { system.levels.addConsumer() }
.onDisappear { system.levels.removeConsumer() }
// Kein `onAppear` für die Pegel: die Messung hängt am Popover, nicht
// an dieser View. `onDisappear` bleibt bei einem NSPopover aus, und
// ein Mikrofon, das nur beim Öffnen an- und nie wieder ausgeht, ist
// schlimmer als eines, das gar nicht misst.
}
}
@@ -107,6 +106,17 @@ extension MixerView {
}
.toggleStyle(.checkbox)
.controlSize(.small)
// Der Hinweis gehört hierher, weil hier die Entscheidung fällt.
//
// Ein Tap ist für macOS eine Tonaufnahme auch wenn nur die
// Lautstärke geregelt wird. Solange der Mixer läuft, steht deshalb das
// orange Aufnahmesymbol in der Leiste. Wer das ohne Erklärung sieht,
// sucht den Fehler bei sich oder hält die App für unsauber.
Text("mixer.recordingHint", bundle: .module)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}

View File

@@ -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))
}
}
}

View File

@@ -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)
}

View File

@@ -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": {

View File

@@ -0,0 +1,30 @@
import SwiftUI
/// Färbt das Symbol eines `Label` in der eingestellten Akzentfarbe.
///
/// **Nötig, weil `.tint` hier nicht greift.** SwiftUI zeichnet Symbole in
/// Listen und Formularen auf macOS in der Akzentfarbe des *Systems* nicht in
/// der des Fensters. Das Ergebnis war eine Widget-Liste in Systemgrün neben
/// einer Reiterleiste in Onyx-Blau, obwohl beides derselben Einstellung folgen
/// soll.
///
/// Ein Stil statt einer Farbe an jeder Fundstelle: er vererbt sich über die
/// Umgebung, also genügt eine Zeile an der Wurzel. Ein Symbol, das seine Farbe
/// selbst mitbringt etwa der grüne Haken für erteilt" , behält sie: die
/// innere Angabe schlägt die äußere.
public struct OnyxAccentLabelStyle: LabelStyle {
public init() {}
public func makeBody(configuration: Configuration) -> some View {
Label {
configuration.title
} icon: {
configuration.icon.foregroundStyle(Onyx.Color.accent)
}
}
}
public extension LabelStyle where Self == OnyxAccentLabelStyle {
static var onyx: OnyxAccentLabelStyle { OnyxAccentLabelStyle() }
}

View File

@@ -10,6 +10,9 @@ import SwiftUI
@MainActor
public final class MenuBarController {
/// Hält den Delegaten am Leben `NSPopover` merkt ihn sich nur schwach.
private var popoverWatcher: PopoverWatcher?
/// Ein Modul, dessen Element keine Breite bekommen hat.
///
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
@@ -145,8 +148,14 @@ public final class MenuBarController {
// Auswahlfelder auf die Systemfarbe zurück das war der Grund, warum
// die Popovers grün waren und die Widgets blau.
entry.popover.contentViewController = NSHostingController(
rootView: entry.module.makePopoverView().tint(Onyx.Color.accent))
rootView: entry.module.makePopoverView()
.tint(Onyx.Color.accent)
.labelStyle(.onyx))
let watcher = PopoverWatcher { [weak self] in self?.popoverClosedByItself() }
popoverWatcher = watcher
entry.popover.delegate = watcher
entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
entry.module.popoverDidOpen()
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
entry.popover.contentViewController?.view.window?.makeKey()
openPopoverID = id
@@ -188,11 +197,21 @@ public final class MenuBarController {
}
}
/// Zugegangen, ohne dass wir es veranlasst haben.
private func popoverClosedByItself() {
guard let id = openPopoverID, let entry = entries[id] else { return }
entry.module.popoverDidClose()
openPopoverID = nil
}
private func closeOpenPopover() {
if let id = openPopoverID, let entry = entries[id] {
entry.popover.performClose(nil)
}
openPopoverID = nil
// Über denselben Weg wie das Zugehen von selbst: der räumt
// `openPopoverID` ab und ist damit genau einmal wirksam, egal ob der
// Delegat vorher, nachher oder gar nicht meldet.
popoverClosedByItself()
globalMonitor.map(NSEvent.removeMonitor)
localMonitor.map(NSEvent.removeMonitor)
keyMonitor.map(NSEvent.removeMonitor)
@@ -213,3 +232,21 @@ public final class MenuBarController {
onVisibilityChanged?(hidden)
}
}
/// Horcht, ob das Popover zugegangen ist.
///
/// Ein eigener Typ, weil ein Delegat von AppKit ein `NSObject` sein muss und
/// der Controller keines ist. Nötig ist er trotzdem: das Popover geht auch
/// ohne Zutun zu beim Wechsel in eine andere App etwa , und ohne diese
/// Meldung erfährt eine laufende Messung nie, dass niemand mehr hinsieht.
final class PopoverWatcher: NSObject, NSPopoverDelegate {
private let onClose: @MainActor () -> Void
init(onClose: @escaping @MainActor () -> Void) {
self.onClose = onClose
}
func popoverDidClose(_ notification: Notification) {
MainActor.assumeIsolated { onClose() }
}
}

View File

@@ -42,6 +42,17 @@ public protocol MenuBarModule: AnyObject {
/// Der Inhalt des Popovers beim Klick.
func makePopoverView() -> AnyView
/// Wird gerufen, wenn das Popover dieses Moduls auf- und wieder zugeht.
///
/// **Die einzige verlässliche Stelle für alles, was nur laufen darf,
/// solange jemand hinsieht.** SwiftUIs `onDisappear` bleibt bei einem
/// `NSPopover` aus: dessen Controller lebt nach dem Schließen weiter, die
/// View verschwindet nie aus der Hierarchie. Wer eine Messung daran hängt,
/// hängt sie an etwas, das nie eintritt im Fall des Mikrofons mit dem
/// orangen Punkt als Beweis.
func popoverDidOpen()
func popoverDidClose()
/// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet hier
/// meldet sich das Modul beim Provider an und wieder ab.
func activate()
@@ -72,3 +83,9 @@ extension TimeInterval {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
public extension MenuBarModule {
// Die meisten Module haben nichts abzuräumen.
func popoverDidOpen() {}
func popoverDidClose() {}
}

View File

@@ -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)
}
}