feat(calendar): Termin einem Kalender zuordnen und umhaengen

Beim Anlegen war die Kalenderauswahl schon da (der oberste ist die
Vorgabe), beim Bearbeiten war sie festgestellt -- mit der Begruendung,
ein Umhaengen sei Loeschen und Neuanlegen unter neuer Kennung. Das
stimmt so nicht: In CalDAV wechselt dabei nur die Sammlung, UID und
Inhalt bleiben. Genau das macht der neue Endpunkt

    POST /api/v1/calendars/{id}/events/{uid}/move  {"calendar_id": …}

Er aendert nur die Zugehoerigkeit der Zeile und erhoeht das ctag auf
**beiden** Seiten. Mit nur einem holte ein Client die andere Haelfte
erst beim naechsten Vollabgleich, und der Termin stuende
zwischenzeitlich doppelt oder gar nicht im Telefon.

Bewusst ein eigener Endpunkt und kein weiteres Feld im PATCH: Ein Umzug
laesst sich auch ohne jede Aenderung am Termin ausloesen, und dafuer den
ganzen Termin mitschicken zu muessen waere eine Einladung, nebenbei
etwas zu ueberschreiben. Im Formular laeuft deshalb erst das PATCH und
dann der Umzug -- andersherum stuende der Termin nach einem Fehler
verschoben, aber unveraendert da, die verwirrendere Haelfte.

Dazu die Zieh-Geste: einen Termin auf einen Kalender in der
Seitenleiste ziehen, am Handy nach kurzem Gedrueckthalten. Ueber
Pointer-Events statt ueber die Drag-and-Drop-Schnittstelle des
Browsers -- die gibt es auf Android nicht, und genau dort soll die
Geste ja auch funktionieren. Ist die Kalenderliste zugeklappt (am Handy
der Regelfall), erscheint waehrend des Ziehens eine Leiste unten in
Daumennaehe, sonst liefe die Geste ins Leere.

Zwei Faelle, die sonst aergern: Wer in einen ausgeblendeten Kalender
zieht, bekommt ihn eingeblendet -- der Termin waere sonst scheinbar
spurlos verschwunden. Und der Klick, der einem Ziehen hinterherkommt,
wird verschluckt, statt den Termindialog zu oeffnen.

Eine Reihe zieht vollstaendig um; sie ist eine einzige Datei, kein
Buendel von Einzelterminen. Das sagt das Formular auch an.

Selbsttest um elf Pruefungen erweitert (36 gruen): Umzug angenommen,
Kennung bleibt, aus der Quelle weg und im Ziel da, ctag auf beiden
Seiten gestiegen, Umzug auf sich selbst folgenlos, fremder Kalender
404, Reihe vollstaendig umgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-04 13:07:36 +02:00
1 parent 688bb13bde
commit 77bf16b27f
9 files changed
+470 -26

No files matched your search

+8 -3
View File
@@ -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")
+1
View File
@@ -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)
+58
View File
@@ -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
+62
View File
@@ -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 {
+6
View File
@@ -992,6 +992,12 @@ 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),
/** 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<CalendarEvent>('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. */
@@ -211,12 +211,19 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
{calendars.length > 1 && (
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Kalender</label>
{/* Beim Bearbeiten fest: Ein Umhängen wäre Löschen und Neuanlegen
unter neuer Kennung – das gehört nicht versteckt in ein
Auswahlfeld. */}
<select value={calId} disabled={!!event} onChange={e => setCalId(e.target.value)} style={inputStyle}>
{/* Auch beim Bearbeiten wählbar: Der Termin wechselt dann beim
Speichern die Sammlung, behält aber seine Kennung – für
CalDAV-Clients derselbe Termin an neuer Adresse, nicht ein
neuer. */}
<select value={calId} disabled={readOnly} onChange={e => setCalId(e.target.value)} style={inputStyle}>
{calendars.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
{!!event && calId !== calendarId && (
<p style={{ margin: '6px 2px 0', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
Wird beim Speichern nach „{calendars.find(c => c.id === calId)?.name}" verschoben
{isSeries ? ' – mit der ganzen Reihe.' : '.'}
</p>
)}
</div>
)}
@@ -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="<id>"`. */
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<DragState | null>(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 }
}
+134 -19
View File
@@ -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',
}}>+</button>
</div>
{drag && (
<p style={{ fontSize: 11.5, color: 'var(--color-text-muted)', margin: '0 0 8px', lineHeight: 1.4 }}>
Auf einen Kalender ziehen zum Verschieben
</p>
)}
{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 (
<div key={cal.id} className="aether-cal-row" style={{
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 4px',
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
}}>
<div key={cal.id} className="aether-cal-row"
data-cal-drop={isTarget ? cal.id : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 4px',
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
outline: isOver ? `2px solid ${cal.color}` : isTarget ? '1px dashed var(--color-border)' : 'none',
background: isOver ? cal.color + '22' : undefined,
}}>
{/* 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() {
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', display: 'flex' }}>
{viewMode === 'month' && (
<MonthGrid cursor={cursor} byDay={byDay} onOpenDay={setDayDialog} onEventClick={openEditEvent} />
<MonthGrid cursor={cursor} byDay={byDay} onOpenDay={setDayDialog}
onEventClick={clickEvent} onEventDown={onPointerDown} />
)}
{viewMode === 'week' && (
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onOpenDay={setDayDialog} onEventClick={openEditEvent} />
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onOpenDay={setDayDialog}
onEventClick={clickEvent} onEventDown={onPointerDown} />
)}
{viewMode === 'list' && (
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
<ListAgenda events={allEvents} onEventClick={clickEvent} onEventDown={onPointerDown} />
)}
</div>
</div>
{/* 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 && (
<div style={{
position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 205,
display: 'flex', gap: 8, padding: 12, overflowX: 'auto',
background: 'var(--color-surface)', borderTop: '1px solid var(--color-border)',
boxShadow: '0 -4px 16px rgba(0,0,0,0.3)',
}}>
{calendars.filter(c => c.id !== drag.ev.calendarId).map(cal => (
<div key={cal.id} data-cal-drop={cal.id} style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px',
borderRadius: 10, fontSize: 13, whiteSpace: 'nowrap', flexShrink: 0,
border: `2px solid ${drag.overId === cal.id ? cal.color : 'var(--color-border)'}`,
background: drag.overId === cal.id ? cal.color + '22' : 'transparent',
color: 'var(--color-text)',
}}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cal.color }} />
{cal.name}
</div>
))}
</div>
)}
{/* Der Termin am Zeiger. `pointerEvents: none`, sonst läge er selbst
unter dem Finger und verdeckte das Ziel darunter. */}
{drag && (
<div style={{
position: 'fixed', left: drag.x + 12, top: drag.y - 14, zIndex: 300,
pointerEvents: 'none', maxWidth: 220, padding: '5px 9px', borderRadius: 6,
fontSize: 12, background: 'var(--color-surface)', color: drag.ev.color,
border: `1px solid ${drag.ev.color}`, boxShadow: '0 6px 18px rgba(0,0,0,0.35)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{drag.ev.summary || '(ohne Titel)'}{drag.ev.recurring ? ' ↻ (ganze Reihe)' : ''}
</div>
)}
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
{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 (
<div
className="aether-cal-chip"
onClick={(e: MouseEvent) => { 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<string, EventWithCalendar[]>
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 }: {
</span>
<div style={{ overflow: 'hidden', minWidth: 0 }}>
{events.slice(0, 3).map(ev => (
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} />
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} onDown={onEventDown} />
))}
{events.length > 3 && (
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
@@ -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<string, EventWithCalendar[]>
onSlotClick: (start: Date) => void
onOpenDay: (day: Date) => void
onEventClick: (ev: EventWithCalendar) => void
onEventDown: EventDown
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const start = startOfWeek(cursor)
@@ -745,7 +847,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick }: {
<div key={+day} className="aether-cal-cell" onClick={() => 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 => (
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} />
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} onDown={onEventDown} />
))}
</div>
))}
@@ -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<string, { day: Date; evs: EventWithCalendar[] }>()
for (const ev of events) {
@@ -853,10 +960,18 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE
{isSameDay(day, today) && ' · heute'}
</div>
{evs.map(ev => (
<div key={eventKey(ev)} className="aether-cal-cell" onClick={() => onEventClick(ev)} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8,
cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6,
}}>
<div key={eventKey(ev)} className="aether-cal-cell"
onClick={() => 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.
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: ev.color, flexShrink: 0 }} />
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)', width: 62, flexShrink: 0 }}>
{ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)}
+54
View File
@@ -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"]