feat(calendar): einzelnen Termin aus einer Reihe streichen

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-04 09:06:53 +02:00
1 parent 0c9cc69493
commit 688bb13bde
6 files changed
+223 -13

No files matched your search

+7 -2
View File
@@ -992,8 +992,13 @@ export const calendarApi = {
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
updateEvent: (id: string, uid: string, input: EventInput) =>
request<CalendarEvent>('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input),
deleteEvent: (id: string, uid: string) =>
request<void>('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<void>('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}${qs}`)
},
}
export { APIError }
@@ -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 (
<div style={{
position: 'fixed', inset: 0, zIndex: 210, display: 'flex',
alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)', padding: 16,
}} {...overlay}>
<div style={{
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 14, padding: 24, width: 380, maxWidth: '100%',
}}>
<h2 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{recurring ? 'Serientermin löschen' : 'Termin löschen'}
</h2>
<p style={{ margin: '0 0 20px', fontSize: 13.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{recurring
? <>„{summary || 'Ohne Titel'}" gehört zu einer Reihe. Was soll gelöscht werden?</>
: <>„{summary || 'Ohne Titel'}" unwiderruflich löschen?</>}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={onSingle} style={danger}>
{recurring ? 'Nur diesen Termin' : 'Löschen'}
</button>
{recurring && (
<button onClick={onSeries} style={danger}>
Ganze Reihe
</button>
)}
<button onClick={onCancel} style={{
padding: '9px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13,
cursor: 'pointer', width: '100%',
}}>
Abbrechen
</button>
</div>
</div>
</div>
)
}
@@ -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
</button>
) : <span />}
<div style={{ display: 'flex', gap: 8 }}>
+24 -9
View File
@@ -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<CalendarItem | null>(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<HTMLInputElement>(null)
const importTargetRef = useRef<string | null>(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 && (
<ConfirmDialog
title="Termin löschen"
message="Diesen Termin unwiderruflich löschen? Bei einer Reihe verschwinden alle Termine der Reihe."
confirmLabel="Löschen" danger
onConfirm={confirmDeleteEvent}
<DeleteEventDialog
summary={deleteEvent.summary}
recurring={deleteEvent.recurring}
onSingle={() => confirmDeleteEvent('single')}
onSeries={() => confirmDeleteEvent('series')}
onCancel={() => setDeleteEvent(null)}
/>
)}