diff --git a/TESTING.md b/TESTING.md index 4a924ad..9306eb3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -17,14 +17,19 @@ einmal etwas schiefging: python3 scripts/kalender-selbsttest.py -19 Prüfungen: Ganztags-Datum (der 3. muss der 3. bleiben – der Fehler, bei dem +36 Prüfungen: Ganztags-Datum (der 3. muss der 3. bleiben – der Fehler, bei dem ein Termin östlich von UTC einen Tag zurücksprang), mehrtägige Termine, Terminfarbe in beiden Schreibweisen, alle Wiederholungsmuster (wöchentlich an bestimmten Tagen, monatlich/jährlich am festen Tag bzw. am n-ten Wochentag), -Bearbeiten einer Reihe, und dass das Zeitfenster wirklich begrenzt. +Bearbeiten einer Reihe, einen einzelnen Termin aus einer Reihe streichen +(EXDATE), Umhängen in einen anderen Kalender (samt ctag auf **beiden** Seiten, +sonst gleicht ein CalDAV-Client nur eine davon ab), und dass das Zeitfenster +wirklich begrenzt. **Legt Termine an und löscht am Ende alle Termine im ersten Kalender des -Kontos.** Also nur gegen eine Entwicklungsinstanz laufen lassen. +Kontos**; für den Umzugstest entsteht kurzzeitig ein zweiter Kalender +„T zweitkalender", der danach wieder verschwindet. Also nur gegen eine +Entwicklungsinstanz laufen lassen. ## Notfall: Migration hängt fest ("Dirty database version") diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index 72daf56..50f1c3d 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -538,6 +538,7 @@ func main() { r.Post("/calendars/{id}/events", calAPIHandler.CreateEvent) r.Patch("/calendars/{id}/events/{uid}", calAPIHandler.UpdateEvent) r.Delete("/calendars/{id}/events/{uid}", calAPIHandler.DeleteEvent) + r.Post("/calendars/{id}/events/{uid}/move", calAPIHandler.MoveEvent) r.Get("/app-passwords", authHandler.ListAppPasswords) r.Post("/app-passwords", authHandler.CreateAppPassword) diff --git a/backend/internal/calendar/events_api.go b/backend/internal/calendar/events_api.go index a508a66..bb953aa 100644 --- a/backend/internal/calendar/events_api.go +++ b/backend/internal/calendar/events_api.go @@ -629,6 +629,64 @@ func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, dto) } +// POST /api/v1/calendars/{id}/events/{uid}/move {"calendar_id": "…"} +// +// Umhängen in einen anderen Kalender. Bewusst ein eigener Endpunkt und nicht +// ein weiteres Feld im PATCH: Ein Umzug lässt sich auch **ohne** jede +// Änderung am Termin auslösen (Ziehen mit der Maus, langes Drücken am Handy), +// und dafür den ganzen Termin mitschicken zu müssen wäre eine Einladung, +// nebenbei etwas zu überschreiben. +// +// Bei einer Reihe zieht die ganze Reihe um – sie ist eine einzige Datei, kein +// Bündel von Einzelterminen. +func (h *APIHandler) MoveEvent(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + fromCal, ok := h.calendarOf(r) + if !ok { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } + var body struct { + CalendarID string `json:"calendar_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + response.Error(w, http.StatusBadRequest, "calendar_id is required") + return + } + toCal, err := uuid.Parse(strings.TrimSpace(body.CalendarID)) + if err != nil { + response.Error(w, http.StatusBadRequest, "calendar_id is required") + return + } + // Der Zielkalender muss demselben Konto gehören. Ohne diese Prüfung + // schöbe man Termine in fremde Kalender – und ein fremder Kalender darf + // sich nicht einmal als vorhanden verraten, deshalb 404 statt 403. + if _, err := h.store.GetCalendar(r.Context(), user.ID, toCal); err != nil { + response.Error(w, http.StatusNotFound, "calendar not found") + return + } + + obj, err := h.store.MoveObject(r.Context(), fromCal, toCal, chi.URLParam(r, "uid")) + switch { + case err == ErrNotFound: + response.Error(w, http.StatusNotFound, "event not found") + return + case err == ErrConflict: + response.Error(w, http.StatusConflict, "an event with this uid already exists in the target calendar") + return + case err != nil: + response.Error(w, http.StatusInternalServerError, "move event failed") + return + } + + dto, err := eventFromObject(obj) + if err != nil { + response.Error(w, http.StatusInternalServerError, "move event failed") + return + } + response.JSON(w, http.StatusOK, dto) +} + var errNotRecurring = &icalError{"calendar: event has no recurrence"} // excludeOccurrence streicht einen einzelnen Termin aus einer Reihe, indem diff --git a/backend/internal/calendar/store.go b/backend/internal/calendar/store.go index b28b089..daa11aa 100644 --- a/backend/internal/calendar/store.go +++ b/backend/internal/calendar/store.go @@ -13,6 +13,11 @@ import ( var ErrNotFound = errors.New("calendar: not found") +// ErrConflict: im Zielkalender liegt bereits ein Termin mit dieser UID. Zwei +// Objekte gleicher UID in einer Sammlung wären für CalDAV-Clients nicht +// unterscheidbar – deshalb bleibt der Termin dann lieber, wo er ist. +var ErrConflict = errors.New("calendar: uid already exists in target calendar") + type Store struct { db *pgxpool.Pool } @@ -212,6 +217,63 @@ func (s *Store) PutObject(ctx context.Context, calendarID uuid.UUID, uid, compon return &o, nil } +// MoveObject hängt einen Termin an einen anderen Kalender um. Die UID und der +// gesamte .ics-Inhalt bleiben dabei unangetastet – es wechselt nur die +// Sammlung, so wie ein MOVE in WebDAV. +// +// Für CalDAV-Clients ändert sich damit die Adresse des Termins: Sie sehen ihn +// in der Quelle verschwinden und im Ziel auftauchen. Deshalb bekommen +// **beide** Kalender ein neues ctag – mit nur einem holte der Client die +// andere Seite erst beim nächsten Vollabgleich, und der Termin stünde +// zwischenzeitlich doppelt oder gar nicht im Telefon. +func (s *Store) MoveObject(ctx context.Context, fromCal, toCal uuid.UUID, uid string) (*Object, error) { + if fromCal == toCal { + return s.GetObjectByUID(ctx, fromCal, uid) + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) + + var taken bool + if err := tx.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM cal_objects WHERE calendar_id = $1 AND uid = $2)`, + toCal, uid, + ).Scan(&taken); err != nil { + return nil, fmt.Errorf("check target: %w", err) + } + if taken { + return nil, ErrConflict + } + + row := tx.QueryRow(ctx, + `UPDATE cal_objects SET calendar_id = $1, updated_at = now() + WHERE calendar_id = $2 AND uid = $3 + RETURNING id, calendar_id, uid, component, data, etag, dtstart, dtend, created_at, updated_at`, + toCal, fromCal, uid, + ) + o, err := scanObject(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, + `UPDATE cal_calendars SET ctag = ctag + 1 WHERE id = $1 OR id = $2`, fromCal, toCal, + ); err != nil { + return nil, fmt.Errorf("bump ctag: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit tx: %w", err) + } + return &o, nil +} + func (s *Store) DeleteObject(ctx context.Context, calendarID uuid.UUID, uid string) error { tx, err := s.db.Begin(ctx) if err != nil { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 49acdae..ffb67ed 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -992,6 +992,12 @@ export const calendarApi = { request('POST', `/calendars/${id}/events`, input), updateEvent: (id: string, uid: string, input: EventInput) => request('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input), + /** Hängt einen Termin in einen anderen Kalender um – UID und Inhalt bleiben, + * nur die Sammlung wechselt. Bei einer Reihe zieht die ganze Reihe mit: + * Sie ist eine einzige Datei, kein Bündel von Einzelterminen. */ + moveEvent: (id: string, uid: string, targetId: string) => + request('POST', `/calendars/${id}/events/${encodeURIComponent(uid)}/move`, + { calendar_id: targetId }), /** 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. */ diff --git a/frontend/src/components/calendar/EventDialog.tsx b/frontend/src/components/calendar/EventDialog.tsx index 61a6313..2d190ac 100644 --- a/frontend/src/components/calendar/EventDialog.tsx +++ b/frontend/src/components/calendar/EventDialog.tsx @@ -211,12 +211,19 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId {calendars.length > 1 && (
- {/* Beim Bearbeiten fest: Ein Umhängen wäre Löschen und Neuanlegen - unter neuer Kennung – das gehört nicht versteckt in ein - Auswahlfeld. */} - setCalId(e.target.value)} style={inputStyle}> {calendars.map(c => )} + {!!event && calId !== calendarId && ( +

+ Wird beim Speichern nach „{calendars.find(c => c.id === calId)?.name}" verschoben + {isSeries ? ' – mit der ganzen Reihe.' : '.'} +

+ )}
)} diff --git a/frontend/src/components/calendar/useEventDrag.ts b/frontend/src/components/calendar/useEventDrag.ts new file mode 100644 index 0000000..2398f4c --- /dev/null +++ b/frontend/src/components/calendar/useEventDrag.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import type { DayEvent } from './DayDialog' + +/** + * Einen Termin auf einen Kalender in der Seitenleiste ziehen. + * + * Bewusst über Pointer-Events statt über die Drag-and-Drop-Schnittstelle des + * Browsers: Die gibt es auf Android-Browsern nicht, und genau dort soll die + * Geste ja auch funktionieren – dort eben als langes Drücken. + * + * Der Unterschied zwischen beiden Eingabearten steckt nur im Auslöser: Die + * Maus zieht sofort los, sobald sie sich ein Stück bewegt; der Finger muss + * erst kurz liegenbleiben. Ohne diese Wartezeit ließe sich die Ansicht nicht + * mehr scrollen, sobald der Finger zufällig auf einem Termin aufsetzt. + */ +export interface DragState { + ev: DayEvent + x: number + y: number + /** Kalender unter dem Zeiger, sofern einer da ist. */ + overId: string | null +} + +/** So lange muss ein Finger liegenbleiben, bis er den Termin „aufnimmt". */ +const LONG_PRESS_MS = 350 +/** Ab dieser Mausbewegung ist es ein Ziehen und kein Klick mehr. */ +const MOUSE_SLOP = 6 +/** Weiter darf der Finger vor dem Aufnehmen nicht wandern – sonst wollte er + * scrollen, nicht ziehen. */ +const TOUCH_SLOP = 12 + +/** Ziel unter dem Zeiger. Ablegeflächen tragen `data-cal-drop=""`. */ +function targetAt(x: number, y: number): string | null { + const el = document.elementFromPoint(x, y) + const drop = el?.closest('[data-cal-drop]') + return drop?.getAttribute('data-cal-drop') ?? null +} + +export function useEventDrag(onDrop: (ev: DayEvent, calendarId: string) => void) { + const [drag, setDrag] = useState(null) + + const dropRef = useRef(onDrop) + dropRef.current = onDrop + + const gesture = useRef<{ + ev: DayEvent + startX: number + startY: number + active: boolean + touch: boolean + timer: number + } | null>(null) + + /** Nach einem Ziehen kommt noch ein Klick hinterher. Der würde den + * Termindialog öffnen – direkt nachdem der Termin umgezogen ist. */ + const swallowClick = useRef(false) + + const stop = useCallback(() => { + const g = gesture.current + if (g?.timer) window.clearTimeout(g.timer) + gesture.current = null + setDrag(null) + }, []) + + const onMove = useCallback((e: PointerEvent) => { + const g = gesture.current + if (!g) return + const dx = e.clientX - g.startX + const dy = e.clientY - g.startY + const dist = Math.hypot(dx, dy) + + if (!g.active) { + if (g.touch) { + // Der Finger wandert, bevor die Wartezeit um ist: Er scrollt. + if (dist > TOUCH_SLOP) stop() + return + } + if (dist < MOUSE_SLOP) return + g.active = true + } + setDrag({ ev: g.ev, x: e.clientX, y: e.clientY, overId: targetAt(e.clientX, e.clientY) }) + }, [stop]) + + const onUp = useCallback((e: PointerEvent) => { + const g = gesture.current + if (g?.active) { + const target = targetAt(e.clientX, e.clientY) + // Auf den eigenen Kalender abgelegt heißt: nichts tun. Ein Umzug an + // Ort und Stelle wäre nur eine überflüssige Runde durch den Server. + if (target && target !== g.ev.calendarId) dropRef.current(g.ev, target) + // Der Klick ist noch unterwegs. Wann genau er eintrifft, ist von + // Browser zu Browser verschieden – die Sperre bleibt deshalb bis zur + // nächsten Berührung stehen, mit einer großzügigen Frist als Rückfall, + // damit sie nicht versehentlich hängenbleibt. + swallowClick.current = true + window.setTimeout(() => { swallowClick.current = false }, 500) + } + stop() + }, [stop]) + + useEffect(() => { + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onUp) + } + }, [onMove, onUp]) + + const onPointerDown = useCallback((ev: DayEvent, e: ReactPointerEvent) => { + if (e.pointerType === 'mouse' && e.button !== 0) return + swallowClick.current = false + // Bewusst **kein** preventDefault hier: Es unterdrückt je nach Browser + // auch den Klick – und ein Termin, der sich nicht mehr öffnen lässt, wäre + // ein teuer bezahltes Ziehen. Gegen die Textmarkierung beim Ziehen hilft + // stattdessen `userSelect: 'none'` an den Terminen selbst. + const touch = e.pointerType !== 'mouse' + const g = { ev, startX: e.clientX, startY: e.clientY, active: false, touch, timer: 0 } + gesture.current = g + if (touch) { + g.timer = window.setTimeout(() => { + g.active = true + setDrag({ ev, x: g.startX, y: g.startY, overId: null }) + navigator.vibrate?.(15) + }, LONG_PRESS_MS) + } + }, []) + + /** Im Klick-Handler eines Termins zuerst fragen: Kam der Klick nur als + * Nachhall eines Ziehens? */ + const dragged = useCallback(() => swallowClick.current, []) + + return { drag, onPointerDown, dragged } +} diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index fe90300..12f230c 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -1,4 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent } from 'react' +import { + useCallback, useEffect, useMemo, useRef, useState, + type CSSProperties, type MouseEvent, type PointerEvent as ReactPointerEvent, +} from 'react' import { calendarApi, APIError, type CalendarItem, type CalendarEvent, type EventInput, @@ -12,6 +15,7 @@ import DeleteEventDialog from '../components/calendar/DeleteEventDialog' import { addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek, } from '../components/calendar/dates' +import { useEventDrag } from '../components/calendar/useEventDrag' import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons' type ViewMode = 'month' | 'week' | 'list' @@ -29,6 +33,9 @@ const GUTTER = 52 type EventWithCalendar = DayEvent +/** Aufnehmen zum Ziehen – siehe components/calendar/useEventDrag.ts. */ +type EventDown = (ev: EventWithCalendar, e: ReactPointerEvent) => void + function decorate(ev: CalendarEvent, cal: CalendarItem): EventWithCalendar { return { ...ev, @@ -195,7 +202,14 @@ export default function CalendarPage() { if (!eventDialog) return try { if (eventDialog.event) { + // Erst die Änderungen, dann der Umzug. Andersherum stünde der Termin + // nach einem Fehler im neuen Kalender, aber unverändert – die + // verwirrendere Hälfte. So bleibt er im Zweifel liegen, wo er war, + // und die Eingaben sind trotzdem gesichert. await calendarApi.updateEvent(eventDialog.calId, eventDialog.event.uid, input) + if (calId !== eventDialog.calId) { + await calendarApi.moveEvent(eventDialog.calId, eventDialog.event.uid, calId) + } } else { await calendarApi.createEvent(calId, input) } @@ -206,6 +220,32 @@ export default function CalendarPage() { } } + /** Termin auf einen Kalender in der Seitenleiste gezogen. */ + async function moveEventTo(ev: EventWithCalendar, targetId: string) { + try { + await calendarApi.moveEvent(ev.calendarId, ev.uid, targetId) + // Ein Kalender, in den man gerade etwas hineingezogen hat, soll das + // auch zeigen – sonst verschwindet der Termin scheinbar spurlos. + setHidden(prev => { + if (!prev.has(targetId)) return prev + const next = new Set(prev) + next.delete(targetId) + return next + }) + await refreshEvents() + } catch (e) { + setError(e instanceof APIError ? e.message : 'Verschieben fehlgeschlagen') + } + } + + const { drag, onPointerDown, dragged } = useEventDrag(moveEventTo) + + /** Klick auf einen Termin – aber nicht der, der nur einem Ziehen hinterherkommt. */ + function clickEvent(ev: EventWithCalendar) { + if (dragged()) return + openEditEvent(ev) + } + /** `scope: 'single'` streicht nur diesen einen Termin aus der Reihe * (EXDATE), `'series'` löscht sie ganz. Bei Einzelterminen sind beide * Wege gleichbedeutend. */ @@ -360,13 +400,26 @@ export default function CalendarPage() { fontSize: 18, lineHeight: 1, padding: '0 2px', }}>+ + {drag && ( +

+ Auf einen Kalender ziehen zum Verschieben +

+ )} {calendars.map(cal => { const on = !hidden.has(cal.id) + // Ablegefläche fürs Ziehen. Der eigene Kalender des gezogenen + // Termins ist keins – dorthin gibt es nichts zu verschieben. + const isTarget = !!drag && drag.ev.calendarId !== cal.id + const isOver = isTarget && drag!.overId === cal.id return ( -
+
{/* Der Farbpunkt **ist** der Schalter: gefüllt = wird angezeigt, leerer Ring = ausgeblendet. Ein Kästchen daneben sagte dasselbe zweimal. */} @@ -406,17 +459,58 @@ export default function CalendarPage() {
{viewMode === 'month' && ( - + )} {viewMode === 'week' && ( - + )} {viewMode === 'list' && ( - + )}
+ {/* Ist die Kalenderliste zugeklappt – am Handy der Regelfall –, fehlte + sonst jedes Ziel, und die Geste liefe ins Leere. Die Leiste taucht + deshalb nur während des Ziehens auf, unten in Daumennähe. */} + {drag && !showList && ( +
+ {calendars.filter(c => c.id !== drag.ev.calendarId).map(cal => ( +
+ + {cal.name} +
+ ))} +
+ )} + + {/* Der Termin am Zeiger. `pointerEvents: none`, sonst läge er selbst + unter dem Finger und verdeckte das Ziel darunter. */} + {drag && ( +
+ {drag.ev.summary || '(ohne Titel)'}{drag.ev.recurring ? ' ↻ (ganze Reihe)' : ''} +
+ )} + {dayDialog && ( @@ -541,13 +635,19 @@ 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 und öffnete den Tag statt den Termin. */ -function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) { +function EventChip({ ev, onClick, onDown }: { + ev: EventWithCalendar; onClick: () => void; onDown: EventDown +}) { return (
{ e.stopPropagation(); onClick() }} + onPointerDown={e => { e.stopPropagation(); onDown(ev, e) }} title={ev.summary} style={{ + // Ohne das scrollt die Ansicht mit, sobald der Finger auf einem + // Termin liegt – und das lange Drücken käme nie zustande. + touchAction: 'none', userSelect: 'none', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '1px 5px', borderRadius: 4, background: ev.color + '26', color: ev.color, cursor: 'pointer', marginBottom: 2, borderLeft: `2px solid ${ev.color}`, @@ -561,11 +661,12 @@ function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void ) } -function MonthGrid({ cursor, byDay, onOpenDay, onEventClick }: { +function MonthGrid({ cursor, byDay, onOpenDay, onEventClick, onEventDown }: { cursor: Date byDay: Map onOpenDay: (day: Date) => void onEventClick: (ev: EventWithCalendar) => void + onEventDown: EventDown }) { const gridStart = startOfWeek(startOfMonth(cursor)) const today = new Date() @@ -611,7 +712,7 @@ function MonthGrid({ cursor, byDay, onOpenDay, onEventClick }: {
{events.slice(0, 3).map(ev => ( - onEventClick(ev)} /> + onEventClick(ev)} onDown={onEventDown} /> ))} {events.length > 3 && ( @@ -679,12 +780,13 @@ function layoutDay(events: EventWithCalendar[], day: Date): Slot[] { })) } -function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick }: { +function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick, onEventDown }: { cursor: Date byDay: Map onSlotClick: (start: Date) => void onOpenDay: (day: Date) => void onEventClick: (ev: EventWithCalendar) => void + onEventDown: EventDown }) { const scrollRef = useRef(null) const start = startOfWeek(cursor) @@ -745,7 +847,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick }: {
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)} onDown={onEventDown} /> ))}
))} @@ -795,9 +897,10 @@ function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick }: { key={eventKey(ev)} className="aether-cal-chip" onClick={e => { e.stopPropagation(); onEventClick(ev) }} + onPointerDown={e => { e.stopPropagation(); onEventDown(ev, e) }} title={`${ev.summary} – ${timeLabel(ev.startsAt)}`} style={{ - position: 'absolute', top, height, + position: 'absolute', top, height, touchAction: 'none', userSelect: 'none', left: `calc(${(col / n) * 100}% + 2px)`, width: `calc(${100 / n}% - 4px)`, background: ev.color + '2e', borderLeft: `3px solid ${ev.color}`, @@ -821,7 +924,11 @@ function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick }: { ) } -function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onEventClick: (ev: EventWithCalendar) => void }) { +function ListAgenda({ events, onEventClick, onEventDown }: { + events: EventWithCalendar[] + onEventClick: (ev: EventWithCalendar) => void + onEventDown: EventDown +}) { const groups = useMemo(() => { const map = new Map() for (const ev of events) { @@ -853,10 +960,18 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE {isSameDay(day, today) && ' · heute'}
{evs.map(ev => ( -
onEventClick(ev)} style={{ - display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, - cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6, - }}> +
onEventClick(ev)} + onPointerDown={e => onEventDown(ev, e)} + style={{ + display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, + cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6, + // Hier bewusst **kein** touchAction: 'none' wie bei den + // Terminen im Raster: Die Zeilen füllen die ganze Breite, und + // eine Liste, die sich nicht mehr scrollen lässt, wäre ein + // schlechter Tausch für eine Zieh-Geste. Mit der Maus ziehen + // geht trotzdem. + }}> {ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)} diff --git a/scripts/kalender-selbsttest.py b/scripts/kalender-selbsttest.py index 70a0a7a..ae69409 100755 --- a/scripts/kalender-selbsttest.py +++ b/scripts/kalender-selbsttest.py @@ -195,6 +195,60 @@ check("zweimal streichen bleibt harmlos", st == 204 and 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── Umhängen in einen anderen Kalender ──") + + +def ctag(cal_id): + _, cals_now = api("GET", "/api/v1/calendars") + return next(c["ctag"] for c in cals_now or [] if c["id"] == cal_id) + + +st, zweit = api("POST", "/api/v1/calendars", + {"name": "T zweitkalender", "color": "#34a853"}, CSRF) +check("zweiter Kalender angelegt", st == 201 and zweit, st) +CAL2 = zweit["id"] +EV2 = f"/api/v1/calendars/{CAL2}/events" + +umzug = create(summary="T umzug", start="2026-11-05T09:00:00Z", + end="2026-11-05T10:00:00Z", all_day=False) +vorher_quelle, vorher_ziel = ctag(CAL), ctag(CAL2) +st, verschoben = api("POST", f"{EV}/{umzug['uid']}/move", {"calendar_id": CAL2}, CSRF) +check("Umzug angenommen", st == 200, st) +check("Kennung bleibt dieselbe", verschoben and verschoben["uid"] == umzug["uid"]) +check("aus der Quelle verschwunden", starts("T umzug") == [], starts("T umzug")) +_, im_ziel = api("GET", f"{EV2}?from=2026-01-01T00:00:00Z&to=2027-12-31T00:00:00Z") +check("im Ziel angekommen", [e["summary"] for e in im_ziel or []] == ["T umzug"], im_ziel) +# Beide Seiten müssen sich melden: Ein CalDAV-Client gleicht sonst nur eine +# davon ab und hat den Termin doppelt oder gar nicht. +check("ctag beider Kalender gestiegen", + ctag(CAL) > vorher_quelle and ctag(CAL2) > vorher_ziel, + f"{vorher_quelle}->{ctag(CAL)}, {vorher_ziel}->{ctag(CAL2)}") + +st, _ = api("POST", f"{EV2}/{umzug['uid']}/move", {"calendar_id": CAL2}, CSRF) +check("Umzug auf sich selbst ist folgenlos", st == 200, st) + +st, _ = api("POST", f"{EV2}/{umzug['uid']}/move", + {"calendar_id": "11111111-1111-1111-1111-111111111111"}, CSRF) +check("unbekannter Zielkalender: 404", st == 404, st) + +# Eine Reihe ist **eine** Datei – sie zieht vollständig um, nicht in Stücken. +serie2 = create(summary="T umzug reihe", start="2026-11-02T08:00:00Z", + end="2026-11-02T09:00:00Z", all_day=False, + recurrence={"freq": "WEEKLY", "interval": 1, "count": 3}) +st, _ = api("POST", f"{EV}/{serie2['uid']}/move", {"calendar_id": CAL2}, CSRF) +_, ziel2 = api("GET", f"{EV2}?from=2026-01-01T00:00:00Z&to=2027-12-31T00:00:00Z") +reihe_im_ziel = sorted(e["start"][:10] for e in ziel2 or [] if e["summary"] == "T umzug reihe") +check("Reihe zieht vollständig um", + st == 200 and reihe_im_ziel == ["2026-11-02", "2026-11-09", "2026-11-16"], + reihe_im_ziel) +check("Reihe nicht mehr in der Quelle", starts("T umzug reihe") == [], starts("T umzug reihe")) + +api("DELETE", f"/api/v1/calendars/{CAL2}", None, CSRF) +_, rest_cals = api("GET", "/api/v1/calendars") +check("Testkalender wieder entfernt", + all(c["name"] != "T zweitkalender" for c in rest_cals or []), + [c["name"] for c in rest_cals or []]) + 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"]