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:
1 parent
688bb13bde
commit
77bf16b27f
9 files changed
+470
-26
No files matched your search
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user