import type { CalendarEvent } from './shared'; import { useState } from 'react'; import { CalendarDays, MapPin, Repeat, Pencil, Trash2, Check, X, Plus } from 'lucide-react'; import { useCalendarEvents, useSelectedCollection } from './useDavData'; import { useDavEdit, CREATING, type NewEvent } from './useDavEdit'; import { useFilter, matches } from './useFilter'; import { useTranslation } from 'officerdev'; // Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns // events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server // never claimed existed. A list can show a repeating event honestly, as one entry with a "repeats" badge. const dayKey = (ev: CalendarEvent) => (ev.start ? ev.start.slice(0, 10) : ''); const formatDay = (iso: string) => { if (!iso) return 'No date'; const date = new Date(`${iso}T12:00:00`); if (Number.isNaN(date.getTime())) return iso; return date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); }; const formatTime = (ev: CalendarEvent) => { if (ev.allDay || !ev.start) return 'All day'; const start = new Date(ev.start); if (Number.isNaN(start.getTime())) return ''; const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }; const end = ev.end ? new Date(ev.end) : null; if (!end || Number.isNaN(end.getTime())) return start.toLocaleTimeString(undefined, opts); return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`; }; type Draft = { summary: string; location: string; date: string; start: string; end: string; allDay: boolean }; const pad = (n: number) => String(n).padStart(2, '0'); const localDate = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const localTime = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}`; /** The next whole hour, for an hour. What every calendar has trained people to expect from "new event". */ const blankDraft = (): Draft => { const start = new Date(Date.now() + 60 * 60 * 1000); start.setMinutes(0, 0, 0); const end = new Date(start.getTime() + 60 * 60 * 1000); return { summary: '', location: '', date: localDate(start), start: localTime(start), end: localTime(end), allDay: false, }; }; /** * The form's local fields become what the sidecar takes: an ISO instant, or a plain date for all-day. * * The clock is read as LOCAL — a person typing 14:00 means 14:00 where they are — and `toISOString` * converts it, so what reaches the file is UTC and unambiguous. */ const toEvent = (d: Draft): NewEvent => { const base = { summary: d.summary.trim(), location: d.location.trim() || undefined }; if (d.allDay) return { ...base, start: d.date, allDay: true }; const start = new Date(`${d.date}T${d.start}`); let end = new Date(`${d.date}T${d.end}`); // 23:00–01:00 is an event that crosses midnight, not one that ends two hours before it started. if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000); return { ...base, start: start.toISOString(), end: end.toISOString(), allDay: false }; }; const FIELD = 'rounded-md border bg-background px-2 py-1 text-sm'; export const CalendarView = () => { const { t } = useTranslation(); const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar'); const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null); // Keyed on the COLLECTION, so a save refreshes the list this event came from. const edit = useDavEdit(['caldav', 'events', selected?.path ?? null]); const [editing, setEditing] = useState(null); const [draft, setDraft] = useState({ summary: '', location: '' }); const [confirming, setConfirming] = useState(null); const [draftNew, setDraftNew] = useState(null); const filter = useFilter('Filter events'); if (loadingCollections) return ; if (!selected) return ; // Title, place, notes — and the DAY, so "saturday" or "2026-08-29" narrows to a date the way a person // expects. `start` is an ISO string, which is why the date is searchable at all without parsing it. const shown = events.filter((ev) => matches(filter.query, ev.summary, ev.location, ev.description, ev.start)); // Not an early return any more: the header carries the only way to ADD an event, and a calendar that // is empty or still loading is exactly when somebody wants it. A filter that matches nothing says so, // rather than looking like a calendar that lost its events. const notice = error ? t('dav.readFailed', { name: selected.displayName, reason: String(error) }) : isLoading ? 'Loading events…' : events.length === 0 ? `${selected.displayName} has no events.` : shown.length === 0 ? `No event matches “${filter.query}”.` : null; const sorted = [...shown].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? '')); const days: Array<{ key: string; events: CalendarEvent[] }> = []; for (const ev of sorted) { const key = dayKey(ev); const last = days[days.length - 1]; if (last && last.key === key) last.events.push(ev); else days.push({ key, events: [ev] }); } // Newest day at the top. A calendar accumulates years, and an ascending list opens on whatever was // imported first — scrolling to the bottom to find this week is the wrong default. Within a day the // order stays chronological, because that is what a day IS. days.reverse(); const submitNew = (e: React.FormEvent) => { e.preventDefault(); if (!draftNew?.summary.trim()) return; void edit.createEvent(selected.path, toEvent(draftNew)).then((ok) => ok && setDraftNew(null)); }; return (
{selected.displayName}
{filter.query ? `${shown.length} of ${events.length}` : events.length}{' '} {events.length === 1 ? 'event' : 'events'}
{filter.input}
{/* Title, when, and where. No recurrence and no alarms: this form writes a minimal VEVENT, and a field the list cannot show afterwards is a field that silently disappears. */} {draftNew && (
setDraftNew({ ...draftNew, summary: e.target.value })} placeholder="Title" className={FIELD} />
setDraftNew({ ...draftNew, date: e.target.value })} className={`${FIELD} tabular-nums`} /> {!draftNew.allDay && ( <> setDraftNew({ ...draftNew, start: e.target.value })} className={`${FIELD} tabular-nums`} /> to setDraftNew({ ...draftNew, end: e.target.value })} className={`${FIELD} tabular-nums`} /> )}
setDraftNew({ ...draftNew, location: e.target.value })} placeholder="Where" className={`${FIELD} text-xs`} />
{edit.error && {edit.error}}
)} {notice && } {days.map((day) => (

{formatDay(day.key)}

{day.events.map((ev) => (
{ev.summary || '(no title)'} {ev.rrule && ( repeats )}
{formatTime(ev)}
{/* The editor is the title and the place, and nothing else. Those are the two fields a person actually corrects; times and recurrence are where an editor does damage, and a half-editor that silently rewrites an RRULE is worse than none. */} {editing === ev.href && (
{ e.preventDefault(); if (!ev.href) return; // `null` removes the line; an empty string would leave an empty LOCATION behind. void edit .save(ev.href, { summary: draft.summary, location: draft.location || null }) .then((ok) => ok && setEditing(null)); }} > setDraft((d) => ({ ...d, summary: e.target.value }))} placeholder="Title" className="rounded-md border bg-background px-2 py-1 text-sm" /> setDraft((d) => ({ ...d, location: e.target.value }))} placeholder="Where" className="rounded-md border bg-background px-2 py-1 text-xs" />
)} {ev.location && (
{ev.location}
)} {ev.description && (

{ev.description}

)}
{/* Only for an event we can actually address. An item with no href came from somewhere that did not give us one, and offering a delete that cannot work is worse than not offering it. */} {ev.href && editing !== ev.href && (
{confirming === ev.href ? ( <> ) : ( <> )}
)}
))}
))}
); }; const Placeholder = ({ text }: { text: string }) => (
{text}
);