Für Entwickler/Agents, die die Library in eine App einbinden. Die Library ist der Swift-Port von
@cocoar/vue-calendarund verhält sich identisch zur Web-Komponente (fixture-verifiziert gegen die TS-Implementierung). Hintergrund:docs/vue-calendar-analyse.md, Projektregeln:CLAUDE.md.
SwiftPM, lokales Package (noch kein Remote):
// Package.swift der App / Xcode: File > Add Package Dependencies > Add Local…
.package(path: "../Cocoar.Calendar.iOS")
// Produkte:
.product(name: "CocoarCalendarUI", package: "CocoarCalendar") // Views (re-exportiert Core)
.product(name: "CocoarCalendarCore", package: "CocoarCalendar") // nur Logik, falls UI nicht gebrauchtMinimum: iOS 26. import CocoarCalendarUI genügt (Core kommt via
@_exported mit).
Niemals Foundation.Date für Event-Zeiten verwenden. Die Library
speichert Absicht (lokale Wandzeit + IANA-Zone), nicht Instants — exakt wie
das Web (Temporal). Es gibt zwei Zeit-Formen, per Enum unverwechselbar:
// Terminiertes Event: Wandzeit + IANA-Zone pro Endpunkt.
let start = try ZonedDateTime.from(
wall: PlainDateTime(isoString: "2026-06-15T09:00:00"),
zone: "Europe/Vienna" // Quell-Zone des Events!
)
let end = try start.adding(minutes: 30)
let standup = try CalendarEvent(
id: "standup",
time: .timed(start: start, end: end),
meta: MyMeta(title: "Daily Standup", colorHex: 0x10B981)
)
// Ganztägig: bewusst zonenlos, end EXKLUSIV (RFC 5545).
let vacation = try CalendarEvent(
id: "vacation",
time: .allDay(start: try PlainDate(isoString: "2026-06-22"),
end: try PlainDate(isoString: "2026-06-27")) // bis inkl. 26.!
)Regeln, die die Library erzwingt (throws):
endmuss strikt nachstartliegen.- Zone muss eine gültige IANA-Zone sein (
Europe/Vienna, nichtCET-Raten). - Cross-Zone ist erlaubt und erwünscht (Flug: start Tokio, end Wien).
Events sind generisch über Meta. Die Library liest nur die Konvention
title / colorHex:
struct MyMeta: CalendarEventMeta { // Hashable & Sendable
var title: String?
var colorHex: UInt32? // 0xRRGGBB; nil → Akzentfarbe
var myAppointmentID: UUID // beliebige eigene Felder
}Ohne Meta: PlainCalendarEvent (= CalendarEvent<Never>).
Das Backend spricht denselben Contract wie die Vue-Komponente:
// Backend → App (timed): { "local": "2026-06-15T09:00:00", "timeZoneId": "Europe/Vienna" }
let zdt = try parseScheduledTime(
ScheduledTimeWire(local: dto.localStart, timeZoneId: dto.timeZoneId)
) // optional: dstPolicy: .compatible|.reject|.earlier|.later (Default compatible)
// App → Backend:
let wire = formatScheduledTime(zdt) // → local + timeZoneId, verlustfrei
// Ganztägig: "2026-06-15"
let date = try parsePlainDate(dto.date)Nie Instants/UTC-Strings ins Backend zurückschreiben — local +
timeZoneId verbatim persistieren (Round-Trip ist garantiert stabil).
Alle Views sind SwiftUI, generisch über Meta: CalendarEventMeta, und
bekommen dieselben Grundparameter: events, timezone (Display-Zone —
was der Nutzer sieht; unabhängig von den Quell-Zonen der Events),
today (von der App geliefert, Views lesen nie selbst die Uhr),
locale, Callbacks.
@State var cursor: PlainDate = … // App besitzt den Cursor
WeekView(
cursor: $cursor, // Binding! Strip-Paging schreibt zurück
firstDayOfWeek: 1, // 0=So … 6=Sa
events: events,
timezone: "Europe/Vienna",
timeRange: (startHour: 7, endHour: 20), // sichtbare Stunden
today: today,
locale: Locale(identifier: "de_AT"),
allDayAxisLabel: "ganztägig",
onTimeClick: { date, time in … }, // Tap auf leeren Slot (gesnappt)
onDateClick: { date in … }, // Tap auf Header/Ganztägig-Zelle
onEventClick: { event in … },
scrollRequest: $scrollRequest // TimeGridScrollRequest(time:) → scrollt zur Stunde
)Verhalten (automatisch, nicht überschreiben):
.compact(iPhone hoch): Wochen-Strip oben (Tag antippen, Woche swipen)- Tages-Timeline in voller Breite.
.regular(iPad, iPhone quer): echtes 7-Spalten-Grid.visibleDays:Override für WorkWeek-Fälle (nur diese Tage rendern).
DayView(cursor: cursor, events: events, timezone: tz,
timeRange: (7, 20), today: today, …) // eine Spalte
MonthView(cursor: cursor, firstDayOfWeek: 1, events: events,
timezone: tz, today: today, locale: loc,
onDateClick: …, onEventClick: …) // 6×7-Raster, Pills + Multi-Day-Bars
AgendaView(cursor: cursor, agendaLengthDays: 30,
showEmptyDays: false, events: events, timezone: tz,
today: today, locale: loc,
allDayLabel: "Ganztägig", continuationTag: "(fortges.)",
todayBadgeLabel: "heute",
onDateClick: …, onEventClick: …,
scrollRequest: $agendaScroll) // AgendaScrollRequest.scrollToDate(…)Month-Verhalten wie im Web: kein „+N more" — Zellen scrollen intern; mehrtägige
Events werden pro Wochenzeile segmentiert. Agenda: Ganztägig vor Terminen,
Fortsetzungstage mit (cont.)-Tag, native pinned Section-Headers.
Die Komplett-Lösung analog <CoarCalendar>: ein @Observable-Controller
(Zustand, Navigation, Events) + eine Shell-View (Toolbar mit ‹ Heute ›,
per-View-Range-Label, Segmented-Switcher; Body wechselt zwischen allen fünf
Views; Header passt sich der Breite an — einzeilig auf iPad, zweizeilig auf
iPhone). Ausführliches Beispiel: Doc-Comment auf CalendarShell.
// Controller EINMAL erzeugen (z. B. @State im Screen). Handler am besten
// direkt bei der Konstruktion setzen — onRangeChange feuert schon beim
// ersten Erscheinen; wer es erst in .task setzt, verpasst das erste Fenster.
let controller = CalendarController<MyMeta>(
view: .week,
timezone: TimeZone.current.identifier, // Display-Zone
locale: Locale(identifier: "de_AT"),
firstDayOfWeek: 1,
timeRange: (startHour: 7, endHour: 20)
)
// Entweder statisch:
controller.setEvents(events)
// … oder per Loader (gecacht pro view|timezone|start|end, debounced,
// Generation-Guard; refresh()/refreshRange() zum Invalidieren):
controller.setEventsLoader { window in
try await api.fetchEvents(from: window.start, to: window.end,
timeZoneId: window.timezone)
}
controller.onEventClick = { event in … }
controller.onTimeClick = { date, time in … }
controller.onRangeChange = { window in … } // z. B. Analytics/Prefetch
// "Hier Termin anlegen" (Long-Press auf LEERE Fläche; Long-Press auf
// einem Event bleibt Drag). Zeit-Slot gesnappt wie onTimeClick:
controller.onTimeLongPress = { date, time in … } // leerer Zeit-Slot (Tag/Woche)
controller.onDateLongPress = { date in … } // leere Monatszelle / Ganztägig-Zelle
// Doppel-Tap auf leere Flächen — volle Gesten-Matrix pro Fläche:
// {Tap, Doppel-Tap, Long-Press} × {Monatszelle, Zeit-Slot}. Die App wählt
// die Semantik (z. B. Tap = markieren, Doppel = Tagesansicht, Long = anlegen).
// ACHTUNG Latenz: Ist ein Doppel-Tap-Handler gesetzt, wartet der Single-Tap
// derselben Fläche ~¼ s auf den möglichen zweiten Tap (System-Fenster);
// ungesetzt (nil) wird die Geste gar nicht angehängt und Taps bleiben sofort.
controller.onDateDoubleClick = { date in … } // Monatszelle
controller.onTimeDoubleClick = { date, time in … } // Zeit-Slot, gesnappt wie onTimeClick
// Swipe-Paging in der Monatsansicht ist automatisch aktiv (links/rechts =
// next()/prev(), identisch zu den Chevron-Buttons).
// ── Rezept: Gesten mit Navigation verknüpfen ──────────────────────────
// Die Hooks sind REINE Notifications — die Library navigiert bei
// Tap/Doppel-Tap/Long-Press nie selbst (Web-Verhalten). OB dabei das
// Datum gesetzt wird, entscheidet die App im Handler: `controller.cursor`
// und `controller.view` sind schreibbar. Je eine Zeile:
controller.onDateClick = { controller.cursor = $0 } // Tap = Tag markieren
controller.onDateDoubleClick = { date in // Doppel-Tap = Tagesansicht
controller.cursor = date
controller.view = .day
}
controller.onDateLongPress = { öffneCreateSheet(für: $0) } // Long-Press = anlegen
// Kein cursor-Write im Handler = Datum bleibt stehen (z. B. nur Analytics).
// Einzige eingebaute Ausnahme: der Tag-Tap im compact-Wochen-Strip setzt
// den Cursor IMMER mit — dort ist die Auswahl der sichtbare Tag, die
// Invariante "controller.cursor == sichtbarer Tag" ist App-lesbar garantiert.
// In der View (alle Labels lokalisierbar — auch die view-internen):
CalendarShell(
controller: controller,
todayLabel: "Heute",
viewLabel: { view in … }, // lokalisierte Switcher-Labels
allDayAxisLabel: "ganztägig", // Achsen-Label des Ganztägig-Bands
agendaAllDayLabel: "Ganztägig", // Agenda-Zeitspalte für All-Day-Zeilen
agendaContinuationTag: "(fortges.)", // Agenda-Fortsetzungstage
agendaTodayBadgeLabel: "heute", // HEUTE-Badge im Agenda-Header
agendaTimeLabel: { event in … }, // optional: Agenda-Zeitlabel pro Event ersetzen
eventContent: { event in AnyView(MyEventCell(event)) } // optional: eigenes Rendering
)Agenda-Zeitlabel-Default: timed Events MIT Ende zeigen die Spanne
(09:00 – 10:30), Punkt-Termine (ohne Ende) nur die Startzeit, All-Day das
agendaAllDayLabel. agendaTimeLabel ersetzt die Regel komplett pro Event.
Navigation imperativ: controller.goToToday(), next(), prev(),
goTo(date), setView(.month) (respektiert availableViews),
scrollToTime(…)/scrollToDate(…). controller.visibleEvents und
controller.loading sind beobachtbar.
Ein Environment-Wert mit zwei Presets: CalendarTheme.light (die
Vue-Token-Fallbacks) und CalendarTheme.dark (seit @cocoar/vue-calendar
2.15.0 shippt das Web dieselben Werte als styles/tokens-dark.css — beide
Seiten bleiben literal vergleichbar). Empfohlener Standard — folgt dem
System-Schema:
CalendarShell(controller: controller)
.calendarTheme(light: .light, dark: .dark)An die App anpassen: Alle ~30 Tokens sind vars — Kopien anpassen und
als Paar übergeben:
var light = CalendarTheme.light
light.accent = Color(hex: 0x7C3AED) // Brand-Farbe
light.backgroundToday = light.accent.opacity(0.06)
var dark = CalendarTheme.dark
dark.accent = Color(hex: 0x8B5CF6) // hellere Dark-Variante der Brand
CalendarShell(controller: controller)
.calendarTheme(light: light, dark: dark)Fixes Theme (ignoriert das Schema): .calendarTheme(.light). Farbige
Events: meta.colorHex füllt die Karte komplett (Web-Verhalten) — in beiden
Modi; Event-Farben werden bewusst nicht umgerechnet.
Fremdmonats-Tage (Monatsansicht) dimmen: backgroundOtherMonth
(Zellhintergrund), textOtherMonth (Tageszahl) und
otherMonthContentOpacity (dimmt Tageszahl + Event-Pills, Default 1 =
kein Dimmen; Multi-Day-Bars sind ausgenommen — sie spannen zeilenweit über
Monatsgrenzen):
var light = CalendarTheme.light
light.otherMonthContentOpacity = 0.4| Fehler | Richtig |
|---|---|
Event-Zeiten aus Date + TimeZone.current zusammenbauen |
ZonedDateTime.from(wall:zone:) mit der Quell-Zone des Termins |
| Display-Zone in die Events schreiben | timezone:-Parameter der Views ist NUR Anzeige; Events behalten ihre Quell-Zonen |
end inklusiv denken |
end ist überall exklusiv (1-Tages-Event: end = start + 1 Tag) |
| Auf Gerätetyp branchen (iPad vs iPhone) | Nichts tun — WeekView brancht selbst über Size-Class |
| Events pro Render neu erzeugen (neue Objekte) | Events stabil halten (Identität = id); Layout ist deterministisch |
| UTC als Display-Zone „zur Sicherheit" | Echte Nutzer-Zone übergeben (TimeZone.current.identifier) |
| DST-Fehler abfangen und ignorieren | DstResolutionError bedeutet: Policy-Entscheidung nötig (siehe DstPolicy) |
Verschieben und Größe-Ändern von Events ist da und verhält sich wie im Web
(event-drop). Aktivierung ausschließlich über den Controller — einen
onEventDrop-Handler setzen genügt, sonst bleibt der Kalender read-only (Cards
bekommen nur einen Tap, es wird nie ein Drag gestartet; exakt das Vue-Verhalten
„kein Handler ⇒ kein Drop").
Ein Handler, alle Flächen. onEventDrop (und optional canDrop / dstPolicy)
gilt nicht nur fürs terminierte Tages-/Wochen-Grid, sondern feuert mit demselben
Closure auch für die Datums-Flächen:
- Month-Grid — Pill/Bar verschieben (
month) und links/rechts resizen (month-resize-start/-end). - All-Day-Band (über dem Zeit-Grid in Day-/Week-View) — Bar verschieben
(
allDay) und resizen (allDay-resize-start/-end).
Es gibt keinen zweiten Handler für diese Flächen: das eine
controller.onEventDrop aktiviert sie alle. Der Payload hat für Datums-Flächen
aber eine andere Form (siehe unten) — ein Handler, der nur terminierte Drops
erwartet, muss das berücksichtigen.
// WICHTIG: onEventDrop (und optional canDrop) direkt bei der Konstruktion
// setzen — NICHT erst in .onAppear. Beide sind @ObservationIgnored, d. h. ein
// späteres Zuweisen triggert KEIN Re-Render der Shell/Views, und das Grid
// bliebe read-only. (onEventClick etc. dürfen später kommen, weil sie zur
// Laufzeit gelesen werden; die Drag-Verdrahtung wird beim ersten Render fixiert.)
let controller = CalendarController<MyMeta>(view: .week, timezone: tz, /* … */)
controller.onEventDrop = { payload in
// payload.original (Vor-Drag-Snapshot), payload.next (aufgelöste Zeit),
// payload.target (Datum/Minuten/valid), payload.disambiguation (DST).
try? api.reschedule(payload.event.id, to: payload.next)
}
// Optional: pro Hit-Test validieren. false ⇒ roter Ghost, kein Commit.
controller.canDrop = { event, target in
// ACHTUNG: target.minutes ist auf Month-Grid UND All-Day-Band IMMER nil
// (zeitfreie Fläche). Dieser `?? true`-Fallback wird dort also zu „immer
// erlaubt". Wer Datums-Drops separat validieren will, per target.date /
// Event-Typ verzweigen statt über minutes.
target.minutes.map { $0 >= 9 * 60 } ?? true // z. B. „nicht vor 09:00"
}
// Optional: DST-Politik für gedroppte Wandzeiten (Default .compatible).
controller.dstPolicy = .reject // markiert Gap/Overlap-Ziele vorab invalidGesten (iOS-nativ, Apple-Calendar-Idiom): Tap öffnet das Event; Long-Press
~0,35 s dann ziehen = verschieben (Dauer bleibt erhalten); die 6-pt-Bänder
= Resize — im Zeit-Grid an Ober-/Unterkante, auf Month-Bars und All-Day-Bars an
der linken/rechten Kante (geclippte Bar-Enden haben keinen Handle). Beim
Ziehen an den Rand des Zeit-Grids wird kontinuierlich gescrollt (Month-Grid und
Band brauchen das nicht). Ohne CalendarController gehen alle Flächen auch
direkt an WeekView / DayView / MonthView / AllDayBand (gleiche Parameter
onEventDrop: / canDrop: / dstPolicy:).
Payload-Form je Fläche. Derselbe EventDropPayload kommt aus allen Flächen,
aber target.minutes und die Gestalt von next.time unterscheiden sich:
| Fläche / Modus | target.minutes |
payload.next.time |
|---|---|---|
Zeit-Grid (timed move/resize) |
Minuten-im-Tag (Int) |
.timed(start, end) |
| Month-Grid / All-Day-Band, All-Day-Event (move + resize) | nil |
.allDay(start, end) (Dauer bleibt erhalten; end exklusiv) |
Month-Grid, terminiertes Event per Pill verschoben (month) |
nil |
.timed(start, end) auf 00:00 des Ziel-Tags — minutes == nil wird als 0 gelesen (kann eine DST-Lücke treffen → disambiguation) |
Month-Grid, terminiertes Event resized (month-resize-*) |
nil |
.timed(start, end) — die bewegte Seite behält ihre Wandzeit, nur der Tag wandert |
Praktisch heißt das: ein Handler, der stumpf api.reschedule(id, to: payload.next)
aufruft, funktioniert weiter — next trägt immer die aufgelöste Ziel-Zeit
(all-day oder timed). Wer aber aus payload.target.minutes eine Uhrzeit
ableitet, muss den nil-Fall (Month/Band) explizit behandeln; sonst verliert /
verwürfelt er Datums-Drops. Auf next.time per switch (.allDay / .timed)
verzweigen ist der robuste Weg.
Die Drop-Mathematik (applyMoveToEvent, buildDropPayload, EventDropPayload
mit disambiguation-Feld) ist fixture-verifiziert gegen die TS-Implementierung.
- Recurrence (RRULE) — Serien bis dahin im Backend expandieren und als konkrete Events liefern (gleiches Vorgehen wie im Web dokumentiert).
- Dark Theme (kommt später), Timeline-View (bewusst raus).
- Month-Zeilen-Expansion kommt NIE (Entscheidung 2026-07-10): Das war im Web nur ein Workaround; auf iOS scrollen volle Zellen intern — alle Events bleiben ohne Expansion erreichbar.
Hinweis WorkWeek am iPhone (compact): Der Wochen-Strip zeigt nur die Arbeitstage; fällt der Cursor auf ein Wochenende, springt die Auswahl auf den nächsten Arbeitstag. Am iPad (regular) werden nur die Arbeitstage als Spalten gerendert — das Lade-Fenster bleibt in beiden Fällen die volle Woche.
| Typ | Zweck |
|---|---|
PlainDate / PlainTime / PlainDateTime |
zonenlose Kalender-Werte (ISO-Parsing via init(isoString:)) |
ZonedDateTime |
Wandzeit + IANA-Zone + Instant; withTimeZone, adding(minutes:/days:) |
CalendarEvent<Meta> / EventTime |
Event; .timed / .allDay, end exklusiv |
CalendarEventMeta / BasicEventMeta |
title/colorHex-Konvention |
ScheduledTimeWire + parse/formatScheduledTime, parsePlainDate |
Backend-Contract |
DstPolicy / DstResolutionError |
Gap/Overlap-Auflösung (compatible/reject/earlier/later) |
CalendarView, computeViewWindow, navigateCursor |
Sichtfenster + Navigation (Core) |
TimeGridScrollRequest / AgendaScrollRequest |
programmatisches Scrollen |
CalendarTheme + .calendarTheme(_:) |
Design-Tokens (Light) |