Files
ihasmail/web/src/views/calendar/EventEditor.tsx
T
jcoffey-dev 2518605126 Custom date and time pickers that follow the configured format
Browsers render <input type="date"> and datetime-local in their own locale and
ignore the page's, so #1 left a German user on an English browser reading
22.11.2025 everywhere but still entering dates through an mm/dd/yyyy widget.
#3 makes the case that people use the picker rather than typing, which is
where the AM/PM mistakes happen.

New DateField and DateTimeField (web/src/ui/datefield.tsx) replace all nine
native controls — event editor (all-day and timed start/end, recurrence
until), out-of-office, contact birthday, advanced search. They take and emit
the same ISO strings the native inputs did, so call sites barely changed.

Each is a text box in the configured order plus a popover: a month grid
(week start from settings, locale weekday and month names, today and the
selection marked) and, for date-times, a list of times in the configured
clock. Keyboard: arrows move by day, PageUp/PageDown by month, Home/End
across the week, Enter picks, Escape closes, ArrowDown opens; the focused day
holds DOM focus so screen readers follow, and the dialog has an accessible
name (Popover gained an ariaLabel prop).

Text entry is lenient — the configured order with any separator, unseparated
digits (221125), day and month alone, non-Latin digits, and bare ISO always;
times take 18:23, 1823, 6:23pm, 930. What will not parse reverts on blur
rather than clearing the field, and impossible dates like 31 February are
rejected instead of rolling into March.

Editable boxes stay Gregorian and Latin-digit even where display does not
(fa-IR, th-TH, ar-EG): the locale's field order and separator are kept, but a
Buddhist-era year in a text box cannot round-trip against a Gregorian grid.
Noted in the README.

The out-of-office format echo added in #2 is gone — the fields now show the
right format themselves.

Closes #3
2026-08-23 13:12:17 -07:00

343 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Plus, Trash2, Users } from "lucide-react";
import type { BusyPeriod, CalendarEvent, EmailAddress, JSCalendarAlert, JSCalendarParticipant, JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
import { useCalendar, myParticipantKeys } from "@/store/calendar";
import { useSettings } from "@/store/settings";
import { useSession } from "@/store/session";
import { useContacts } from "@/store/contacts";
import { Dialog } from "@/ui/dialog";
import { ColorSwatches, Switch } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { RecipientInput } from "../compose/RecipientInput";
import { DateField, DateTimeField } from "@/ui/datefield";
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { newKey } from "@/lib/contacts";
export interface EditorInit {
event?: CalendarEvent;
start: Date;
end: Date;
allDay: boolean;
}
const ALERT_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 1440, 2880, 10080];
export function EventEditor({ init, onClose }: { init: EditorInit; onClose: () => void }) {
const cal = useCalendar();
const settings = useSettings((s) => s.settings);
const session = useSession((s) => s.session);
const [base, setBase] = useState<CalendarEvent | null | undefined>(init.event && !init.event.baseEventId ? init.event : undefined);
const editing = Boolean(init.event);
// Load base event for recurring instances
useEffect(() => {
if (init.event?.baseEventId) void cal.getEvent(init.event.baseEventId).then((e) => setBase(e));
else if (!init.event) setBase(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [init.event?.id]);
if (base === undefined) return null;
return <EventForm key={base?.id ?? "new"} init={init} base={base} editing={editing} onClose={onClose} settingsTz={settings.timeZone ?? browserTimeZone} defaultAlert={settings.defaultAlertMinutes} myEmail={session?.username ?? ""} />;
}
function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myEmail }: { init: EditorInit; base: CalendarEvent | null; editing: boolean; onClose: () => void; settingsTz: string; defaultAlert: number; myEmail: string }) {
const cal = useCalendar();
const contacts = useContacts();
const ev = base;
const calendars = Object.values(cal.calendars).filter((c) => c.myRights.mayWriteAll || c.myRights.mayWriteOwn);
const initialCal = ev ? Object.keys(ev.calendarIds)[0] : (calendars.find((c) => c.isDefault)?.id ?? calendars[0]?.id);
const evTz = ev?.timeZone ?? settingsTz;
const baseStart = ev ? zonedToDate(ev.start, ev.showWithoutTime ? null : evTz) : init.start;
const baseEnd = ev ? new Date(baseStart.getTime() + (parseDuration(ev.duration) || (ev.showWithoutTime ? 86400 : 3600)) * 1000) : init.end;
const [title, setTitle] = useState(ev?.title ?? "");
const [calendarId, setCalendarId] = useState(initialCal ?? "");
const [allDay, setAllDay] = useState(ev ? Boolean(ev.showWithoutTime) : init.allDay);
const [start, setStart] = useState(baseStart);
const [end, setEnd] = useState(baseEnd);
const [tz, setTz] = useState(evTz);
const [location, setLocation] = useState(Object.values(ev?.locations ?? {})[0]?.name ?? "");
const [vurl, setVurl] = useState(Object.values(ev?.virtualLocations ?? {})[0]?.uri ?? "");
const [description, setDescription] = useState(ev?.description ?? "");
const [status, setStatus] = useState<NonNullable<CalendarEvent["status"]>>(ev?.status ?? "confirmed");
const [privacy, setPrivacy] = useState<NonNullable<CalendarEvent["privacy"]>>(ev?.privacy ?? "public");
const [freeBusy, setFreeBusy] = useState<NonNullable<CalendarEvent["freeBusyStatus"]>>(ev?.freeBusyStatus ?? "busy");
const [color, setColor] = useState<string | null>(ev?.color ?? null);
const categories = useSettings((s) => s.settings.eventCategories);
const [category, setCategory] = useState<string>(() => Object.keys(ev?.categories ?? {}).find((n) => categories.some((c) => c.name.toLowerCase() === n.toLowerCase())) ?? "");
const [rule, setRule] = useState<JSCalendarRecurrenceRule | undefined>(ev?.recurrenceRules?.[0]);
const [preset, setPreset] = useState<RecurrencePreset>(presetFor(ev?.recurrenceRules?.[0]));
const [alerts, setAlerts] = useState<number[]>(() => {
const a = Object.values(ev?.alerts ?? {}).map((x) => ("offset" in x.trigger ? -parseDuration(x.trigger.offset) / 60 : 0)).filter((n) => n >= 0);
if (ev) return a;
return defaultAlert >= 0 ? [defaultAlert] : [];
});
const myKeys = ev ? myParticipantKeys(ev, cal.identities) : [];
const [attendees, setAttendees] = useState<EmailAddress[]>(() =>
Object.entries(ev?.participants ?? {})
.filter(([k, p]) => !myKeys.includes(k) && !(p.roles?.owner && !p.roles?.attendee))
.map(([, p]) => ({ name: p.name ?? null, email: p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, "") ?? "" }))
.filter((a) => a.email),
);
const [sendInvites, setSendInvites] = useState(true);
const [busy, setBusy] = useState(false);
const [fb, setFb] = useState<Record<string, BusyPeriod[]>>({});
const [showMore, setShowMore] = useState(Boolean(ev && (ev.privacy !== "public" || ev.freeBusyStatus === "free" || ev.color || ev.status !== "confirmed" || Object.keys(ev.categories ?? {}).length)));
const identity = cal.identities.find((i) => i.isDefault) ?? cal.identities[0];
const myAddress = identity?.calendarAddress ?? (myEmail.includes("@") ? `mailto:${myEmail}` : "");
const myPlainEmail = myAddress.replace(/^mailto:/i, "");
// Free/busy lookup for attendees that are directory principals
useEffect(() => {
if (!attendees.length || !contacts.principalsLoaded) {
if (!contacts.principalsLoaded) void contacts.loadPrincipals();
return;
}
const dayStart = new Date(start);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
let cancelled = false;
(async () => {
const out: Record<string, BusyPeriod[]> = {};
for (const a of attendees) {
const p = contacts.principals.find((x) => x.email?.toLowerCase() === a.email.toLowerCase());
if (!p) continue;
try {
out[a.email] = await cal.availability(p.id, dayStart, dayEnd);
} catch {
/* ignore */
}
}
if (!cancelled) setFb(out);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attendees.map((a) => a.email).join(","), start.getTime(), contacts.principalsLoaded]);
const onStartChange = (d: Date) => {
if (Number.isNaN(d.getTime())) return;
const dur = end.getTime() - start.getTime();
setStart(d);
setEnd(new Date(d.getTime() + Math.max(dur, allDay ? DAY_MS : 15 * 60_000)));
};
const save = async () => {
if (!calendarId) {
toast.error("Choose a calendar");
return;
}
if (end <= start) {
toast.error("End must be after start");
return;
}
setBusy(true);
try {
const s = allDay ? new Date(start.getFullYear(), start.getMonth(), start.getDate()) : start;
let e = allDay ? new Date(end.getFullYear(), end.getMonth(), end.getDate()) : end;
if (allDay && e <= s) e = new Date(s.getTime() + DAY_MS);
const participants: Record<string, JSCalendarParticipant> = {};
if (attendees.length && myAddress) {
participants.me = { "@type": "Participant", name: identity?.name || undefined, email: myPlainEmail, sendTo: { imip: myAddress }, kind: "individual", roles: { owner: true, attendee: true }, participationStatus: "accepted", expectReply: false };
for (const a of attendees) {
// preserve existing status if the attendee was already there
const existing = Object.values(ev?.participants ?? {}).find((p) => (p.email ?? Object.values(p.sendTo ?? {})[0]?.replace(/^mailto:/i, ""))?.toLowerCase() === a.email.toLowerCase());
participants[newKey("p")] = { "@type": "Participant", name: a.name ?? undefined, email: a.email, sendTo: { imip: `mailto:${a.email}` }, kind: "individual", roles: { attendee: true }, participationStatus: existing?.participationStatus ?? "needs-action", expectReply: true };
}
}
const alertObj: Record<string, JSCalendarAlert> = {};
for (const m of alerts) alertObj[newKey("a")] = { "@type": "Alert", trigger: { "@type": "OffsetTrigger", offset: formatDuration(-m * 60), relativeTo: "start" }, action: "display" };
const obj: Record<string, unknown> = {
title: title.trim() || "(untitled)",
description: description.trim() || undefined,
showWithoutTime: allDay,
start: allDay ? `${toLocalDateOnly(s)}T00:00:00` : dateToZonedLocal(s, tz),
timeZone: allDay ? null : tz,
duration: formatDuration(Math.round((e.getTime() - s.getTime()) / 1000)),
locations: location.trim() ? { [newKey("l")]: { "@type": "Location", name: location.trim() } } : undefined,
virtualLocations: vurl.trim() ? { [newKey("v")]: { "@type": "VirtualLocation", uri: vurl.trim(), name: "Online meeting" } } : undefined,
participants: Object.keys(participants).length ? participants : undefined,
replyTo: Object.keys(participants).length && myAddress ? { imip: myAddress } : undefined,
alerts: Object.keys(alertObj).length ? alertObj : undefined,
useDefaultAlerts: false,
recurrenceRules: rule ? [rule] : undefined,
status,
privacy,
freeBusyStatus: freeBusy,
color: color ?? (category ? categories.find((c) => c.name === category)?.color : undefined),
categories: category ? { [category]: true } : undefined,
};
const invites = sendInvites && attendees.length > 0;
if (ev) {
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) patch[k] = v === undefined ? null : v;
if (Object.keys(ev.calendarIds)[0] !== calendarId) patch.calendarIds = { [calendarId]: true };
await cal.updateEvent(ev.id, patch, invites);
toast.success("Event updated");
} else {
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) if (v !== undefined) clean[k] = v;
await cal.createEvent(clean as Partial<CalendarEvent>, calendarId, invites);
toast.success(invites ? "Event created and invitations sent" : "Event created");
}
onClose();
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
const customRule = rule ?? { "@type": "RecurrenceRule", frequency: "weekly" as const };
const dayWindow = useMemo(() => {
const ds = new Date(start);
ds.setHours(0, 0, 0, 0);
return { ds, de: new Date(ds.getTime() + DAY_MS) };
}, [start]);
return (
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
<div className="event-form">
{init.event?.baseEventId && <div className="info-box mb-16">This is a recurring event changes apply to the whole series.</div>}
<div className="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder="Add title" autoFocus value={title} onChange={(e) => setTitle(e.target.value)} /></div>
<div className="time-row mb-8">
{allDay ? (
<>
<DateField aria-label="Starts" value={toLocalDateOnly(start)} onChange={(v) => v && onStartChange(new Date(`${v}T00:00:00`))} />
<span className="muted center">to</span>
<DateField aria-label="Ends" value={toLocalDateOnly(new Date(end.getTime() - 1))} onChange={(v) => v && setEnd(new Date(new Date(`${v}T00:00:00`).getTime() + DAY_MS))} />
</>
) : (
<>
<DateTimeField aria-label="Starts" value={toInputDateTime(start)} onChange={(v) => v && onStartChange(fromInputDateTime(v))} />
<span className="muted center">to</span>
<DateTimeField aria-label="Ends" value={toInputDateTime(end)} onChange={(v) => v && setEnd(fromInputDateTime(v))} />
</>
)}
</div>
<div className="row wrap" style={{ gap: 16, marginBottom: 8 }}>
<label className="check"><input type="checkbox" checked={allDay} onChange={(e) => { setAllDay(e.target.checked); if (e.target.checked) { const s = new Date(start); s.setHours(0, 0, 0, 0); setStart(s); setEnd(new Date(s.getTime() + Math.max(DAY_MS, Math.ceil((end.getTime() - s.getTime()) / DAY_MS) * DAY_MS))); } }} /> All day</label>
{!allDay && (
<select className="select" style={{ width: "auto", height: 32 }} value={tz} onChange={(e) => setTz(e.target.value)} title="Time zone">
{!listTimeZones().includes(tz) && <option value={tz}>{tz}</option>}
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
</select>
)}
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
<option value="none">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly on {formatWeekday(start, "long")}</option>
<option value="weekdays">Every weekday</option>
<option value="monthly">Monthly on day {start.getDate()}</option>
<option value="yearly">Yearly</option>
<option value="custom">Custom</option>
</select>
</div>
{preset === "custom" && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="row wrap" style={{ gap: 8 }}>
<span>Repeat every</span>
<input className="input" type="number" min={1} style={{ width: 70 }} value={customRule.interval ?? 1} onChange={(e) => setRule({ ...customRule, interval: Math.max(1, Number(e.target.value)) })} />
<select className="select" style={{ width: "auto" }} value={customRule.frequency} onChange={(e) => setRule({ ...customRule, frequency: e.target.value as JSCalendarRecurrenceRule["frequency"], byDay: e.target.value === "weekly" ? customRule.byDay : undefined, byMonthDay: e.target.value === "monthly" ? [start.getDate()] : undefined })}>
<option value="daily">day(s)</option><option value="weekly">week(s)</option><option value="monthly">month(s)</option><option value="yearly">year(s)</option>
</select>
</div>
{customRule.frequency === "weekly" && (
<div className="row" style={{ gap: 4, marginTop: 8 }}>
{WEEKDAYS.map((w) => {
const on = customRule.byDay?.some((d) => d.day === w.key);
return <button key={w.key} type="button" className={`btn btn-sm btn-pill ${on ? "btn-primary" : ""}`} style={{ width: 36, padding: 0 }} title={w.label} onClick={() => { const cur = customRule.byDay ?? []; const next: JSCalendarNDay[] = on ? cur.filter((d) => d.day !== w.key) : [...cur, { "@type": "NDay", day: w.key }]; setRule({ ...customRule, byDay: next.length ? next : undefined }); }}>{w.short}</button>;
})}
</div>
)}
<div className="row wrap" style={{ gap: 8, marginTop: 8 }}>
<span>Ends</span>
<select className="select" style={{ width: "auto" }} value={customRule.until ? "until" : customRule.count ? "count" : "never"} onChange={(e) => { const v = e.target.value; setRule({ ...customRule, until: v === "until" ? `${toLocalDateOnly(new Date(start.getTime() + 30 * DAY_MS))}T23:59:59` : undefined, count: v === "count" ? 10 : undefined }); }}>
<option value="never">never</option><option value="until">on date</option><option value="count">after N times</option>
</select>
{customRule.until && <DateField aria-label="Repeat until" className="w-auto" value={customRule.until.slice(0, 10)} onChange={(v) => v && setRule({ ...customRule, until: `${v}T23:59:59` })} />}
{customRule.count && <input className="input" type="number" min={1} style={{ width: 80 }} value={customRule.count} onChange={(e) => setRule({ ...customRule, count: Math.max(1, Number(e.target.value)) })} />}
</div>
<div className="hint mt-8">{describeRule(customRule)}</div>
</div>
)}
<div className="field-row">
<div className="field"><label>Calendar</label>
<select className="select" value={calendarId} onChange={(e) => setCalendarId(e.target.value)}>
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="field"><label>Location</label><input className="input" value={location} onChange={(e) => setLocation(e.target.value)} placeholder="Add location" /></div>
</div>
<div className="field"><label>Meeting link</label><input className="input" value={vurl} onChange={(e) => setVurl(e.target.value)} placeholder="https://meet.example.com/…" /></div>
<div className="field">
<label><Users size={13} /> Guests</label>
<div className="input" style={{ height: "auto", minHeight: 38, padding: "4px 8px" }}>
<RecipientInput value={attendees} onChange={setAttendees} placeholder="Add guests by name or email" />
</div>
{attendees.length > 0 && (
<>
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
{Object.keys(fb).length > 0 && (
<div className="freebusy">
<div className="hint">Availability on {formatNumericDate(start)}</div>
{attendees.filter((a) => fb[a.email]).map((a) => (
<div key={a.email} className="fb-row">
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
<div className="fb-bar">
{fb[a.email]!.map((b, i) => {
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime());
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime());
if (be <= bs) return null;
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${formatClock(new Date(b.utcStart))} ${formatClock(new Date(b.utcEnd))}`} />;
})}
{!allDay && <span className="fb-window" style={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />}
</div>
</div>
))}
</div>
)}
</>
)}
</div>
<div className="field"><label>Description</label><textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} /></div>
<div className="field">
<label>Reminders</label>
<div className="alerts-list">
{alerts.map((m, i) => (
<div key={i} className="row">
<select className="select" style={{ width: "auto" }} value={String(m)} onChange={(e) => setAlerts(alerts.map((x, j) => (j === i ? Number(e.target.value) : x)))}>
{[...new Set([...ALERT_OPTIONS, m])].sort((a, b) => a - b).map((o) => <option key={o} value={o}>{o === 0 ? "At time of event" : `${humanDuration(o * 60)} before`}</option>)}
</select>
<button className="icon-btn sm danger" onClick={() => setAlerts(alerts.filter((_, j) => j !== i))} aria-label="Remove reminder"><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAlerts([...alerts, 10])}><Plus size={14} /> Add reminder</button>
</div>
</div>
<button className="btn btn-ghost btn-sm" onClick={() => setShowMore((v) => !v)}>{showMore ? "Fewer options" : "More options"}</button>
{showMore && (
<div className="mt-8">
<div className="field-row">
<div className="field"><label>Status</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">Confirmed</option><option value="tentative">Tentative</option><option value="cancelled">Cancelled</option></select></div>
<div className="field"><label>Show as</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">Busy</option><option value="free">Free</option></select></div>
<div className="field"><label>Visibility</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">Default</option><option value="private">Private</option><option value="secret">Secret</option></select></div>
</div>
<div className="field"><label>Category</label>
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">None</option>
{categories.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
</div>
<div className="field"><label>Color</label><div className="row wrap"><ColorSwatches value={color} onChange={setColor} />{color && <button className="btn btn-ghost btn-sm" onClick={() => setColor(null)}>Use {category ? "category" : "calendar"} color</button>}</div></div>
</div>
)}
</div>
</Dialog>
);
}