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"]