From 688bb13bdeecc7186a98681bac7e085f2477bfe1 Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 4 Sep 2026 09:06:53 +0200 Subject: [PATCH] feat(calendar): einzelnen Termin aus einer Reihe streichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Löschen eines Serientermins gibt es jetzt zwei Wege statt einem: "Nur diesen Termin" und "Ganze Reihe". Das sind zwei verschiedene Absichten, und sie sind nicht ineinander umkehrbar -- eine einzelne Ja/Nein-Rückfrage musste sich bisher für eine davon entscheiden, und die andere war gar nicht erreichbar. "Nur diesen" wird als EXDATE in der Reihe vermerkt, so wie das iCalendar-Format eine Ausnahme ausdrückt: Die Reihe behält ihre Regel und ihre UID, jeder CalDAV-Client versteht es ohne Zutun. Bewusst nicht über ein Aufspalten in zwei Reihen gelöst -- das änderte die UID, und alle Geräte hielten den Rest für einen neuen Termin. Ein zweites Streichen desselben Termins bleibt folgenlos (sonst liefe die Datei mit der Zeit mit doppelten EXDATE-Zeilen voll), und bei einem Einzeltermin ohne Wiederholung sind beide Wege dasselbe -- der Server löscht dann einfach, statt den Aufrufer mit einer Unterscheidung zu behelligen, die es dort nicht gibt. Selbsttest um sechs Prüfungen erweitert (25 grün): Reihe steht, Streichen liefert 204, der gestrichene Termin fehlt und der Rest bleibt, EXDATE steht im .ics, zweimal Streichen ist harmlos, ganze Reihe löschen räumt alles weg. Co-Authored-By: Claude Opus 5 --- backend/internal/calendar/events_api.go | 88 ++++++++++++++++++- frontend/src/api/client.ts | 9 +- .../components/calendar/DeleteEventDialog.tsx | 73 +++++++++++++++ .../src/components/calendar/EventDialog.tsx | 5 +- frontend/src/pages/CalendarPage.tsx | 33 +++++-- scripts/kalender-selbsttest.py | 28 ++++++ 6 files changed, 223 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/calendar/DeleteEventDialog.tsx diff --git a/backend/internal/calendar/events_api.go b/backend/internal/calendar/events_api.go index 7179a97..a508a66 100644 --- a/backend/internal/calendar/events_api.go +++ b/backend/internal/calendar/events_api.go @@ -629,6 +629,61 @@ func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, dto) } +var errNotRecurring = &icalError{"calendar: event has no recurrence"} + +// excludeOccurrence streicht einen einzelnen Termin aus einer Reihe, indem +// er als EXDATE vermerkt wird – die Art, wie das iCalendar-Format eine +// Ausnahme ausdrückt. Die Reihe selbst bleibt mit ihrer Regel bestehen, und +// jeder CalDAV-Client versteht das ohne Zutun. +// +// Bewusst **nicht** so gelöst, dass die Reihe in zwei Reihen zerlegt wird: +// Das änderte die UID, und alle Geräte hielten den Rest für einen neuen +// Termin. +func (h *APIHandler) excludeOccurrence(r *http.Request, calID uuid.UUID, uid string, at time.Time) error { + obj, err := h.store.GetObjectByUID(r.Context(), calID, uid) + if err != nil { + return err + } + cal, err := ical.NewDecoder(strings.NewReader(obj.Data)).Decode() + if err != nil { + return err + } + ev := firstEventComponent(cal) + if ev == nil { + return errNoEventComponent + } + if rule, err := ev.Props.RecurrenceRule(); err != nil || rule == nil { + return errNotRecurring + } + + startProp := ev.Props.Get(ical.PropDateTimeStart) + allDay := startProp != nil && isDateOnly(startProp) + + // Schon ausgetragen? Dann nichts tun – ein zweites EXDATE auf denselben + // Zeitpunkt wäre harmlos, aber die Datei liefe mit der Zeit voll. + for _, ex := range ev.Props[ical.PropExceptionDates] { + if t, err := ex.DateTime(time.UTC); err == nil && t.Equal(at) { + return nil + } + } + + ex := ical.NewProp(ical.PropExceptionDates) + if allDay { + ex.SetDate(at) + } else { + ex.SetDateTime(at) + } + ev.Props.Add(ex) + + data, err := encodeICal(cal) + if err != nil { + return err + } + start, end := timeRange(cal) + _, err = h.store.PutObject(r.Context(), calID, uid, obj.Component, data, etagFor(data), start, end) + return err +} + func (h *APIHandler) writeEvent(r *http.Request, calID uuid.UUID, uid string, body eventBody) (*EventDTO, error) { cal := buildEventICal(uid, body) data, err := encodeICal(cal) @@ -643,7 +698,11 @@ func (h *APIHandler) writeEvent(r *http.Request, calID uuid.UUID, uid string, bo return eventFromObject(obj) } -// DELETE /api/v1/calendars/{id}/events/{uid} +// DELETE /api/v1/calendars/{id}/events/{uid}[?occurrence=] +// +// Ohne occurrence verschwindet der Termin ganz – bei einer Reihe also die +// **ganze** Reihe. Mit occurrence fällt nur dieser eine Termin aus; die Reihe +// bleibt bestehen und behält ihre Regel. func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) { calID, ok := h.calendarOf(r) if !ok { @@ -651,6 +710,33 @@ func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) { return } uid := chi.URLParam(r, "uid") + + if occ := r.URL.Query().Get("occurrence"); occ != "" { + at, err := time.Parse(time.RFC3339, occ) + if err != nil { + response.Error(w, http.StatusBadRequest, "invalid occurrence") + return + } + switch err := h.excludeOccurrence(r, calID, uid, at.UTC()); err { + case nil: + response.NoContent(w) + case ErrNotFound: + response.Error(w, http.StatusNotFound, "event not found") + case errNotRecurring: + // Kein Serientermin: Dann ist „nur dieser" dasselbe wie „ganz + // weg" – also einfach löschen, statt den Aufrufer mit einer + // Unterscheidung zu behelligen, die es hier nicht gibt. + if err := h.store.DeleteObject(r.Context(), calID, uid); err != nil && err != ErrNotFound { + response.Error(w, http.StatusInternalServerError, "delete event failed") + return + } + response.NoContent(w) + default: + response.Error(w, http.StatusInternalServerError, "delete occurrence failed") + } + return + } + if err := h.store.DeleteObject(r.Context(), calID, uid); err == ErrNotFound { response.Error(w, http.StatusNotFound, "event not found") return diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c4fab6c..49acdae 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -992,8 +992,13 @@ export const calendarApi = { request('POST', `/calendars/${id}/events`, input), updateEvent: (id: string, uid: string, input: EventInput) => request('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input), - deleteEvent: (id: string, uid: string) => - request('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}`), + /** Ohne `occurrence` verschwindet der Termin ganz – bei einer Reihe also + * die ganze Reihe. Mit `occurrence` (ISO-Anfang des einzelnen Termins) + * fällt nur dieser eine aus, die Reihe bleibt bestehen. */ + deleteEvent: (id: string, uid: string, occurrence?: string) => { + const qs = occurrence ? `?occurrence=${encodeURIComponent(occurrence)}` : '' + return request('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}${qs}`) + }, } export { APIError } diff --git a/frontend/src/components/calendar/DeleteEventDialog.tsx b/frontend/src/components/calendar/DeleteEventDialog.tsx new file mode 100644 index 0000000..4f3b71b --- /dev/null +++ b/frontend/src/components/calendar/DeleteEventDialog.tsx @@ -0,0 +1,73 @@ +import { useEffect } from 'react' +import { useOverlayDismiss } from '../../hooks/useOverlayDismiss' + +/** + * Löschen eines Termins. + * + * Bei einem Serientermin gibt es zwei verschiedene Absichten, und sie sind + * nicht ineinander umkehrbar: „diesen einen absagen" und „die ganze Reihe + * beenden". Eine einzelne Ja/Nein-Rückfrage müsste sich für eine davon + * entscheiden – und die andere wäre dann gar nicht erreichbar. + */ +export default function DeleteEventDialog({ summary, recurring, onSingle, onSeries, onCancel }: { + summary: string + recurring: boolean + /** Nur diesen Termin. Bei einem Einzeltermin der einzige Weg. */ + onSingle: () => void + /** Ganze Reihe – nur bei Wiederholungen angeboten. */ + onSeries: () => void + onCancel: () => void +}) { + const overlay = useOverlayDismiss(onCancel) + + useEffect(() => { + function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onCancel]) + + const danger = { + padding: '9px 16px', borderRadius: 8, border: 'none', + background: 'var(--color-danger)', color: '#fff', fontSize: 13, + fontWeight: 600, cursor: 'pointer', width: '100%', + } as const + + return ( +
+
+

+ {recurring ? 'Serientermin löschen' : 'Termin löschen'} +

+

+ {recurring + ? <>„{summary || 'Ohne Titel'}" gehört zu einer Reihe. Was soll gelöscht werden? + : <>„{summary || 'Ohne Titel'}" unwiderruflich löschen?} +

+ +
+ + {recurring && ( + + )} + +
+
+
+ ) +} diff --git a/frontend/src/components/calendar/EventDialog.tsx b/frontend/src/components/calendar/EventDialog.tsx index ab95cda..61a6313 100644 --- a/frontend/src/components/calendar/EventDialog.tsx +++ b/frontend/src/components/calendar/EventDialog.tsx @@ -368,7 +368,10 @@ 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', }}> - {isSeries ? 'Reihe löschen' : 'Löschen'} + {/* Nicht „Reihe löschen": Ob die ganze Reihe oder nur dieser + eine Termin verschwindet, wird erst im nächsten Schritt + gefragt – hier vorzugreifen wäre eine falsche Ansage. */} + Löschen ) : }
diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index c7a4030..fe90300 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -8,6 +8,7 @@ import ConfirmDialog from '../components/ConfirmDialog' import EventDialog from '../components/calendar/EventDialog' import CalendarDialog from '../components/calendar/CalendarDialog' import DayDialog, { type DayEvent } from '../components/calendar/DayDialog' +import DeleteEventDialog from '../components/calendar/DeleteEventDialog' import { addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek, } from '../components/calendar/dates' @@ -101,7 +102,8 @@ export default function CalendarPage() { const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; start: Date } | null>(null) const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null) const [deleteCalendar, setDeleteCalendar] = useState(null) - const [deleteEvent, setDeleteEvent] = useState<{ calId: string; uid: string } | null>(null) + const [deleteEvent, setDeleteEvent] = useState< + { calId: string; uid: string; summary: string; recurring: boolean; occurrence: string } | null>(null) const importInputRef = useRef(null) const importTargetRef = useRef(null) @@ -204,10 +206,15 @@ export default function CalendarPage() { } } - async function confirmDeleteEvent() { + /** `scope: 'single'` streicht nur diesen einen Termin aus der Reihe + * (EXDATE), `'series'` löscht sie ganz. Bei Einzelterminen sind beide + * Wege gleichbedeutend. */ + async function confirmDeleteEvent(scope: 'single' | 'series') { if (!deleteEvent) return try { - await calendarApi.deleteEvent(deleteEvent.calId, deleteEvent.uid) + await calendarApi.deleteEvent( + deleteEvent.calId, deleteEvent.uid, + scope === 'single' && deleteEvent.recurring ? deleteEvent.occurrence : undefined) setDeleteEvent(null) setEventDialog(null) await refreshEvents() @@ -428,7 +435,15 @@ export default function CalendarPage() { calendars={calendars} calendarId={eventDialog.calId} onSave={saveEvent} - onDelete={eventDialog.event ? () => setDeleteEvent({ calId: eventDialog.calId, uid: eventDialog.event!.uid }) : undefined} + onDelete={eventDialog.event ? () => setDeleteEvent({ + calId: eventDialog.calId, + uid: eventDialog.event!.uid, + summary: eventDialog.event!.summary, + recurring: eventDialog.event!.recurring, + // Der angeklickte Einzeltermin – nur er wird gestrichen, wenn + // man sich für „nur diesen" entscheidet. + occurrence: eventDialog.event!.start, + }) : undefined} onCancel={() => setEventDialog(null)} /> )} @@ -445,11 +460,11 @@ export default function CalendarPage() { /> )} {deleteEvent && ( - confirmDeleteEvent('single')} + onSeries={() => confirmDeleteEvent('series')} onCancel={() => setDeleteEvent(null)} /> )} diff --git a/scripts/kalender-selbsttest.py b/scripts/kalender-selbsttest.py index 61ee245..70a0a7a 100755 --- a/scripts/kalender-selbsttest.py +++ b/scripts/kalender-selbsttest.py @@ -167,6 +167,34 @@ check("Bearbeiten erlaubt (200)", st == 200, st) got = starts("T monat wochentag") check("jetzt jeden 2. Monat, 1. Freitag", got == ["2026-09-04", "2026-11-06", "2027-01-01"], got) +print("\n── Aus einer Reihe einen einzelnen Termin streichen ──") +create(summary="T ausnahme", start="2026-10-05T08:00:00Z", + end="2026-10-05T09:00:00Z", all_day=False, + recurrence={"freq": "WEEKLY", "interval": 1, "count": 4}) +check("Reihe steht", starts("T ausnahme") == ["2026-10-05", "2026-10-12", + "2026-10-19", "2026-10-26"], + starts("T ausnahme")) +serie = [e for e in events("2026-10-01T00:00:00Z", "2026-11-01T00:00:00Z") + if e["summary"] == "T ausnahme"][1] # der zweite Termin +st, _ = api("DELETE", f"{EV}/{serie['uid']}?occurrence={serie['start']}", None, CSRF) +check("nur diesen streichen (204)", st == 204, st) +got = starts("T ausnahme") +check("der 12. fehlt, Rest bleibt", + got == ["2026-10-05", "2026-10-19", "2026-10-26"], got) + +st, ics = api("GET", f"/api/v1/calendars/{CAL}/export", raw=True) +blk = [b for b in ics.split("BEGIN:VEVENT") if "T ausnahme" in b] +check("als EXDATE im .ics vermerkt", + blk and any("EXDATE" in l and "20261012" in l for l in blk[0].splitlines()), + [l for l in (blk[0].splitlines() if blk else []) if "EXDATE" in l]) + +st, _ = api("DELETE", f"{EV}/{serie['uid']}?occurrence={serie['start']}", None, CSRF) +check("zweimal streichen bleibt harmlos", st == 204 and starts("T ausnahme") == + ["2026-10-05", "2026-10-19", "2026-10-26"], starts("T ausnahme")) + +st, _ = api("DELETE", f"{EV}/{serie['uid']}", None, CSRF) +check("ganze Reihe löschen", st == 204 and starts("T ausnahme") == [], starts("T ausnahme")) + print("\n── Zeitfenster begrenzt wirklich ──") win = [e for e in events("2026-09-14T00:00:00Z", "2026-09-20T00:00:00Z") if e["summary"] == "T wochentage"]