Compare commits

...

2 Commits

Author SHA1 Message Date
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
10 changed files with 262 additions and 10 deletions

View File

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

View File

@@ -77,7 +77,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
rootView: NotchContainer(presentation: presentation) {
NotchPanelContent(model: model)
}
.tint(Onyx.Color.accent),
.tint(Onyx.Color.accent)
.labelStyle(.onyx),
presentation: presentation)
}
coordinator.start()

View File

@@ -231,9 +231,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

@@ -65,7 +65,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
// 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))
.tint(Onyx.Color.accent)
.labelStyle(.onyx))
let window = NSWindow(contentViewController: hosting)
window.title = "Onyx"

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

@@ -145,7 +145,9 @@ 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))
entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
entry.popover.contentViewController?.view.window?.makeKey()

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