feat(calendar): Wiederholungen, Tagesansicht, Terminfarben, Farbpunkt als Schalter
Fünf Wünsche aus dem Durchtesten: 1. Wiederholungen einstellbar (täglich/wöchentlich/monatlich/jährlich, alle N, Ende nie/nach Anzahl/an einem Datum). Der eigentliche Aufwand lag nicht im Formular, sondern im Aufklappen: eine wöchentliche Reihe erschien sonst als *ein* Eintrag. Der Server klappt sie jetzt im angefragten Zeitfenster auf (?from=&to=, rrule-go über go-icals RecurrenceSet), die Ansichten fragen ihren sichtbaren Ausschnitt an. Geprüft: 5 Termine wöchentlich, nach Umstellung auf 14-tägig korrekt neu gerechnet, Fenster einer Woche liefert genau einen. Regeln, die das Formular nicht abbildet (etwa jeder zweite Dienstag im Monat), werden weiterhin korrekt *angezeigt* -- geprüft, die vier Termine stimmen -- aber nicht zum Bearbeiten angeboten (409). Ein halb verstandenes Speichern würde die Regel sonst still beschneiden. 2. Tag wird erst geöffnet, statt sofort einen Termin anzulegen: Klick auf einen Tag zeigt dessen Termine als Liste, dort wählt man einen aus oder legt bewusst einen neuen an. 3. Eigene Farbe je Termin (COLOR nach RFC 7986), leer = Kalenderfarbe. 4. Kalenderliste ohne Kästchen: der Farbpunkt selbst ist der Schalter -- gefüllt heißt sichtbar, leerer Ring ausgeblendet. 5. Ganztägig als Schiebeschalter statt Kästchen -- es stellt das Formular um, das ist keine Nebenangabe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
105ce8de4a
commit
79a7cc6a3d
8 files changed
+795
-218
No files matched your search
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
|
||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||
import type { CalendarEvent, CalendarItem, EventInput } from '../../api/client'
|
||||
import type { CalendarEvent, CalendarItem, EventInput, Recurrence } from '../../api/client'
|
||||
import { addDays, allDayISO, eventDay, timedISO, toDateInput, toTimeInput } from './dates'
|
||||
import { ColorSwatches, Toggle } from './controls'
|
||||
|
||||
interface Props {
|
||||
/** Fehlt er, entsteht ein neuer Termin. */
|
||||
@@ -15,43 +16,66 @@ interface Props {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
type FreqChoice = 'NONE' | Recurrence['freq']
|
||||
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
|
||||
|
||||
const freqLabels: Record<FreqChoice, string> = {
|
||||
NONE: 'Einmalig',
|
||||
DAILY: 'Täglich',
|
||||
WEEKLY: 'Wöchentlich',
|
||||
MONTHLY: 'Monatlich',
|
||||
YEARLY: 'Jährlich',
|
||||
}
|
||||
|
||||
export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
|
||||
const overlay = useOverlayDismiss(onCancel)
|
||||
const titleRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const allDayInit = event?.all_day ?? false
|
||||
const start = event ? eventDay(event.start, allDayInit) : defaultStart
|
||||
const startTimed = event && !allDayInit ? new Date(event.start) : defaultStart
|
||||
// Bei ganztägigen Terminen liefert die Schnittstelle den letzten *belegten*
|
||||
// Tag – genau das, was im Formular stehen soll (siehe events_api.go).
|
||||
const end = event?.end
|
||||
? eventDay(event.end, allDayInit)
|
||||
// Bei einer Reihe bearbeitet das Formular die **Reihe**, nicht den
|
||||
// angeklickten Einzeltermin – deshalb series_start, wo vorhanden.
|
||||
const baseStartISO = event?.series_start ?? event?.start
|
||||
const baseEndISO = event?.series_end ?? event?.end
|
||||
const start = baseStartISO ? eventDay(baseStartISO, allDayInit) : defaultStart
|
||||
const startTimed = baseStartISO && !allDayInit ? new Date(baseStartISO) : defaultStart
|
||||
const end = baseEndISO
|
||||
? eventDay(baseEndISO, allDayInit)
|
||||
: new Date(startTimed.getTime() + 60 * 60 * 1000)
|
||||
|
||||
const [calId, setCalId] = useState(calendarId)
|
||||
const [summary, setSummary] = useState(event?.summary ?? '')
|
||||
const [description, setDescription] = useState(event?.description ?? '')
|
||||
const [location, setLocation] = useState(event?.location ?? '')
|
||||
const [color, setColor] = useState(event?.color ?? '')
|
||||
const [allDay, setAllDay] = useState(allDayInit)
|
||||
const [startDate, setStartDate] = useState(toDateInput(start))
|
||||
const [startTime, setStartTime] = useState(toTimeInput(startTimed))
|
||||
const [endDate, setEndDate] = useState(toDateInput(end))
|
||||
const [endTime, setEndTime] = useState(toTimeInput(end))
|
||||
|
||||
const rec = event?.recurrence
|
||||
const [freq, setFreq] = useState<FreqChoice>(rec?.freq ?? 'NONE')
|
||||
const [interval, setInterval] = useState(String(rec?.interval ?? 1))
|
||||
const [endChoice, setEndChoice] = useState<EndChoice>(
|
||||
rec?.count ? 'COUNT' : rec?.until ? 'UNTIL' : 'NEVER')
|
||||
const [count, setCount] = useState(String(rec?.count ?? 10))
|
||||
const [until, setUntil] = useState(
|
||||
rec?.until ? toDateInput(eventDay(rec.until, true)) : toDateInput(addDays(start, 90)))
|
||||
|
||||
useEffect(() => { titleRef.current?.focus() }, [])
|
||||
|
||||
// Escape schließt – erwartet man bei jedem Dialog, und ohne fühlt sich die
|
||||
// Oberfläche zäh an.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onCancel])
|
||||
|
||||
const readOnly = !!event?.recurring
|
||||
// Nur Regeln, die dieses Formular vollständig abbildet, sind bearbeitbar.
|
||||
// Alles andere ("jeden zweiten Dienstag im Monat") bliebe beim Speichern
|
||||
// auf der Strecke – der Server lehnt es deshalb auch ab.
|
||||
const readOnly = !!event?.recurring && !event?.recurrence
|
||||
const isSeries = !!event?.recurring
|
||||
|
||||
/** Ende darf nicht vor dem Start liegen – statt einer Fehlermeldung zieht
|
||||
* das Ende einfach mit, so wie man es von anderen Kalendern kennt. */
|
||||
function changeStartDate(v: string) {
|
||||
const shift = (+new Date(v) - +new Date(startDate)) / 86400000
|
||||
setStartDate(v)
|
||||
@@ -60,6 +84,14 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecurrence(): Recurrence | null {
|
||||
if (freq === 'NONE') return null
|
||||
const r: Recurrence = { freq, interval: Math.max(1, parseInt(interval, 10) || 1) }
|
||||
if (endChoice === 'COUNT') r.count = Math.max(1, parseInt(count, 10) || 1)
|
||||
if (endChoice === 'UNTIL') r.until = allDayISO(until)
|
||||
return r
|
||||
}
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (readOnly || !summary.trim()) return
|
||||
@@ -80,6 +112,8 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
description: description.trim() || undefined,
|
||||
location: location.trim() || undefined,
|
||||
all_day: allDay,
|
||||
color: color || undefined,
|
||||
recurrence: buildRecurrence(),
|
||||
start: startISO,
|
||||
end: endISO,
|
||||
}, calId)
|
||||
@@ -94,6 +128,12 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
const labelStyle: CSSProperties = {
|
||||
fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block',
|
||||
}
|
||||
const noteStyle: CSSProperties = {
|
||||
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 8, padding: '8px 10px', fontSize: 12.5,
|
||||
color: 'var(--color-text-muted)', marginBottom: 14, lineHeight: 1.45,
|
||||
}
|
||||
const calColor = calendars.find(c => c.id === calId)?.color ?? '#4285f4'
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@@ -102,18 +142,23 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
}} {...overlay}>
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 14, padding: 24, width: 420, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
|
||||
borderRadius: 14, padding: 24, width: 440, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{event ? 'Termin bearbeiten' : 'Neuer Termin'}
|
||||
{event ? (isSeries ? 'Terminreihe bearbeiten' : 'Termin bearbeiten') : 'Neuer Termin'}
|
||||
</h2>
|
||||
|
||||
{readOnly && (
|
||||
<div style={{
|
||||
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 8, padding: '8px 10px', fontSize: 12.5, color: 'var(--color-text-muted)', marginBottom: 14,
|
||||
}}>
|
||||
Wiederholender Termin – nur mit einem CalDAV-Client (z. B. DAVx5) bearbeitbar, nicht hier.
|
||||
<div style={noteStyle}>
|
||||
Diese Wiederholungsregel kann dieses Formular nicht vollständig
|
||||
abbilden – zum Ändern einen CalDAV-Client verwenden (z. B. DAVx5).
|
||||
Der Termin bleibt unverändert erhalten.
|
||||
</div>
|
||||
)}
|
||||
{isSeries && !readOnly && (
|
||||
<div style={noteStyle}>
|
||||
Terminreihe: Änderungen gelten für <strong>alle</strong> Termine der
|
||||
Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -135,10 +180,9 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 13, color: 'var(--color-text)', cursor: readOnly ? 'default' : 'pointer' }}>
|
||||
<input type="checkbox" checked={allDay} disabled={readOnly} onChange={e => setAllDay(e.target.checked)} />
|
||||
Ganztägig
|
||||
</label>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="Ganztägig" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
@@ -154,7 +198,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Ende</label>
|
||||
<input type="date" value={endDate} min={startDate} disabled={readOnly}
|
||||
@@ -168,6 +212,60 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Wiederholung ───────────────────────────────────────────── */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Wiederholung</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select value={freq} disabled={readOnly}
|
||||
onChange={e => setFreq(e.target.value as FreqChoice)}
|
||||
style={{ ...inputStyle, flex: 1 }}>
|
||||
{(Object.keys(freqLabels) as FreqChoice[]).map(f => (
|
||||
<option key={f} value={f}>{freqLabels[f]}</option>
|
||||
))}
|
||||
</select>
|
||||
{freq !== 'NONE' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>alle</span>
|
||||
<input type="number" min={1} max={99} value={interval} disabled={readOnly}
|
||||
onChange={e => setInterval(e.target.value)}
|
||||
style={{ ...inputStyle, width: 60 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{freq !== 'NONE' && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Endet</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select value={endChoice} disabled={readOnly}
|
||||
onChange={e => setEndChoice(e.target.value as EndChoice)}
|
||||
style={{ ...inputStyle, flex: 1 }}>
|
||||
<option value="NEVER">Nie</option>
|
||||
<option value="COUNT">Nach Anzahl</option>
|
||||
<option value="UNTIL">An einem Datum</option>
|
||||
</select>
|
||||
{endChoice === 'COUNT' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<input type="number" min={1} max={999} value={count} disabled={readOnly}
|
||||
onChange={e => setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} />
|
||||
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Mal</span>
|
||||
</div>
|
||||
)}
|
||||
{endChoice === 'UNTIL' && (
|
||||
<input type="date" value={until} min={startDate} disabled={readOnly}
|
||||
onChange={e => setUntil(e.target.value)} style={{ ...inputStyle, width: 160 }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Farbe ──────────────────────────────────────────────────── */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={labelStyle}>Farbe</label>
|
||||
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Ort</label>
|
||||
<input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} />
|
||||
@@ -185,7 +283,7 @@ 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',
|
||||
}}>
|
||||
Löschen
|
||||
{isSeries ? 'Reihe löschen' : 'Löschen'}
|
||||
</button>
|
||||
) : <span />}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
|
||||
Reference in new issue
Block a user