// One event, created or edited. Summary, calendar, when, where and the description are writable; // attendees and the conference link are not, deliberately, because v1 has no RSVP flow and no // scheduling anything. // // The one genuinely subtle thing here is scope. Saving or deleting an occurrence of a recurring // series does something different to the server depending on whether you meant this one, this one // and everything after it, or the whole series, so the panel stops and asks. There is no default, // because a default is exactly what gets missed. import { useMemo, useState, type FormEvent } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { eventCreate, eventDelete, eventUpdate } from "../api/events"; import { useEscapeLayer } from "../escape"; import type { Calendar, EventDraft, EventPatch, Instance, InstanceKey, Scope } from "../ipc"; import { useAccounts } from "../store/useAccounts"; import { useCalendarView } from "../store/useCalendarView"; import { useOverlays } from "../store/useOverlays"; import { notify } from "../store/useToast"; import { parseDateOnly, toDateOnly, toOffsetIso } from "../time"; import "../styles/create.css"; import { ColorPicker } from "./ColorPicker"; import { parseDescription } from "./EventDetailsHtml"; import { Icon } from "./Icon"; import { RichText } from "./RichText"; import { allDayEndInput, allDayEndWire, attendeeTally, canWrite, defaultRange, fromDateTime, responseLabel, toTimeOnly, whenText, } from "./overlayModel"; import { Confirm, Sheet } from "./overlayShell"; import { calSlot, eventTitle } from "./GridModel"; import { useEditor, type EditorSeed, type EditorTarget } from "./useEditor"; const FORM_ID = "event-editor-form"; const LINK = "M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2"; const REPEAT = "M17 2l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3"; const LOCK = "M5 11h14v10H5zM8 11V7a4 4 0 0 1 8 0v4"; interface Form { summary: string; calendarId: string; allDay: boolean; startDate: string; startTime: string; endDate: string; endTime: string; location: string; description: string; /** Google's per-event colour id. Empty means the event takes its calendar's colour. */ colorId: string; } interface Times { startMs: number; endMs: number; start: string; end: string; valid: boolean; } function formFor(instance: Instance | null, seed: EditorSeed, anchor: number, fallback: string): Form { if (instance) { return { summary: instance.summary, calendarId: instance.calendarId, allDay: instance.allDay, startDate: toDateOnly(instance.startMs), startTime: toTimeOnly(instance.startMs), endDate: instance.allDay ? allDayEndInput(instance.endMs) : toDateOnly(instance.endMs), endTime: toTimeOnly(instance.endMs), location: instance.location ?? "", description: instance.description ?? "", colorId: instance.colorId ?? "", }; } const range = defaultRange(anchor); const startMs = seed.startMs ?? range.startMs; const endMs = seed.endMs ?? startMs + (range.endMs - range.startMs); const allDay = seed.allDay ?? false; return { summary: seed.summary ?? "", calendarId: seed.calendarId ?? fallback, allDay, startDate: toDateOnly(startMs), startTime: toTimeOnly(startMs), endDate: allDay ? allDayEndInput(endMs) : toDateOnly(endMs), endTime: toTimeOnly(endMs), location: seed.location ?? "", description: seed.description ?? "", colorId: seed.colorId ?? "", }; } /** What the form means in wire terms, and whether it means anything at all. */ function timesOf(form: Form): Times { if (form.allDay) { const startMs = parseDateOnly(form.startDate); const endMs = parseDateOnly(form.endDate); const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs; return { startMs, endMs, start: form.startDate, end: valid ? allDayEndWire(form.endDate) : "", valid }; } const startMs = fromDateTime(form.startDate, form.startTime); const endMs = fromDateTime(form.endDate, form.endTime); const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs; return { startMs, endMs, start: valid ? toOffsetIso(startMs) : "", end: valid ? toOffsetIso(endMs) : "", valid, }; } /** Only what moved. An absent field means unchanged; an empty string clears the two that allow it. */ function patchOf(form: Form, times: Times, instance: Instance): EventPatch { const patch: EventPatch = {}; if (form.summary !== instance.summary) patch.summary = form.summary; if (form.location !== (instance.location ?? "")) patch.location = form.location; if (form.description !== (instance.description ?? "")) patch.description = form.description; if (form.calendarId !== instance.calendarId) patch.calendarId = form.calendarId; // The empty string is meaningful here: it is how the event is put back on its calendar's colour. if (form.colorId !== (instance.colorId ?? "")) patch.colorId = form.colorId; // The panel holds the inclusive last day of an all-day event, so compare like with like. const wasEnd = instance.allDay ? parseDateOnly(allDayEndInput(instance.endMs)) : instance.endMs; if (form.allDay !== instance.allDay || times.startMs !== instance.startMs || times.endMs !== wasEnd) { patch.allDay = form.allDay; patch.start = times.start; patch.end = times.end; } return patch; } const targetId = (target: EditorTarget): string => target.mode === "create" ? "create" : `edit ${target.key.eventId} ${target.key.originalStart ?? ""}`; export interface EventEditorProps { /** Where a new event lands when nothing seeded a calendar. */ defaultCalendarId?: string; } /** * Nothing is resident: the panel below only exists while the overlay is open, which is what keeps * its form state from surviving into the next event you open. */ export function EventEditor({ defaultCalendarId }: EventEditorProps) { const open = useOverlays((s) => s.open); const target = useEditor((s) => s.target); if (open !== "editor") return null; const resolved: EditorTarget = target ?? { mode: "create", seed: {} }; return ( ); } interface EditorPanelProps { target: EditorTarget; defaultCalendarId?: string; } function EditorPanel({ target, defaultCalendarId }: EditorPanelProps) { const dismiss = useEditor((s) => s.close); const calendars = useAccounts((s) => s.calendars); const instances = useCalendarView((s) => s.instances); const anchor = useCalendarView((s) => s.anchor); const key: InstanceKey | null = target.mode === "edit" ? target.key : null; const instance = useMemo( () => key ? (instances.find((i) => i.eventId === key.eventId && i.originalStart === key.originalStart) ?? null) : null, [instances, key], ); const writable = useMemo( () => calendars.filter((c) => canWrite(c.accessRole) || c.id === instance?.calendarId), [calendars, instance], ); const fallback = defaultCalendarId ?? writable.find((c) => c.primary)?.id ?? writable[0]?.id ?? ""; const [form, setForm] = useState
(() => formFor(instance, target.mode === "create" ? target.seed : {}, anchor, fallback), ); const [ask, setAsk] = useState<{ action: "save" | "delete" } | null>(null); const [busy, setBusy] = useState(false); // The swatch for "same as calendar" previews the calendar it would follow, drawn in the slot // that calendar's dot is drawn in everywhere else rather than in the hex Google stores. const chosen = calendars.find((c) => c.id === form.calendarId) ?? null; const calendarColor = chosen ? `var(--cal-${calSlot(chosen.colorHex, chosen.id)})` : "var(--line-strong)"; const recurring = instance?.recurring ?? false; const readOnly = instance?.readOnly ?? false; const times = timesOf(form); const scopeStep = ask !== null && recurring; useEscapeLayer(scopeStep, () => setAsk(null)); const set = (field: K, value: Form[K]) => setForm((prev) => ({ ...prev, [field]: value })); const close = () => { setAsk(null); dismiss(); }; async function run(action: "save" | "delete", scope: Scope) { setBusy(true); try { if (action === "delete") { if (!key) { close(); return; } await eventDelete(key, scope); notify("Event deleted"); } else if (key && instance) { const patch = patchOf(form, times, instance); if (Object.keys(patch).length > 0) await eventUpdate(key, patch, scope); } else { const draft: EventDraft = { calendarId: form.calendarId, summary: form.summary.trim() || "Untitled", description: form.description || null, location: form.location || null, start: times.start, end: times.end, allDay: form.allDay, // Absent rather than empty when it follows the calendar, which is what the wire reads // as no override. ...(form.colorId ? { colorId: form.colorId } : {}), }; await eventCreate(draft); } void useCalendarView.getState().load(); close(); } catch (e) { notify(action === "delete" ? `Could not delete the event: ${e}` : `Could not save the event: ${e}`); setAsk(null); } finally { setBusy(false); } } const onSubmit = (e: FormEvent) => { e.preventDefault(); if (!times.valid || !form.calendarId || busy) return; if (recurring) setAsk({ action: "save" }); else void run("save", "this"); }; if (scopeStep && ask) { const action = ask.action; return ( setAsk(null)}> Back } >

{instance?.summary || "This event"} repeats. Choose what this{" "} {action === "delete" ? "deletion" : "change"} applies to.

{SCOPES.map(([scope, name, note]) => ( ))}
); } if (ask) { return ( It goes from this calendar for everyone it was shared with.

} confirmLabel="Delete" busy={busy} onConfirm={() => void run("delete", "this")} onCancel={() => setAsk(null)} />
); } if (target.mode === "edit" && !instance) { return ( Close } >

That event is no longer in the range you are looking at.

); } if (readOnly && instance) { return ( Close } > ); } return ( {target.mode === "edit" && ( )} } > e.stopPropagation()}>
Colour set("colorId", colorId ?? "")} calendarColor={calendarColor} />
When
set("startDate", e.target.value)} /> {!form.allDay && ( set("startTime", e.target.value)} /> )} to set("endDate", e.target.value)} /> {!form.allDay && ( set("endTime", e.target.value)} /> )}
{!times.valid && ( That ends before it starts. )} {recurring && ( Repeats, so saving will ask what the change applies to. )}