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
@@ -7,6 +7,7 @@ import PageHeader from '../components/PageHeader'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import EventDialog from '../components/calendar/EventDialog'
|
||||
import CalendarDialog from '../components/calendar/CalendarDialog'
|
||||
import DayDialog, { type DayEvent } from '../components/calendar/DayDialog'
|
||||
import {
|
||||
addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek,
|
||||
} from '../components/calendar/dates'
|
||||
@@ -25,24 +26,27 @@ const HOUR_H = 44
|
||||
/** Breite der Stundenspalte links im Wochenraster. */
|
||||
const GUTTER = 52
|
||||
|
||||
interface EventWithCalendar extends CalendarEvent {
|
||||
calendarId: string
|
||||
color: string
|
||||
/** Anfang als örtlicher Zeitpunkt – ganztägige Termine als Tagesbeginn. */
|
||||
startsAt: Date
|
||||
endsAt: Date | null
|
||||
}
|
||||
type EventWithCalendar = DayEvent
|
||||
|
||||
function decorate(ev: CalendarEvent, cal: CalendarItem): EventWithCalendar {
|
||||
return {
|
||||
...ev,
|
||||
calendarId: cal.id,
|
||||
color: cal.color,
|
||||
calendarName: cal.name,
|
||||
// Eigene Terminfarbe schlägt die Kalenderfarbe – aber nur, wenn eine
|
||||
// gesetzt ist. Sonst erbt der Termin sie weiterhin.
|
||||
color: ev.color || cal.color,
|
||||
startsAt: eventDay(ev.start, ev.all_day),
|
||||
endsAt: ev.end ? eventDay(ev.end, ev.all_day) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Eindeutig auch für aufgeklappte Wiederholungen: die teilen sich die UID
|
||||
* und unterscheiden sich nur im Anfangszeitpunkt. */
|
||||
function eventKey(ev: EventWithCalendar): string {
|
||||
return `${ev.calendarId}|${ev.uid}|${ev.start}`
|
||||
}
|
||||
|
||||
/** Termine je Tag. Ein mehrtägiger Termin steht in **jeder** Zelle, die er
|
||||
* belegt – vorher nur am Starttag, was ihn ab dem zweiten Tag verschwinden
|
||||
* ließ. */
|
||||
@@ -66,7 +70,7 @@ function groupByDay(events: EventWithCalendar[]): Map<string, EventWithCalendar[
|
||||
}
|
||||
|
||||
/** Vorschlag für einen neuen Termin: heute die nächste volle Stunde, an
|
||||
* anderen Tagen 9 Uhr. Mitternacht als Vorgabe (vorher) trifft fast nie zu. */
|
||||
* anderen Tagen 9 Uhr. Mitternacht als Vorgabe trifft fast nie zu. */
|
||||
function suggestStart(day: Date): Date {
|
||||
const now = new Date()
|
||||
if (isSameDay(day, now)) {
|
||||
@@ -83,8 +87,7 @@ function suggestStart(day: Date): Date {
|
||||
export default function CalendarPage() {
|
||||
const [calendars, setCalendars] = useState<CalendarItem[]>([])
|
||||
/** Ausgeblendete Kalender. Bewusst andersherum als „sichtbare": So ist ein
|
||||
* **neu angelegter** Kalender automatisch sichtbar. Vorher landete er in
|
||||
* keiner Sichtbar-Liste und blieb nach dem Anlegen unsichtbar. */
|
||||
* **neu angelegter** Kalender automatisch sichtbar. */
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||
const [eventsByCal, setEventsByCal] = useState<Record<string, CalendarEvent[]>>({})
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('month')
|
||||
@@ -94,6 +97,7 @@ export default function CalendarPage() {
|
||||
const [showList, setShowList] = useState(() =>
|
||||
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 900px)').matches)
|
||||
|
||||
const [dayDialog, setDayDialog] = useState<Date | null>(null)
|
||||
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; start: Date } | null>(null)
|
||||
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
|
||||
const [deleteCalendar, setDeleteCalendar] = useState<CalendarItem | null>(null)
|
||||
@@ -102,10 +106,28 @@ export default function CalendarPage() {
|
||||
const importInputRef = useRef<HTMLInputElement>(null)
|
||||
const importTargetRef = useRef<string | null>(null)
|
||||
|
||||
const loadEvents = useCallback(async (list: CalendarItem[]) => {
|
||||
/**
|
||||
* Sichtbares Zeitfenster. Der Server klappt Wiederholungen **darin** auf –
|
||||
* eine wöchentliche Reihe ohne Ende hat kein natürliches Ende, also muss
|
||||
* die Ansicht sagen, welchen Ausschnitt sie braucht.
|
||||
*/
|
||||
const range = useMemo(() => {
|
||||
if (viewMode === 'month') {
|
||||
const s = startOfWeek(startOfMonth(cursor))
|
||||
return { from: addDays(s, -1), to: addDays(s, 43) }
|
||||
}
|
||||
if (viewMode === 'week') {
|
||||
const s = startOfWeek(cursor)
|
||||
return { from: addDays(s, -1), to: addDays(s, 8) }
|
||||
}
|
||||
const today = startOfDay(new Date())
|
||||
return { from: addDays(today, -31), to: addDays(today, 365) }
|
||||
}, [viewMode, cursor.getFullYear(), cursor.getMonth(), viewMode === 'week' ? +startOfWeek(cursor) : 0])
|
||||
|
||||
const loadEvents = useCallback(async (list: CalendarItem[], from: Date, to: Date) => {
|
||||
const pairs = await Promise.all(list.map(async c => {
|
||||
try {
|
||||
return [c.id, await calendarApi.listEvents(c.id)] as const
|
||||
return [c.id, await calendarApi.listEvents(c.id, from, to)] as const
|
||||
} catch {
|
||||
return [c.id, [] as CalendarEvent[]] as const
|
||||
}
|
||||
@@ -113,20 +135,29 @@ export default function CalendarPage() {
|
||||
setEventsByCal(Object.fromEntries(pairs))
|
||||
}, [])
|
||||
|
||||
const reloadAll = useCallback(async () => {
|
||||
const loadCalendars = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const list = await calendarApi.list()
|
||||
setCalendars(list)
|
||||
await loadEvents(list)
|
||||
setCalendars(await calendarApi.list())
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadEvents])
|
||||
}, [])
|
||||
|
||||
useEffect(() => { reloadAll() }, [reloadAll])
|
||||
useEffect(() => { loadCalendars() }, [loadCalendars])
|
||||
|
||||
// Termine hängen an Kalenderliste **und** Zeitfenster: Beim Blättern in
|
||||
// einen anderen Monat müssen die Wiederholungen dort neu aufgeklappt werden.
|
||||
useEffect(() => {
|
||||
if (calendars.length === 0) { setEventsByCal({}); return }
|
||||
loadEvents(calendars, range.from, range.to)
|
||||
}, [calendars, range.from, range.to, loadEvents])
|
||||
|
||||
const refreshEvents = useCallback(
|
||||
() => loadEvents(calendars, range.from, range.to),
|
||||
[calendars, range.from, range.to, loadEvents])
|
||||
|
||||
const allEvents = useMemo(() => {
|
||||
const out: EventWithCalendar[] = []
|
||||
@@ -149,13 +180,10 @@ export default function CalendarPage() {
|
||||
|
||||
const defaultCalendarId = calendars[0]?.id
|
||||
|
||||
const openNewEvent = useCallback((start: Date) => {
|
||||
setCalendars(cals => {
|
||||
const first = cals[0]
|
||||
if (first) setEventDialog({ calId: first.id, start })
|
||||
return cals
|
||||
})
|
||||
}, [])
|
||||
function openNewEvent(start: Date) {
|
||||
if (!defaultCalendarId) return
|
||||
setEventDialog({ calId: defaultCalendarId, start })
|
||||
}
|
||||
|
||||
function openEditEvent(ev: EventWithCalendar) {
|
||||
setEventDialog({ calId: ev.calendarId, event: ev, start: ev.startsAt })
|
||||
@@ -170,7 +198,7 @@ export default function CalendarPage() {
|
||||
await calendarApi.createEvent(calId, input)
|
||||
}
|
||||
setEventDialog(null)
|
||||
await loadEvents(calendars)
|
||||
await refreshEvents()
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
||||
}
|
||||
@@ -182,7 +210,7 @@ export default function CalendarPage() {
|
||||
await calendarApi.deleteEvent(deleteEvent.calId, deleteEvent.uid)
|
||||
setDeleteEvent(null)
|
||||
setEventDialog(null)
|
||||
await loadEvents(calendars)
|
||||
await refreshEvents()
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
||||
}
|
||||
@@ -196,7 +224,7 @@ export default function CalendarPage() {
|
||||
await calendarApi.create(name, color)
|
||||
}
|
||||
setCalDialog(null)
|
||||
await reloadAll()
|
||||
await loadCalendars()
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
||||
}
|
||||
@@ -207,7 +235,7 @@ export default function CalendarPage() {
|
||||
try {
|
||||
await calendarApi.remove(deleteCalendar.id)
|
||||
setDeleteCalendar(null)
|
||||
await reloadAll()
|
||||
await loadCalendars()
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
||||
}
|
||||
@@ -224,7 +252,7 @@ export default function CalendarPage() {
|
||||
if (!file || !calId) return
|
||||
try {
|
||||
await calendarApi.import(calId, file)
|
||||
await loadEvents(calendars)
|
||||
await refreshEvents()
|
||||
} catch (err) {
|
||||
setError(err instanceof APIError ? err.message : 'Import fehlgeschlagen')
|
||||
}
|
||||
@@ -238,11 +266,6 @@ export default function CalendarPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function jumpToDay(day: Date) {
|
||||
setCursor(day)
|
||||
setViewMode('week')
|
||||
}
|
||||
|
||||
const periodLabel = viewMode === 'month'
|
||||
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
|
||||
: viewMode === 'week'
|
||||
@@ -272,7 +295,7 @@ export default function CalendarPage() {
|
||||
...navBtnStyle, width: 30,
|
||||
color: showList ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="3.5" cy="6" r="1.2" fill="currentColor" /><circle cx="3.5" cy="12" r="1.2" fill="currentColor" /><circle cx="3.5" cy="18" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
@@ -330,27 +353,42 @@ export default function CalendarPage() {
|
||||
fontSize: 18, lineHeight: 1, padding: '0 2px',
|
||||
}}>+</button>
|
||||
</div>
|
||||
{calendars.map(cal => (
|
||||
<div key={cal.id} className="aether-cal-row" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 4px',
|
||||
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
|
||||
}}>
|
||||
<input type="checkbox" checked={!hidden.has(cal.id)} onChange={() => toggleVisible(cal.id)}
|
||||
style={{ accentColor: cal.color, cursor: 'pointer' }} />
|
||||
<span onClick={() => toggleVisible(cal.id)} style={{
|
||||
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
|
||||
opacity: hidden.has(cal.id) ? 0.45 : 1,
|
||||
{calendars.map(cal => {
|
||||
const on = !hidden.has(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,
|
||||
}}>
|
||||
{cal.name}
|
||||
</span>
|
||||
<CalMenu
|
||||
onRename={() => setCalDialog({ calendar: cal })}
|
||||
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
|
||||
onImport={() => triggerImport(cal.id)}
|
||||
onDelete={() => setDeleteCalendar(cal)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* Der Farbpunkt **ist** der Schalter: gefüllt = wird
|
||||
angezeigt, leerer Ring = ausgeblendet. Ein Kästchen
|
||||
daneben sagte dasselbe zweimal. */}
|
||||
<button
|
||||
onClick={() => toggleVisible(cal.id)}
|
||||
role="switch" aria-checked={on}
|
||||
title={on ? 'Ausblenden' : 'Einblenden'}
|
||||
style={{
|
||||
width: 15, height: 15, borderRadius: '50%', flexShrink: 0, padding: 0,
|
||||
border: `2px solid ${cal.color}`,
|
||||
background: on ? cal.color : 'transparent',
|
||||
cursor: 'pointer', transition: 'background 0.12s',
|
||||
}}
|
||||
/>
|
||||
<span onClick={() => toggleVisible(cal.id)} style={{
|
||||
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
|
||||
opacity: on ? 1 : 0.45,
|
||||
}}>
|
||||
{cal.name}
|
||||
</span>
|
||||
<CalMenu
|
||||
onRename={() => setCalDialog({ calendar: cal })}
|
||||
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
|
||||
onImport={() => triggerImport(cal.id)}
|
||||
onDelete={() => setDeleteCalendar(cal)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{calendars.length === 0 && (
|
||||
<p style={{ fontSize: 12.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
Noch kein Kalender – über <strong>+</strong> einen anlegen.
|
||||
@@ -361,10 +399,10 @@ export default function CalendarPage() {
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', display: 'flex' }}>
|
||||
{viewMode === 'month' && (
|
||||
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} onMore={jumpToDay} />
|
||||
<MonthGrid cursor={cursor} byDay={byDay} onOpenDay={setDayDialog} onEventClick={openEditEvent} />
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onEventClick={openEditEvent} />
|
||||
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onOpenDay={setDayDialog} onEventClick={openEditEvent} />
|
||||
)}
|
||||
{viewMode === 'list' && (
|
||||
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
|
||||
@@ -374,6 +412,15 @@ export default function CalendarPage() {
|
||||
|
||||
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
|
||||
|
||||
{dayDialog && (
|
||||
<DayDialog
|
||||
day={dayDialog}
|
||||
events={byDay.get(dayKey(dayDialog)) ?? []}
|
||||
onPick={ev => { setDayDialog(null); openEditEvent(ev) }}
|
||||
onCreate={() => { const d = dayDialog; setDayDialog(null); openNewEvent(suggestStart(d)) }}
|
||||
onClose={() => setDayDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{eventDialog && (
|
||||
<EventDialog
|
||||
event={eventDialog.event}
|
||||
@@ -400,7 +447,7 @@ export default function CalendarPage() {
|
||||
{deleteEvent && (
|
||||
<ConfirmDialog
|
||||
title="Termin löschen"
|
||||
message="Diesen Termin unwiderruflich löschen?"
|
||||
message="Diesen Termin unwiderruflich löschen? Bei einer Reihe verschwinden alle Termine der Reihe."
|
||||
confirmLabel="Löschen" danger
|
||||
onConfirm={confirmDeleteEvent}
|
||||
onCancel={() => setDeleteEvent(null)}
|
||||
@@ -478,8 +525,7 @@ 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, die sofort den Dialog „Neuer Termin"
|
||||
* öffnete – ein vorhandener Termin ließ sich dadurch gar nicht öffnen. */
|
||||
* an die Tageszelle darunter durch und öffnete den Tag statt den Termin. */
|
||||
function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
@@ -495,16 +541,16 @@ function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void
|
||||
>
|
||||
{!ev.all_day && <span style={{ opacity: 0.75 }}>{timeLabel(ev.startsAt)}</span>}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary || '(ohne Titel)'}</span>
|
||||
{ev.recurring && <span style={{ opacity: 0.7, flexShrink: 0 }}>↻</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
|
||||
function MonthGrid({ cursor, byDay, onOpenDay, onEventClick }: {
|
||||
cursor: Date
|
||||
byDay: Map<string, EventWithCalendar[]>
|
||||
onDayClick: (start: Date) => void
|
||||
onOpenDay: (day: Date) => void
|
||||
onEventClick: (ev: EventWithCalendar) => void
|
||||
onMore: (day: Date) => void
|
||||
}) {
|
||||
const gridStart = startOfWeek(startOfMonth(cursor))
|
||||
const today = new Date()
|
||||
@@ -532,7 +578,7 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
|
||||
<div
|
||||
key={+day}
|
||||
className="aether-cal-cell"
|
||||
onClick={() => onDayClick(suggestStart(day))}
|
||||
onClick={() => onOpenDay(day)}
|
||||
style={{
|
||||
borderRight: '1px solid var(--color-border)', padding: 4, cursor: 'pointer',
|
||||
background: inMonth ? 'transparent' : 'var(--color-surface-2)',
|
||||
@@ -550,18 +596,12 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
|
||||
</span>
|
||||
<div style={{ overflow: 'hidden', minWidth: 0 }}>
|
||||
{events.slice(0, 3).map(ev => (
|
||||
<EventChip key={ev.calendarId + ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
|
||||
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} />
|
||||
))}
|
||||
{events.length > 3 && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onMore(day) }}
|
||||
style={{
|
||||
border: 'none', background: 'none', padding: '0 4px', cursor: 'pointer',
|
||||
fontSize: 10.5, color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
|
||||
+{events.length - 3} weitere
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -624,10 +664,11 @@ function layoutDay(events: EventWithCalendar[], day: Date): Slot[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
||||
function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick }: {
|
||||
cursor: Date
|
||||
byDay: Map<string, EventWithCalendar[]>
|
||||
onSlotClick: (start: Date) => void
|
||||
onOpenDay: (day: Date) => void
|
||||
onEventClick: (ev: EventWithCalendar) => void
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -643,7 +684,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
||||
}, [])
|
||||
|
||||
const cols = `${GUTTER}px repeat(7, minmax(0, 1fr))`
|
||||
const nowTop = ((now: Date) => (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_H)(today)
|
||||
const nowTop = (today.getHours() * 60 + today.getMinutes()) / 60 * HOUR_H
|
||||
|
||||
/** Klick ins Raster: die angeklickte Stunde, nicht Mitternacht. */
|
||||
function slotFromClick(e: MouseEvent<HTMLDivElement>, day: Date) {
|
||||
@@ -657,13 +698,17 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||
{/* Kopfzeile */}
|
||||
{/* Kopfzeile – der Tag darin öffnet den Tag, wie im Monatsraster. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
|
||||
<div />
|
||||
{days.map(day => {
|
||||
const isToday = isSameDay(day, today)
|
||||
return (
|
||||
<div key={+day} style={{ padding: '6px 4px', textAlign: 'center', borderLeft: '1px solid var(--color-border)' }}>
|
||||
<button key={+day} onClick={() => onOpenDay(day)} className="aether-cal-cell" style={{
|
||||
padding: '6px 4px', textAlign: 'center', borderLeft: '1px solid var(--color-border)',
|
||||
background: 'transparent', border: 'none', borderLeftWidth: 1, borderLeftStyle: 'solid',
|
||||
borderLeftColor: 'var(--color-border)', cursor: 'pointer',
|
||||
}}>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
|
||||
<span style={{
|
||||
fontSize: 14, fontWeight: isToday ? 700 : 500,
|
||||
@@ -673,20 +718,19 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
||||
}}>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster, so wie sie
|
||||
auch gemeint sind: ohne Uhrzeit. */}
|
||||
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0, minHeight: 26 }}>
|
||||
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)', padding: '4px 6px', textAlign: 'right' }}>ganztg.</div>
|
||||
{days.map(day => (
|
||||
<div key={+day} className="aether-cal-cell" onClick={() => onSlotClick(suggestStart(day))}
|
||||
<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={ev.calendarId + ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
|
||||
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -733,7 +777,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
||||
|
||||
{slots.map(({ ev, top, height, col, cols: n }) => (
|
||||
<div
|
||||
key={ev.calendarId + ev.uid}
|
||||
key={eventKey(ev)}
|
||||
className="aether-cal-chip"
|
||||
onClick={e => { e.stopPropagation(); onEventClick(ev) }}
|
||||
title={`${ev.summary} – ${timeLabel(ev.startsAt)}`}
|
||||
@@ -746,7 +790,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 10.5, color: ev.color, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{ev.summary || '(ohne Titel)'}
|
||||
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
|
||||
</div>
|
||||
{height > 28 && (
|
||||
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)' }}>{timeLabel(ev.startsAt)}</div>
|
||||
@@ -794,7 +838,7 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE
|
||||
{isSameDay(day, today) && ' · heute'}
|
||||
</div>
|
||||
{evs.map(ev => (
|
||||
<div key={ev.calendarId + ev.uid} className="aether-cal-cell" onClick={() => onEventClick(ev)} style={{
|
||||
<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,
|
||||
}}>
|
||||
@@ -803,7 +847,7 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE
|
||||
{ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)}
|
||||
</span>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{ev.summary || '(ohne Titel)'}
|
||||
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
|
||||
</span>
|
||||
{ev.location && (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', marginLeft: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
|
||||
Reference in new issue
Block a user