From 79a7cc6a3d2ae8f73c0d16c249fe9b53269061d1 Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 4 Sep 2026 00:56:28 +0200 Subject: [PATCH] feat(calendar): Wiederholungen, Tagesansicht, Terminfarben, Farbpunkt als Schalter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fünf Wünsche aus dem Durchtesten: 1. Wiederholungen einstellbar (täglich/wöchentlich/monatlich/jährlich, alle N, Ende nie/nach Anzahl/an einem Datum). Der eigentliche Aufwand lag nicht im Formular, sondern im Aufklappen: eine wöchentliche Reihe erschien sonst als *ein* Eintrag. Der Server klappt sie jetzt im angefragten Zeitfenster auf (?from=&to=, rrule-go über go-icals RecurrenceSet), die Ansichten fragen ihren sichtbaren Ausschnitt an. Geprüft: 5 Termine wöchentlich, nach Umstellung auf 14-tägig korrekt neu gerechnet, Fenster einer Woche liefert genau einen. Regeln, die das Formular nicht abbildet (etwa jeder zweite Dienstag im Monat), werden weiterhin korrekt *angezeigt* -- geprüft, die vier Termine stimmen -- aber nicht zum Bearbeiten angeboten (409). Ein halb verstandenes Speichern würde die Regel sonst still beschneiden. 2. Tag wird erst geöffnet, statt sofort einen Termin anzulegen: Klick auf einen Tag zeigt dessen Termine als Liste, dort wählt man einen aus oder legt bewusst einen neuen an. 3. Eigene Farbe je Termin (COLOR nach RFC 7986), leer = Kalenderfarbe. 4. Kalenderliste ohne Kästchen: der Farbpunkt selbst ist der Schalter -- gefüllt heißt sichtbar, leerer Ring ausgeblendet. 5. Ganztägig als Schiebeschalter statt Kästchen -- es stellt das Formular um, das ist keine Nebenangabe. Co-Authored-By: Claude Opus 5 --- backend/go.mod | 2 +- backend/internal/calendar/events_api.go | 372 ++++++++++++++---- frontend/src/api/client.ts | 40 +- .../components/calendar/CalendarDialog.tsx | 17 +- .../src/components/calendar/DayDialog.tsx | 119 ++++++ .../src/components/calendar/EventDialog.tsx | 148 +++++-- frontend/src/components/calendar/controls.tsx | 95 +++++ frontend/src/pages/CalendarPage.tsx | 220 ++++++----- 8 files changed, 795 insertions(+), 218 deletions(-) create mode 100644 frontend/src/components/calendar/DayDialog.tsx create mode 100644 frontend/src/components/calendar/controls.tsx diff --git a/backend/go.mod b/backend/go.mod index 4275b9c..27c3474 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -13,6 +13,7 @@ require ( github.com/gorilla/sessions v1.4.0 github.com/jackc/pgx/v5 v5.10.0 github.com/rs/zerolog v1.33.0 + github.com/teambition/rrule-go v1.8.2 golang.org/x/crypto v0.53.0 golang.org/x/image v0.42.0 golang.org/x/net v0.56.0 @@ -29,7 +30,6 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - github.com/teambition/rrule-go v1.8.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect diff --git a/backend/internal/calendar/events_api.go b/backend/internal/calendar/events_api.go index d31e723..f0165e5 100644 --- a/backend/internal/calendar/events_api.go +++ b/backend/internal/calendar/events_api.go @@ -9,19 +9,44 @@ import ( ical "github.com/emersion/go-ical" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/teambition/rrule-go" "aether/internal/auth" "aether/pkg/response" ) +// propColor ist die Farbe eines einzelnen Termins (RFC 7986). Der Standard +// sieht dort einen CSS3-*Namen* vor, wir schreiben einen Hex-Wert – so wie +// es auch Nextcloud und Thunderbird tun, weil eine Palette aus benannten +// Farben in einer Oberfläche niemand pflegen will. Clients, die die +// Eigenschaft nicht verstehen, überspringen sie; niemand zeigt deswegen +// etwas Falsches an. +const propColor = "COLOR" + +// maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem +// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre +// sonst allein genug, um eine Monatsansicht lahmzulegen. +const maxOccurrences = 750 + +// RecurrenceDTO ist die **vereinfachte** Wiederholung, die die Oberfläche +// anbietet: alle N Tage/Wochen/Monate/Jahre, wahlweise mit Ende nach einer +// Anzahl oder an einem Datum. +// +// Eine RRULE kann weit mehr ("jeden zweiten Dienstag im Monat, außer im +// August"). Solche Regeln werden gelesen und unverändert behalten, aber +// nicht in dieses Formular gezwängt – siehe recurrenceFromRule. +type RecurrenceDTO struct { + Freq string `json:"freq"` // DAILY | WEEKLY | MONTHLY | YEARLY + Interval int `json:"interval"` // >= 1 + Count int `json:"count,omitempty"` + Until *time.Time `json:"until,omitempty"` +} + // EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche. // Anders als CalDAV (byte-genaues .ics) bildet sie nur die gängigen Felder -// ab – reicht für Anlegen/Ändern eines einfachen Termins im Browser. Ein -// über einen echten CalDAV-Client angelegter Termin mit Wiederholung bleibt -// über /caldav/ voll erhalten, zeigt sich hier nur mit Recurring=true und -// lässt sich in der Web-Oberfläche bewusst nicht bearbeiten (siehe Update) – -// eine RRULE über dieses vereinfachte Formular zu verändern, würde sie eher -// beschädigen als korrekt anpassen. +// ab. Was hier nicht auftaucht (Teilnehmer, Anhänge, Erinnerungen), bleibt +// im gespeicherten .ics trotzdem unangetastet erhalten – geändert wird nur, +// was das Formular auch wirklich kennt. type EventDTO struct { UID string `json:"uid"` Summary string `json:"summary"` @@ -31,11 +56,91 @@ type EventDTO struct { // belegten** Tages – in beide Richtungen gleich. Die exklusive // DTEND-Rechnung des .ics-Formats (letzter Tag + 1) bleibt vollständig // hier im Paket; wer diese Schnittstelle nutzt, rechnet nie mit ±1 Tag. - Start time.Time `json:"start"` - End *time.Time `json:"end,omitempty"` - AllDay bool `json:"all_day"` - Recurring bool `json:"recurring"` - ETag string `json:"etag"` + Start time.Time `json:"start"` + End *time.Time `json:"end,omitempty"` + AllDay bool `json:"all_day"` + // Color leer = Farbe des Kalenders. Ein eigener Wert übersteuert sie nur + // für diesen einen Termin. + Color string `json:"color,omitempty"` + // Recurring: es hängt überhaupt eine Wiederholung dran. + // Recurrence: die Regel, *sofern* sie sich im Formular abbilden lässt. + // Beides zusammen ergibt den Fall „wiederkehrend, aber zu verwickelt zum + // Bearbeiten" – dann ist Recurring true und Recurrence nil. + Recurring bool `json:"recurring"` + Recurrence *RecurrenceDTO `json:"recurrence,omitempty"` + // Bei aufgeklappten Wiederholungen tragen Start/End den *einzelnen* + // Termin, SeriesStart/SeriesEnd dagegen die Reihe selbst. Das Formular + // bearbeitet immer die Reihe und braucht daher deren Anfang. + SeriesStart *time.Time `json:"series_start,omitempty"` + SeriesEnd *time.Time `json:"series_end,omitempty"` + ETag string `json:"etag"` +} + +func firstEventComponent(cal *ical.Calendar) *ical.Component { + for _, child := range cal.Children { + if child.Name == "VEVENT" || child.Name == "VTODO" { + return child + } + } + return nil +} + +// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt +// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig – +// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000"). +func isDateOnly(prop *ical.Prop) bool { + return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8 +} + +var freqToString = map[rrule.Frequency]string{ + rrule.DAILY: "DAILY", rrule.WEEKLY: "WEEKLY", + rrule.MONTHLY: "MONTHLY", rrule.YEARLY: "YEARLY", +} + +var stringToFreq = map[string]rrule.Frequency{ + "DAILY": rrule.DAILY, "WEEKLY": rrule.WEEKLY, + "MONTHLY": rrule.MONTHLY, "YEARLY": rrule.YEARLY, +} + +// recurrenceFromRule bildet eine RRULE auf das einfache Formular ab – oder +// gibt nil zurück, wenn sie mehr kann, als das Formular darstellt. Wichtig +// ist die Richtung: Im Zweifel **nil**. Eine Regel, die wir nur halb +// verstehen, dürfen wir nicht zum Bearbeiten anbieten, sonst schreibt das +// Speichern die verlorenen Teile still weg. +func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO { + if opt == nil { + return nil + } + freq, ok := freqToString[opt.Freq] + if !ok { + return nil // stündlich und feiner bietet die Oberfläche nicht an + } + if len(opt.Bysetpos) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 || + len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 || len(opt.Byhour) > 0 || + len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 || len(opt.Byeaster) > 0 { + return nil + } + // BYDAY mit genau dem Wochentag des Starts ist die Schreibweise vieler + // Clients für „jede Woche" und ändert nichts an der Bedeutung. Alles + // andere (mehrere Tage, Wochentag ≠ Start) ist eine echte Erweiterung. + if len(opt.Byweekday) > 0 { + if freq != "WEEKLY" || len(opt.Byweekday) != 1 { + return nil + } + if opt.Byweekday[0].Day() != int(start.Weekday()+6)%7 { + return nil + } + } + + dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count} + if dto.Interval < 1 { + dto.Interval = 1 + } + if !opt.Until.IsZero() { + u := opt.Until.UTC() + dto.Until = &u + } + return dto } func eventFromObject(o *Object) (*EventDTO, error) { @@ -44,13 +149,7 @@ func eventFromObject(o *Object) (*EventDTO, error) { if err != nil { return nil, err } - var ev *ical.Component - for _, child := range cal.Children { - if child.Name == "VEVENT" || child.Name == "VTODO" { - ev = child - break - } - } + ev := firstEventComponent(cal) if ev == nil { return nil, errNoEventComponent } @@ -58,11 +157,11 @@ func eventFromObject(o *Object) (*EventDTO, error) { summary, _ := ev.Props.Text(ical.PropSummary) description, _ := ev.Props.Text(ical.PropDescription) location, _ := ev.Props.Text(ical.PropLocation) - _, recurring := ev.Props["RRULE"] + color, _ := ev.Props.Text(propColor) dto := &EventDTO{ UID: o.UID, Summary: summary, Description: description, Location: location, - Recurring: recurring, ETag: o.ETag, + Color: color, ETag: o.ETag, } startProp := ev.Props.Get(ical.PropDateTimeStart) @@ -93,14 +192,67 @@ func eventFromObject(o *Object) (*EventDTO, error) { end := dto.Start dto.End = &end } + + if opt, err := ev.Props.RecurrenceRule(); err == nil && opt != nil { + dto.Recurring = true + dto.Recurrence = recurrenceFromRule(opt, dto.Start) + } return dto, nil } -// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt -// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig – -// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000"). -func isDateOnly(prop *ical.Prop) bool { - return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8 +// expand klappt eine Wiederholung im gefragten Zeitfenster in einzelne +// Termine auf. Ohne das zeigt eine wöchentliche Reihe im Kalender genau +// **einen** Eintrag – nämlich den ersten. +func expand(o *Object, base *EventDTO, from, to time.Time) []EventDTO { + if !base.Recurring { + // Einzeltermin: nur, wenn er das Fenster überhaupt berührt. + end := base.Start + if base.End != nil { + end = *base.End + } + if end.Before(from) || base.Start.After(to) { + return nil + } + return []EventDTO{*base} + } + + dec := ical.NewDecoder(strings.NewReader(o.Data)) + cal, err := dec.Decode() + if err != nil { + return []EventDTO{*base} + } + ev := firstEventComponent(cal) + if ev == nil { + return []EventDTO{*base} + } + set, err := ev.RecurrenceSet(time.UTC) + if err != nil || set == nil { + return []EventDTO{*base} + } + + // Die Dauer bleibt über alle Einzeltermine gleich; nur der Anfang wandert. + var dur time.Duration + if base.End != nil { + dur = base.End.Sub(base.Start) + } + seriesStart, seriesEnd := base.Start, base.End + + out := make([]EventDTO, 0, 16) + for i, occ := range set.Between(from, to, true) { + if i >= maxOccurrences { + break + } + item := *base + item.Start = occ.UTC() + if seriesEnd != nil { + end := item.Start.Add(dur) + item.End = &end + } + item.SeriesStart = &seriesStart + item.SeriesEnd = seriesEnd + out = append(out, item) + } + return out } var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"} @@ -109,7 +261,30 @@ type icalError struct{ msg string } func (e *icalError) Error() string { return e.msg } -// GET /api/v1/calendars/{id}/events +// parseWindow liest from/to aus der Abfrage. Ohne Angabe ein großzügiges +// Fenster um heute – die Listenansicht fragt ohne Grenzen und soll trotzdem +// etwas sehen, ohne dass eine tägliche Reihe über Jahre aufgeklappt wird. +func parseWindow(r *http.Request) (from, to time.Time) { + now := time.Now().UTC() + from = now.AddDate(0, -1, 0) + to = now.AddDate(1, 0, 0) + if v := r.URL.Query().Get("from"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + from = t.UTC() + } + } + if v := r.URL.Query().Get("to"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + to = t.UTC() + } + } + if to.Before(from) { + to = from + } + return from, to +} + +// GET /api/v1/calendars/{id}/events?from=…&to=… func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) calID, err := uuid.Parse(chi.URLParam(r, "id")) @@ -126,24 +301,28 @@ func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusInternalServerError, "list events failed") return } + from, to := parseWindow(r) + out := make([]EventDTO, 0, len(objs)) for i := range objs { - dto, err := eventFromObject(&objs[i]) + base, err := eventFromObject(&objs[i]) if err != nil { continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen } - out = append(out, *dto) + out = append(out, expand(&objs[i], base, from, to)...) } response.JSON(w, http.StatusOK, out) } type eventBody struct { - Summary string `json:"summary"` - Description string `json:"description"` - Location string `json:"location"` - Start time.Time `json:"start"` - End *time.Time `json:"end"` - AllDay bool `json:"all_day"` + Summary string `json:"summary"` + Description string `json:"description"` + Location string `json:"location"` + Start time.Time `json:"start"` + End *time.Time `json:"end"` + AllDay bool `json:"all_day"` + Color string `json:"color"` + Recurrence *RecurrenceDTO `json:"recurrence"` } func buildEventICal(uid string, body eventBody) *ical.Calendar { @@ -163,6 +342,10 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar { if body.Location != "" { ev.Props.SetText(ical.PropLocation, body.Location) } + if body.Color != "" { + ev.Props.SetText(propColor, body.Color) + } + if body.AllDay { // Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb // hier ausdrücklich .UTC() – der Aufrufer schickt für einen @@ -190,19 +373,56 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar { ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC()) } } + + if rule := ruleFromRecurrence(body); rule != nil { + ev.Props.SetRecurrenceRule(rule) + } + root.Children = append(root.Children, ev.Component) return root } -// POST /api/v1/calendars/{id}/events -func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { +func ruleFromRecurrence(body eventBody) *rrule.ROption { + rec := body.Recurrence + if rec == nil { + return nil + } + freq, ok := stringToFreq[strings.ToUpper(rec.Freq)] + if !ok { + return nil + } + interval := rec.Interval + if interval < 1 { + interval = 1 + } + opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count} + if rec.Until != nil { + // Bis zum **Ende** des genannten Tages, damit ein Termin an diesem + // Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte + // Termin je nach Uhrzeit heraus – ein Fehler, den man erst Wochen + // später bemerkt. + u := rec.Until.UTC() + opt.Until = time.Date(u.Year(), u.Month(), u.Day(), 23, 59, 59, 0, time.UTC) + } + return opt +} + +func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) { user := auth.UserFromContext(r.Context()) calID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { - response.Error(w, http.StatusNotFound, "calendar not found") - return + return uuid.UUID{}, false } if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil { + return uuid.UUID{}, false + } + return calID, true +} + +// POST /api/v1/calendars/{id}/events +func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { + calID, ok := h.calendarOf(r) + if !ok { response.Error(w, http.StatusNotFound, "calendar not found") return } @@ -213,35 +433,23 @@ func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { } uid := uuid.New().String() - cal := buildEventICal(uid, body) - data, err := encodeICal(cal) - if err != nil { - response.Error(w, http.StatusInternalServerError, "encode event failed") - return - } - start, end := timeRange(cal) - obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end) + dto, err := h.writeEvent(r, calID, uid, body) if err != nil { response.Error(w, http.StatusInternalServerError, "create event failed") return } - dto, err := eventFromObject(obj) - if err != nil { - response.Error(w, http.StatusInternalServerError, "read back event failed") - return - } response.JSON(w, http.StatusCreated, dto) } // PATCH /api/v1/calendars/{id}/events/{uid} +// +// Bei einer Reihe gilt die Änderung für **alle** Termine der Reihe. Einzelne +// Ausnahmen (RECURRENCE-ID) sind bewusst nicht dabei: Sie brauchen eine +// eigene Bedienführung ("dieser Termin / alle folgenden / alle"), und ohne +// die richtet ein stilles "gilt für alles" mehr Schaden an als Nutzen. func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { - user := auth.UserFromContext(r.Context()) - calID, err := uuid.Parse(chi.URLParam(r, "id")) - if err != nil { - response.Error(w, http.StatusNotFound, "calendar not found") - return - } - if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil { + calID, ok := h.calendarOf(r) + if !ok { response.Error(w, http.StatusNotFound, "calendar not found") return } @@ -255,12 +463,11 @@ func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusInternalServerError, "load event failed") return } - existingDTO, err := eventFromObject(existing) - if err == nil && existingDTO.Recurring { - // Diese Ansicht kennt keine RRULE-Bearbeitung – ein Formular mit nur - // "Titel/Start/Ende" würde die Wiederholungsregel beim Speichern - // stillschweigend verwerfen. Lieber ablehnen als Daten beschädigen. - response.Error(w, http.StatusConflict, "recurring events can only be edited in a CalDAV client") + if dto, err := eventFromObject(existing); err == nil && dto.Recurring && dto.Recurrence == nil { + // Wiederholung, die das Formular nicht vollständig abbildet (etwa + // „jeden zweiten Dienstag"). Speichern würde die Regel auf das + // reduzieren, was das Formular kennt – also lieber ablehnen. + response.Error(w, http.StatusConflict, "this recurrence rule can only be edited in a CalDAV client") return } @@ -269,35 +476,32 @@ func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { response.Error(w, http.StatusBadRequest, "summary and start are required") return } - cal := buildEventICal(uid, body) - data, err := encodeICal(cal) - if err != nil { - response.Error(w, http.StatusInternalServerError, "encode event failed") - return - } - start, end := timeRange(cal) - obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end) + dto, err := h.writeEvent(r, calID, uid, body) if err != nil { response.Error(w, http.StatusInternalServerError, "update event failed") return } - dto, err := eventFromObject(obj) - if err != nil { - response.Error(w, http.StatusInternalServerError, "read back event failed") - return - } response.JSON(w, http.StatusOK, dto) } +func (h *APIHandler) writeEvent(r *http.Request, calID uuid.UUID, uid string, body eventBody) (*EventDTO, error) { + cal := buildEventICal(uid, body) + data, err := encodeICal(cal) + if err != nil { + return nil, err + } + start, end := timeRange(cal) + obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end) + if err != nil { + return nil, err + } + return eventFromObject(obj) +} + // DELETE /api/v1/calendars/{id}/events/{uid} func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) { - user := auth.UserFromContext(r.Context()) - calID, err := uuid.Parse(chi.URLParam(r, "id")) - if err != nil { - response.Error(w, http.StatusNotFound, "calendar not found") - return - } - if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil { + calID, ok := h.calendarOf(r) + if !ok { response.Error(w, http.StatusNotFound, "calendar not found") return } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4407437..03bea84 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -902,20 +902,40 @@ export interface CalendarItem { created_at: string } +/** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/ + * Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients + * bleiben erhalten, kommen hier aber als `recurrence: undefined` an und + * sind dann nur lesbar (siehe events_api.go). */ +export interface Recurrence { + freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' + interval: number + count?: number + /** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */ + until?: string +} + export interface CalendarEvent { uid: string summary: string description: string location: string - /** ISO 8601, immer UTC ("...Z") – der Server rechnet nicht um, das - * übernimmt der Browser beim Anzeigen (new Date(...) kennt die lokale - * Zeitzone bereits). */ + /** ISO 8601, immer UTC ("...Z"). Bei ganztägigen Terminen Mitternacht UTC + * als reine Datumsangabe – nie über new Date(...) in Ortszeit umrechnen, + * sondern über eventDay() aus components/calendar/dates.ts. */ start: string end?: string all_day: boolean - /** Über einen echten CalDAV-Client mit Wiederholung angelegt – hier nur - * sichtbar, nicht bearbeitbar (siehe events_api.go, UpdateEvent). */ + /** Leer = Farbe des Kalenders. */ + color?: string + /** Es hängt überhaupt eine Wiederholung dran. */ recurring: boolean + /** Die Regel – nur gesetzt, wenn sie sich im Formular abbilden lässt. */ + recurrence?: Recurrence + /** Bei aufgeklappten Wiederholungen: Anfang/Ende der **Reihe**, während + * start/end den einzelnen Termin tragen. Das Formular bearbeitet immer + * die Reihe. */ + series_start?: string + series_end?: string etag: string } @@ -926,6 +946,8 @@ export interface EventInput { start: string end?: string all_day: boolean + color?: string + recurrence?: Recurrence | null } export const calendarApi = { @@ -949,7 +971,13 @@ export const calendarApi = { return json.data }, - listEvents: (id: string) => request('GET', `/calendars/${id}/events`), + /** Termine im Zeitfenster. Wiederholungen kommen vom Server bereits in + * einzelne Termine aufgeklappt zurück – deshalb braucht es die Grenzen: + * eine tägliche Reihe ohne Ende hat sonst kein natürliches Ende. */ + listEvents: (id: string, from?: Date, to?: Date) => { + const qs = from && to ? `?from=${from.toISOString()}&to=${to.toISOString()}` : '' + return request('GET', `/calendars/${id}/events${qs}`) + }, createEvent: (id: string, input: EventInput) => request('POST', `/calendars/${id}/events`, input), updateEvent: (id: string, uid: string, input: EventInput) => diff --git a/frontend/src/components/calendar/CalendarDialog.tsx b/frontend/src/components/calendar/CalendarDialog.tsx index 0aae29e..f2b4ac5 100644 --- a/frontend/src/components/calendar/CalendarDialog.tsx +++ b/frontend/src/components/calendar/CalendarDialog.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useState, type FormEvent } from 'react' import { useOverlayDismiss } from '../../hooks/useOverlayDismiss' import type { CalendarItem } from '../../api/client' - -const palette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#a142f4', '#ff6d00', '#00acc1', '#607d8b'] +import { ColorSwatches, palette } from './controls' interface Props { calendar?: CalendarItem @@ -50,18 +49,8 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) { /> -
- {palette.map(c => ( - + ))} +
+ +
+ + +
+ + + ) +} diff --git a/frontend/src/components/calendar/EventDialog.tsx b/frontend/src/components/calendar/EventDialog.tsx index 48e6f87..79a9550 100644 --- a/frontend/src/components/calendar/EventDialog.tsx +++ b/frontend/src/components/calendar/EventDialog.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react' import { useOverlayDismiss } from '../../hooks/useOverlayDismiss' -import type { CalendarEvent, CalendarItem, EventInput } from '../../api/client' +import type { CalendarEvent, CalendarItem, EventInput, Recurrence } from '../../api/client' import { addDays, allDayISO, eventDay, timedISO, toDateInput, toTimeInput } from './dates' +import { ColorSwatches, Toggle } from './controls' interface Props { /** Fehlt er, entsteht ein neuer Termin. */ @@ -15,43 +16,66 @@ interface Props { onCancel: () => void } +type FreqChoice = 'NONE' | Recurrence['freq'] +type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL' + +const freqLabels: Record = { + NONE: 'Einmalig', + DAILY: 'Täglich', + WEEKLY: 'Wöchentlich', + MONTHLY: 'Monatlich', + YEARLY: 'Jährlich', +} + export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) { const overlay = useOverlayDismiss(onCancel) const titleRef = useRef(null) const allDayInit = event?.all_day ?? false - const start = event ? eventDay(event.start, allDayInit) : defaultStart - const startTimed = event && !allDayInit ? new Date(event.start) : defaultStart - // Bei ganztägigen Terminen liefert die Schnittstelle den letzten *belegten* - // Tag – genau das, was im Formular stehen soll (siehe events_api.go). - const end = event?.end - ? eventDay(event.end, allDayInit) + // Bei einer Reihe bearbeitet das Formular die **Reihe**, nicht den + // angeklickten Einzeltermin – deshalb series_start, wo vorhanden. + const baseStartISO = event?.series_start ?? event?.start + const baseEndISO = event?.series_end ?? event?.end + const start = baseStartISO ? eventDay(baseStartISO, allDayInit) : defaultStart + const startTimed = baseStartISO && !allDayInit ? new Date(baseStartISO) : defaultStart + const end = baseEndISO + ? eventDay(baseEndISO, allDayInit) : new Date(startTimed.getTime() + 60 * 60 * 1000) const [calId, setCalId] = useState(calendarId) const [summary, setSummary] = useState(event?.summary ?? '') const [description, setDescription] = useState(event?.description ?? '') const [location, setLocation] = useState(event?.location ?? '') + const [color, setColor] = useState(event?.color ?? '') const [allDay, setAllDay] = useState(allDayInit) const [startDate, setStartDate] = useState(toDateInput(start)) const [startTime, setStartTime] = useState(toTimeInput(startTimed)) const [endDate, setEndDate] = useState(toDateInput(end)) const [endTime, setEndTime] = useState(toTimeInput(end)) + const rec = event?.recurrence + const [freq, setFreq] = useState(rec?.freq ?? 'NONE') + const [interval, setInterval] = useState(String(rec?.interval ?? 1)) + const [endChoice, setEndChoice] = useState( + rec?.count ? 'COUNT' : rec?.until ? 'UNTIL' : 'NEVER') + const [count, setCount] = useState(String(rec?.count ?? 10)) + const [until, setUntil] = useState( + rec?.until ? toDateInput(eventDay(rec.until, true)) : toDateInput(addDays(start, 90))) + useEffect(() => { titleRef.current?.focus() }, []) - // Escape schließt – erwartet man bei jedem Dialog, und ohne fühlt sich die - // Oberfläche zäh an. useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [onCancel]) - const readOnly = !!event?.recurring + // Nur Regeln, die dieses Formular vollständig abbildet, sind bearbeitbar. + // Alles andere ("jeden zweiten Dienstag im Monat") bliebe beim Speichern + // auf der Strecke – der Server lehnt es deshalb auch ab. + const readOnly = !!event?.recurring && !event?.recurrence + const isSeries = !!event?.recurring - /** Ende darf nicht vor dem Start liegen – statt einer Fehlermeldung zieht - * das Ende einfach mit, so wie man es von anderen Kalendern kennt. */ function changeStartDate(v: string) { const shift = (+new Date(v) - +new Date(startDate)) / 86400000 setStartDate(v) @@ -60,6 +84,14 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId } } + function buildRecurrence(): Recurrence | null { + if (freq === 'NONE') return null + const r: Recurrence = { freq, interval: Math.max(1, parseInt(interval, 10) || 1) } + if (endChoice === 'COUNT') r.count = Math.max(1, parseInt(count, 10) || 1) + if (endChoice === 'UNTIL') r.until = allDayISO(until) + return r + } + function handleSubmit(e: FormEvent) { e.preventDefault() if (readOnly || !summary.trim()) return @@ -80,6 +112,8 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId description: description.trim() || undefined, location: location.trim() || undefined, all_day: allDay, + color: color || undefined, + recurrence: buildRecurrence(), start: startISO, end: endISO, }, calId) @@ -94,6 +128,12 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId const labelStyle: CSSProperties = { fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block', } + const noteStyle: CSSProperties = { + background: 'var(--color-surface-2)', border: '1px solid var(--color-border)', + borderRadius: 8, padding: '8px 10px', fontSize: 12.5, + color: 'var(--color-text-muted)', marginBottom: 14, lineHeight: 1.45, + } + const calColor = calendars.find(c => c.id === calId)?.color ?? '#4285f4' return (

- {event ? 'Termin bearbeiten' : 'Neuer Termin'} + {event ? (isSeries ? 'Terminreihe bearbeiten' : 'Termin bearbeiten') : 'Neuer Termin'}

{readOnly && ( -
- Wiederholender Termin – nur mit einem CalDAV-Client (z. B. DAVx5) bearbeitbar, nicht hier. +
+ Diese Wiederholungsregel kann dieses Formular nicht vollständig + abbilden – zum Ändern einen CalDAV-Client verwenden (z. B. DAVx5). + Der Termin bleibt unverändert erhalten. +
+ )} + {isSeries && !readOnly && ( +
+ Terminreihe: Änderungen gelten für alle Termine der + Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag.
)} @@ -135,10 +180,9 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
)} - +
+ +
@@ -154,7 +198,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId )}
-
+
+ {/* ── Wiederholung ───────────────────────────────────────────── */} +
+ +
+ + {freq !== 'NONE' && ( +
+ alle + setInterval(e.target.value)} + style={{ ...inputStyle, width: 60 }} /> +
+ )} +
+
+ + {freq !== 'NONE' && ( +
+ +
+ + {endChoice === 'COUNT' && ( +
+ setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} /> + Mal +
+ )} + {endChoice === 'UNTIL' && ( + setUntil(e.target.value)} style={{ ...inputStyle, width: 160 }} /> + )} +
+
+ )} + + {/* ── Farbe ──────────────────────────────────────────────────── */} +
+ + +
+
setLocation(e.target.value)} style={inputStyle} /> @@ -185,7 +283,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId padding: '8px 14px', borderRadius: 8, border: 'none', background: 'var(--color-danger)', color: '#fff', fontSize: 13, cursor: 'pointer', }}> - Löschen + {isSeries ? 'Reihe löschen' : 'Löschen'} ) : }
diff --git a/frontend/src/components/calendar/controls.tsx b/frontend/src/components/calendar/controls.tsx new file mode 100644 index 0000000..8a3bbe0 --- /dev/null +++ b/frontend/src/components/calendar/controls.tsx @@ -0,0 +1,95 @@ +import type { CSSProperties } from 'react' + +/** Eine Palette für Kalender **und** Termine – zwei getrennte Listen liefen + * sonst mit der Zeit auseinander, und ein Termin in einer Farbe, die es als + * Kalenderfarbe nicht gibt, sieht aus wie ein Fehler. */ +export const palette = [ + '#4285f4', '#ea4335', '#fbbc04', '#34a853', + '#a142f4', '#ff6d00', '#00acc1', '#607d8b', +] + +/** + * Schiebeschalter statt Kästchen. + * + * Nicht nur Zierde: „Ganztägig" schaltet das Formular um (die Uhrzeitfelder + * verschwinden). Ein Kästchen liest sich wie eine Angabe, die man nebenbei + * ankreuzt – ein Schalter zeigt, dass hier etwas umgestellt wird. + */ +export function Toggle({ checked, onChange, disabled, label }: { + checked: boolean + onChange: (v: boolean) => void + disabled?: boolean + label: string +}) { + return ( + + ) +} + +/** + * Farbwahl als Kreise. Der gewählte Kreis ist gefüllt, die anderen sind + * Umrisse – dieselbe Sprache wie in der Kalenderliste, wo ein gefüllter + * Punkt „wird angezeigt" heißt. + */ +export function ColorSwatches({ value, onChange, inherit, disabled }: { + value: string + onChange: (c: string) => void + /** Ist sie gesetzt, gibt es zusätzlich die Wahl „Farbe des Kalenders" + * (leerer Wert) und dieser Kreis zeigt sie an. */ + inherit?: string + disabled?: boolean +}) { + const dot = (color: string, selected: boolean, onClick: () => void, key: string, title: string) => ( +
- {calendars.map(cal => ( -
- toggleVisible(cal.id)} - style={{ accentColor: cal.color, cursor: 'pointer' }} /> - toggleVisible(cal.id)} style={{ - flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer', - opacity: hidden.has(cal.id) ? 0.45 : 1, + {calendars.map(cal => { + const on = !hidden.has(cal.id) + return ( +
- {cal.name} - - setCalDialog({ calendar: cal })} - onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')} - onImport={() => triggerImport(cal.id)} - onDelete={() => setDeleteCalendar(cal)} - /> -
- ))} + {/* Der Farbpunkt **ist** der Schalter: gefüllt = wird + angezeigt, leerer Ring = ausgeblendet. Ein Kästchen + daneben sagte dasselbe zweimal. */} +
+ ) + })} {calendars.length === 0 && (

Noch kein Kalender – über + einen anlegen. @@ -361,10 +399,10 @@ export default function CalendarPage() {

{viewMode === 'month' && ( - + )} {viewMode === 'week' && ( - + )} {viewMode === 'list' && ( @@ -374,6 +412,15 @@ export default function CalendarPage() { + {dayDialog && ( + { setDayDialog(null); openEditEvent(ev) }} + onCreate={() => { const d = dayDialog; setDayDialog(null); openNewEvent(suggestStart(d)) }} + onClose={() => setDayDialog(null)} + /> + )} {eventDialog && ( setDeleteEvent(null)} @@ -478,8 +525,7 @@ function timeLabel(d: Date): string { /** Ein Termin im Monatsraster bzw. in der ganztägigen Zeile der Woche. * `stopPropagation` ist hier das Entscheidende: Ohne das reichte der Klick - * an die Tageszelle darunter durch, die sofort den Dialog „Neuer Termin" - * öffnete – ein vorhandener Termin ließ sich dadurch gar nicht öffnen. */ + * an die Tageszelle darunter durch und öffnete den Tag statt den Termin. */ function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) { return (
void > {!ev.all_day && {timeLabel(ev.startsAt)}} {ev.summary || '(ohne Titel)'} + {ev.recurring && ↻}
) } -function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: { +function MonthGrid({ cursor, byDay, onOpenDay, onEventClick }: { cursor: Date byDay: Map - onDayClick: (start: Date) => void + onOpenDay: (day: Date) => void onEventClick: (ev: EventWithCalendar) => void - onMore: (day: Date) => void }) { const gridStart = startOfWeek(startOfMonth(cursor)) const today = new Date() @@ -532,7 +578,7 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
onDayClick(suggestStart(day))} + onClick={() => onOpenDay(day)} style={{ borderRight: '1px solid var(--color-border)', padding: 4, cursor: 'pointer', background: inMonth ? 'transparent' : 'var(--color-surface-2)', @@ -550,18 +596,12 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
{events.slice(0, 3).map(ev => ( - onEventClick(ev)} /> + onEventClick(ev)} /> ))} {events.length > 3 && ( - + )}
@@ -624,10 +664,11 @@ function layoutDay(events: EventWithCalendar[], day: Date): Slot[] { })) } -function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: { +function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick }: { cursor: Date byDay: Map onSlotClick: (start: Date) => void + onOpenDay: (day: Date) => void onEventClick: (ev: EventWithCalendar) => void }) { const scrollRef = useRef(null) @@ -643,7 +684,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: { }, []) const cols = `${GUTTER}px repeat(7, minmax(0, 1fr))` - const nowTop = ((now: Date) => (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_H)(today) + const nowTop = (today.getHours() * 60 + today.getMinutes()) / 60 * HOUR_H /** Klick ins Raster: die angeklickte Stunde, nicht Mitternacht. */ function slotFromClick(e: MouseEvent, day: Date) { @@ -657,13 +698,17 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: { return (
- {/* Kopfzeile */} + {/* Kopfzeile – der Tag darin öffnet den Tag, wie im Monatsraster. */}
{days.map(day => { const isToday = isSameDay(day, today) return ( -
+
+ ) })}
- {/* Ganztägige Termine – eigene Zeile über dem Stundenraster, so wie sie - auch gemeint sind: ohne Uhrzeit. */} + {/* Ganztägige Termine – eigene Zeile über dem Stundenraster. */}
ganztg.
{days.map(day => ( -
onSlotClick(suggestStart(day))} +
onOpenDay(day)} style={{ borderLeft: '1px solid var(--color-border)', padding: 2, cursor: 'pointer', minWidth: 0 }}> {(byDay.get(dayKey(day)) ?? []).filter(ev => ev.all_day).map(ev => ( - onEventClick(ev)} /> + onEventClick(ev)} /> ))}
))} @@ -733,7 +777,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: { {slots.map(({ ev, top, height, col, cols: n }) => (
{ e.stopPropagation(); onEventClick(ev) }} title={`${ev.summary} – ${timeLabel(ev.startsAt)}`} @@ -746,7 +790,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: { }} >
- {ev.summary || '(ohne Titel)'} + {ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
{height > 28 && (
{timeLabel(ev.startsAt)}
@@ -794,7 +838,7 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE {isSameDay(day, today) && ' · heute'}
{evs.map(ev => ( -
onEventClick(ev)} style={{ +
onEventClick(ev)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6, }}> @@ -803,7 +847,7 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE {ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)} - {ev.summary || '(ohne Titel)'} + {ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''} {ev.location && (