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:
marcandClaude Opus 5 committed 2026-09-04 00:56:28 +02:00
1 parent 105ce8de4a
commit 79a7cc6a3d
8 files changed
+795 -218

No files matched your search

@@ -1,8 +1,7 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarItem } from '../../api/client'
const palette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#a142f4', '#ff6d00', '#00acc1', '#607d8b']
import { ColorSwatches, palette } from './controls'
interface Props {
calendar?: CalendarItem
@@ -50,18 +49,8 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
/>
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Farbe</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 20, flexWrap: 'wrap' }}>
{palette.map(c => (
<button
key={c} type="button" onClick={() => setColor(c)}
aria-label={c}
style={{
width: 28, height: 28, borderRadius: '50%', background: c, cursor: 'pointer',
border: color === c ? '2px solid var(--color-text)' : '2px solid transparent',
boxShadow: color === c ? '0 0 0 2px var(--color-surface)' : 'none',
}}
/>
))}
<div style={{ marginBottom: 20 }}>
<ColorSwatches value={color} onChange={setColor} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
@@ -0,0 +1,119 @@
import { useEffect } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarEvent } from '../../api/client'
const weekdayLong = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
const monthLabels = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
export interface DayEvent extends CalendarEvent {
calendarId: string
/** Endgültige Farbe: eigene Farbe des Termins, sonst die des Kalenders. */
color: string
calendarName: string
startsAt: Date
endsAt: Date | null
}
/**
* Ein angeklickter Tag wird erst **geöffnet**, statt sofort einen neuen
* Termin anzulegen.
*
* Vorher legte jeder Klick auf eine Tageszelle direkt einen neuen Termin an –
* auch der Klick, mit dem man eigentlich nachsehen wollte, was an dem Tag
* ansteht. Hier steht zuerst, was da ist; einen neuen Termin gibt es auf
* Ansage.
*/
export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
day: Date
events: DayEvent[]
onPick: (ev: DayEvent) => void
onCreate: () => void
onClose: () => void
}) {
const overlay = useOverlayDismiss(onClose)
useEffect(() => {
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const timeLabel = (ev: DayEvent) => {
if (ev.all_day) return 'ganztägig'
const from = ev.startsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
if (!ev.endsAt) return from
const to = ev.endsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return `${from} – ${to}`
}
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 190, 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, width: 420, maxWidth: '100%', maxHeight: '80vh',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<div style={{ padding: '18px 22px 12px', borderBottom: '1px solid var(--color-border)' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{weekdayLong[day.getDay()]}</div>
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--color-text)' }}>
{day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()}
</div>
</div>
<div style={{ padding: 12, overflowY: 'auto', flex: 1 }}>
{events.length === 0 && (
<p style={{ margin: '12px 10px', fontSize: 13, color: 'var(--color-text-muted)' }}>
Nichts an diesem Tag.
</p>
)}
{events.map(ev => (
<button
key={ev.calendarId + ev.uid + ev.start}
onClick={() => onPick(ev)}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
padding: '9px 10px', marginBottom: 4, borderRadius: 8, textAlign: 'left',
background: 'transparent', border: '1px solid var(--color-border)', cursor: 'pointer',
}}
className="aether-cal-cell"
>
<span style={{ width: 4, alignSelf: 'stretch', borderRadius: 2, background: ev.color, flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.summary || '(ohne Titel)'}
{ev.recurring && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--color-text-muted)' }}>↻</span>}
</span>
<span style={{ display: 'block', fontSize: 11.5, color: 'var(--color-text-muted)' }}>
{timeLabel(ev)}
{ev.location ? ` · ${ev.location}` : ''}
{` · ${ev.calendarName}`}
</span>
</span>
</button>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '12px 16px', borderTop: '1px solid var(--color-border)' }}>
<button onClick={onCreate} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>
+ Termin an diesem Tag
</button>
<button onClick={onClose} style={{
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}>
Schließen
</button>
</div>
</div>
</div>
)
}
+123 -25
View File
@@ -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 }}>
@@ -0,0 +1,95 @@
import type { CSSProperties } from 'react'
/** Eine Palette für Kalender **und** Termine – zwei getrennte Listen liefen
* sonst mit der Zeit auseinander, und ein Termin in einer Farbe, die es als
* Kalenderfarbe nicht gibt, sieht aus wie ein Fehler. */
export const palette = [
'#4285f4', '#ea4335', '#fbbc04', '#34a853',
'#a142f4', '#ff6d00', '#00acc1', '#607d8b',
]
/**
* Schiebeschalter statt Kästchen.
*
* Nicht nur Zierde: „Ganztägig" schaltet das Formular um (die Uhrzeitfelder
* verschwinden). Ein Kästchen liest sich wie eine Angabe, die man nebenbei
* ankreuzt – ein Schalter zeigt, dass hier etwas umgestellt wird.
*/
export function Toggle({ checked, onChange, disabled, label }: {
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
label: string
}) {
return (
<label style={{
display: 'flex', alignItems: 'center', gap: 10,
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
fontSize: 13, color: 'var(--color-text)',
}}>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => !disabled && onChange(!checked)}
style={{
width: 38, height: 22, borderRadius: 11, flexShrink: 0, padding: 0,
border: '1px solid var(--color-border)',
background: checked ? 'var(--color-primary)' : 'var(--color-surface-2)',
cursor: disabled ? 'default' : 'pointer',
transition: 'background 0.15s',
position: 'relative',
}}
>
<span style={{
position: 'absolute', top: 2, left: checked ? 18 : 2,
width: 16, height: 16, borderRadius: '50%',
background: checked ? '#fff' : 'var(--color-text-muted)',
transition: 'left 0.15s',
}} />
</button>
{label}
</label>
)
}
/**
* Farbwahl als Kreise. Der gewählte Kreis ist gefüllt, die anderen sind
* Umrisse – dieselbe Sprache wie in der Kalenderliste, wo ein gefüllter
* Punkt „wird angezeigt" heißt.
*/
export function ColorSwatches({ value, onChange, inherit, disabled }: {
value: string
onChange: (c: string) => void
/** Ist sie gesetzt, gibt es zusätzlich die Wahl „Farbe des Kalenders"
* (leerer Wert) und dieser Kreis zeigt sie an. */
inherit?: string
disabled?: boolean
}) {
const dot = (color: string, selected: boolean, onClick: () => void, key: string, title: string) => (
<button
key={key} type="button" title={title} aria-label={title} disabled={disabled}
onClick={onClick}
style={{
width: 26, height: 26, borderRadius: '50%', padding: 0, flexShrink: 0,
background: selected ? color : 'transparent',
border: `2px solid ${color}`,
cursor: disabled ? 'default' : 'pointer',
boxShadow: selected ? `0 0 0 2px var(--color-surface), 0 0 0 3.5px ${color}` : 'none',
transition: 'background 0.12s, box-shadow 0.12s',
} as CSSProperties}
/>
)
return (
<div style={{ display: 'flex', gap: 9, flexWrap: 'wrap', alignItems: 'center' }}>
{inherit !== undefined && dot(inherit, value === '', () => onChange(''), 'inherit', 'Farbe des Kalenders')}
{inherit !== undefined && (
<span style={{ width: 1, height: 20, background: 'var(--color-border)', margin: '0 2px' }} />
)}
{palette.map(c => dot(c, value.toLowerCase() === c.toLowerCase(), () => onChange(c), c, c))}
</div>
)
}