// 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 (