Extract 515 strings by codemod, and the two bugs only a screenshot caught

Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy
itself, and a parser does not get bored. scripts/i18n-extract.mjs does the
mechanical part -- JSX text and the attributes a person actually reads -- and
refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left.

What it refuses matters as much as what it does. Text split around an
interpolation arrives as separate fragments, and wrapping each on its own
produces "Move " and " messages", which no translator can do anything with;
those are listed for a person to rebuild as sentences. So is anything
containing a double quote, which would end the literal.

Three things it had to be taught, each found by running it:

- <code>, <kbd> and <pre> are not prose. The first run wrapped `label:name`
  inside <code> -- a search operator, where translating it breaks the thing it
  documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
  it, so an import called `t` is shadowed inside those callbacks -- silently,
  wherever the local happens to be callable. The name is checked per file now
  and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
  `Language &amp; region` moved into t("...") and rendered the entity on screen.

That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language &amp; region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.

The codemod decodes entities now, and checks for a quote after decoding rather
than before.
This commit is contained in:
2026-08-31 09:58:33 -07:00
parent 95dcb96086
commit 8ea611f7f7
50 changed files with 900 additions and 713 deletions
+11 -10
View File
@@ -10,6 +10,7 @@ import { toast } from "@/ui/toast";
import { askDeleteScope, askEditScope, droppedMessage, runScoped } from "./scope";
import { toLocalDateOnly } from "@/lib/dates";
import { formatTime } from "@/lib/format";
import { t } from "@/lib/i18n";
export type CalendarContext =
| { kind: "event"; inst: EventInstance; anchor: Anchor }
@@ -50,10 +51,10 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
return (
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${formatDayMonth(start)}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label="New all-day event" onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label={t("New all-day event")} onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
<MenuSep />
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
<MenuItem icon={<CalIcon size={16} />} label="Go to week" onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
<MenuItem icon={<CalIcon size={16} />} label={t("Go to day")} onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
<MenuItem icon={<CalIcon size={16} />} label={t("Go to week")} onClick={() => navigate(`/calendar/week/${toLocalDateOnly(start)}`)} />
</Popover>
);
}
@@ -109,9 +110,9 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
return (
<Popover anchor={ctx.anchor} onClose={onClose} width={260} closeOnClick={false}>
<MenuItem icon={<ExternalLink size={16} />} label="Open" onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
{canEdit && <MenuItem icon={<Pencil size={16} />} label="Edit…" onClick={() => { onClose(); onEdit(inst); }} />}
{canEdit && <MenuItem icon={<Copy size={16} />} label="Duplicate" onClick={() => { onClose(); void duplicate(); }} />}
<MenuItem icon={<ExternalLink size={16} />} label={t("Open")} onClick={() => { onClose(); onOpen(inst, ctx.anchor); }} />
{canEdit && <MenuItem icon={<Pencil size={16} />} label={t("Edit…")} onClick={() => { onClose(); onEdit(inst); }} />}
{canEdit && <MenuItem icon={<Copy size={16} />} label={t("Duplicate")} onClick={() => { onClose(); void duplicate(); }} />}
{canEdit && (
<>
<MenuSep />
@@ -119,8 +120,8 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
{categories.map((c) => (
<MenuItem key={c.name} label={<span className="row gap-8"><span className="label-dot" style={{ background: c.color, width: 12, height: 12 }} />{c.name}</span>} checked={currentCat?.name === c.name} onClick={() => { onClose(); setCategory(currentCat?.name === c.name ? null : c); }} />
))}
<MenuItem icon={<X size={16} />} label="No category" disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
<MenuItem icon={<Tag size={16} />} label="Manage categories…" onClick={() => { onClose(); navigate("/settings/calendar"); }} />
<MenuItem icon={<X size={16} />} label={t("No category")} disabled={!currentCat} onClick={() => { onClose(); setCategory(null); }} />
<MenuItem icon={<Tag size={16} />} label={t("Manage categories…")} onClick={() => { onClose(); navigate("/settings/calendar"); }} />
{/*
A colour is what a category already carries, so a second way to set
one just made two things that could disagree. Picking a category is
@@ -131,9 +132,9 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
another client — would otherwise ignore its category for ever with
nothing on the menu to say why.
*/}
{ev.color && <MenuItem icon={<Palette size={16} />} label="Clear custom colour" onClick={() => { onClose(); setColor(null); }} />}
{ev.color && <MenuItem icon={<Palette size={16} />} label={t("Clear custom colour")} onClick={() => { onClose(); setColor(null); }} />}
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" onClick={() => void del()} />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} onClick={() => void del()} />
</>
)}
</Popover>
+10 -9
View File
@@ -5,6 +5,7 @@ import { Dialog } from "@/ui/dialog";
import { ColorSwatches } from "@/ui/misc";
import { toast } from "@/ui/toast";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { t as translate } from "@/lib/i18n";
export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calendar>; onClose: () => void }) {
const cal = useCalendar();
@@ -30,21 +31,21 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calend
}
};
return (
<Dialog open onClose={onClose} title={calendar.id ? "Edit calendar" : "New calendar"} size="sm" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>Save</button></>}>
<div className="field"><label>Name</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>Color</label><ColorSwatches value={color} onChange={setColor} /></div>
<div className="field"><label>Description</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
<div className="field"><label>Time zone</label>
<Dialog open onClose={onClose} title={calendar.id ? "Edit calendar" : "New calendar"} size="sm" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>{translate("Save")}</button></>}>
<div className="field"><label>{translate("Name")}</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
<div className="field"><label>{translate("Color")}</label><ColorSwatches value={color} onChange={setColor} /></div>
<div className="field"><label>{translate("Description")}</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
<div className="field"><label>{translate("Time zone")}</label>
<select className="select" value={tz} onChange={(e) => setTz(e.target.value)}>
<option value="">Default ({browserTimeZone})</option>
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="field"><label>Free/busy</label>
<div className="field"><label>{translate("Free/busy")}</label>
<select className="select" value={avail} onChange={(e) => setAvail(e.target.value as Calendar["includeInAvailability"])}>
<option value="all">Count all events as busy</option>
<option value="attending">Only events I'm attending</option>
<option value="none">Don't include in availability</option>
<option value="all">{translate("Count all events as busy")}</option>
<option value="attending">{translate("Only events I'm attending")}</option>
<option value="none">{translate("Don't include in availability")}</option>
</select>
</div>
</Dialog>
+18 -17
View File
@@ -12,6 +12,7 @@ import { toast } from "@/ui/toast";
import type { Calendar } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog";
import { t } from "@/lib/i18n";
export function CalendarSidebar() {
const [location, navigate] = useLocation();
@@ -45,9 +46,9 @@ export function CalendarSidebar() {
<div style={{ padding: "4px 8px" }}>
<div className="mini-cal">
<div className="mc-head">
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label="Previous month"><ChevronLeft size={16} /></button>
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label={t("Previous month")}><ChevronLeft size={16} /></button>
<span>{formatMonthYear(anchor)}</span>
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label="Next month"><ChevronRight size={16} /></button>
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label={t("Next month")}><ChevronRight size={16} /></button>
</div>
<div className="mc-grid">
{dow.map((d, i) => <div key={i} className="mc-dow">{d}</div>)}
@@ -59,16 +60,16 @@ export function CalendarSidebar() {
</div>
</div>
<div className="nav-section" style={{ paddingLeft: 4 }}>
<span>My calendars</span>
<button className="icon-btn" title="New calendar" onClick={() => setEditCal({})}><Plus size={16} /></button>
<span>{t("My calendars")}</span>
<button className="icon-btn" title={t("New calendar")} onClick={() => setEditCal({})}><Plus size={16} /></button>
</div>
{calendars.map((c) => (
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
<span className="cal-name">{c.name}</span>
{Object.keys(c.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label="Shared" />}
{Object.keys(c.shareWith ?? {}).length > 0 && <Share2 size={12} className="faint" aria-label={t("Shared")} />}
{c.isDefault && <Star size={12} className="faint" />}
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label={t("Calendar options")}><MoreVertical size={14} /></button>
</div>
))}
{/* Calendars other people shared, split by whether the reader has added
@@ -78,7 +79,7 @@ export function CalendarSidebar() {
and adding one is a deliberate act rather than a guess on our part. */}
{sharedSubscribed.length > 0 && (
<>
<div className="nav-section"><span>Shared with me</span></div>
<div className="nav-section"><span>{t("Shared with me")}</span></div>
{sharedSubscribed.map(({ accountId, accountName, calendar: c }) => {
const key = `${accountId}:${c.id}`;
return (
@@ -87,8 +88,8 @@ export function CalendarSidebar() {
<span className="cal-name">{c.name}</span>
<button
className="icon-btn xs nav-more"
title="Remove from my calendar"
aria-label="Remove from my calendar"
title={t("Remove from my calendar")}
aria-label={t("Remove from my calendar")}
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, false); }}
>
<X size={14} />
@@ -100,15 +101,15 @@ export function CalendarSidebar() {
)}
{sharedAvailable.length > 0 && (
<>
<div className="nav-section"><span>Available to add</span></div>
<div className="nav-section"><span>{t("Available to add")}</span></div>
{sharedAvailable.map(({ accountId, accountName, calendar: c }) => (
<div key={`${accountId}:${c.id}`} className="cal-list-item" title={`${c.name} — from ${accountName}`}>
<span className="cal-color" style={{ background: "transparent", borderColor: c.color ?? "var(--border-strong)" }} />
<span className="cal-name faint">{c.name}</span>
<button
className="icon-btn xs nav-more"
title="Add to my calendar"
aria-label="Add to my calendar"
title={t("Add to my calendar")}
aria-label={t("Add to my calendar")}
onClick={(e) => { e.stopPropagation(); void cal.setSharedSubscribed(accountId, c.id, true); }}
>
<Plus size={14} />
@@ -122,15 +123,15 @@ export function CalendarSidebar() {
{menuCal && (
<>
<MenuItem icon={cal.hidden[menuCal.id] ? <Eye size={16} /> : <EyeOff size={16} />} label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} />
<MenuItem icon={<Pencil size={16} />} label="Edit" onClick={() => setEditCal(menuCal)} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
<MenuItem icon={<Pencil size={16} />} label={t("Edit")} onClick={() => setEditCal(menuCal)} />
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
{/* Revoking every share at once, without walking the dialog and
removing people one at a time. Only offered when there is
something to revoke. */}
{Object.keys(menuCal.shareWith ?? {}).length > 0 && (
<MenuItem
icon={<UserMinus size={16} />}
label="Stop sharing"
label={t("Stop sharing")}
disabled={!menuCal.myRights.mayShare}
onClick={async () => {
const who = Object.keys(menuCal.shareWith ?? {}).length;
@@ -149,9 +150,9 @@ export function CalendarSidebar() {
}}
/>
)}
<MenuItem icon={<Star size={16} />} label="Make default" disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
<MenuItem icon={<Star size={16} />} label={t("Make default")} disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
<MenuSep />
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
</>
)}
</Popover>
+9 -8
View File
@@ -12,6 +12,7 @@ import { EventPopover } from "./EventPopover";
import { EventEditor, type EditorInit } from "./EventEditor";
import type { Anchor } from "@/ui/popover";
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu";
import { t as translate } from "@/lib/i18n";
type View = "month" | "week" | "day" | "agenda";
const HOUR_H = 48;
@@ -91,7 +92,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
);
if (!cal.available) {
return <div className="p-16"><Empty icon={<CalIcon size={40} />} title="Calendar is not available">This account does not have the JMAP calendars capability.</Empty></div>;
return <div className="p-16"><Empty icon={<CalIcon size={40} />} title={translate("Calendar is not available")}>{translate("This account does not have the JMAP calendars capability.")}</Empty></div>;
}
const title =
@@ -118,9 +119,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
return (
<div className="cal-main">
<div className="cal-toolbar">
<button className="btn btn-sm" onClick={() => go(view, new Date())}>Today</button>
<button className="icon-btn sm" onClick={() => step(-1)} aria-label="Previous"><ChevronLeft size={18} /></button>
<button className="icon-btn sm" onClick={() => step(1)} aria-label="Next"><ChevronRight size={18} /></button>
<button className="btn btn-sm" onClick={() => go(view, new Date())}>{translate("Today")}</button>
<button className="icon-btn sm" onClick={() => step(-1)} aria-label={translate("Previous")}><ChevronLeft size={18} /></button>
<button className="icon-btn sm" onClick={() => step(1)} aria-label={translate("Next")}><ChevronRight size={18} /></button>
<h2 className="truncate">{title}</h2>
<span className="spacer" />
{cal.loading && <span className="spinner" />}
@@ -136,7 +137,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
{(effectiveView === "week" || effectiveView === "day") && <TimeGrid days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />}
{effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />}
{isMobile && <button className="fab" aria-label="New event" onClick={() => openNew()}><Plus size={24} /></button>}
{isMobile && <button className="fab" aria-label={translate("New event")} onClick={() => openNew()}><Plus size={24} /></button>}
{popover && <EventPopover inst={popover.inst} anchor={popover.anchor} onClose={() => setPopover(null)} onEdit={() => { setEditor({ event: popover.inst.event, start: popover.inst.start, end: popover.inst.end, allDay: popover.inst.allDay }); setPopover(null); }} />}
{editor && <EventEditor init={editor} onClose={() => setEditor(null)} />}
</div>
@@ -250,7 +251,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
))}
</div>
<div className="week-allday">
<div className="ad-label">all-day</div>
<div className="ad-label">{translate("all-day")}</div>
{days.map((d) => (
<div key={d.toISOString()} className="ad-cell" onClick={() => onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}>
{allDay(d).map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
@@ -311,7 +312,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
})}
{drag && isSameDay(drag.day, d) && (
<div className="ev-block draft-new" style={{ top: (drag.startMin / 60) * HOUR_H, height: ((drag.endMin - drag.startMin) / 60) * HOUR_H, left: 0, width: "calc(100% - 3px)", background: "var(--accent)" }}>
<div className="ev-title">(new event)</div>
<div className="ev-title">{translate("(new event)")}</div>
<div className="ev-time">{formatTime(new Date(d.getTime() + drag.startMin * 60_000))} {formatTime(new Date(d.getTime() + drag.endMin * 60_000))}</div>
</div>
)}
@@ -394,7 +395,7 @@ function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent:
}
return [...map.values()].sort((a, b) => a.day.getTime() - b.day.getTime());
}, [instances, start, end]);
if (!byDay.length) return <Empty icon={<CalIcon size={36} />} title="Nothing scheduled">No events in the next 60 days.</Empty>;
if (!byDay.length) return <Empty icon={<CalIcon size={36} />} title={translate("Nothing scheduled")}>{translate("No events in the next 60 days.")}</Empty>;
return (
<div className="agenda">
{byDay.map(({ day, items }) => (
+34 -33
View File
@@ -15,6 +15,7 @@ import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
import { newKey } from "@/lib/contacts";
import { askEditScope, droppedMessage, runScoped } from "./scope";
import { t as translate } from "@/lib/i18n";
export interface EditorInit {
event?: CalendarEvent;
@@ -258,7 +259,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
}, [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></>}>
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>{translate("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">
{ev && isRecurring(ev) && (
<div className="info-box mb-16">
@@ -267,49 +268,49 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
: "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="field"><input className="input" style={{ fontSize: "1.1em", height: 44 }} placeholder={translate("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))} />
<DateField aria-label={translate("Starts")} value={toLocalDateOnly(start)} onChange={(v) => v && onStartChange(new Date(`${v}T00:00:00`))} />
<span className="muted center">{translate("to")}</span>
<DateField aria-label={translate("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))} />
<DateTimeField aria-label={translate("Starts")} value={toInputDateTime(start)} onChange={(v) => v && onStartChange(fromInputDateTime(v))} />
<span className="muted center">{translate("to")}</span>
<DateTimeField aria-label={translate("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">
<select className="select" style={{ width: "auto", height: 32 }} value={tz} onChange={(e) => setTz(e.target.value)} title={translate("Time zone")}>
{!listTimeZones().includes(tz) && <option value={tz}>{tz}</option>}
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
</select>
)}
{!oneDate && (
<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="none">{translate("Does not repeat")}</option>
<option value="daily">{translate("Daily")}</option>
<option value="weekly">Weekly on {formatWeekday(start, "long")}</option>
<option value="weekdays">Every weekday</option>
<option value="weekdays">{translate("Every weekday")}</option>
<option value="monthly">Monthly on day {start.getDate()}</option>
<option value="yearly">Yearly</option>
<option value="custom">Custom</option>
<option value="yearly">{translate("Yearly")}</option>
<option value="custom">{translate("Custom…")}</option>
</select>
)}
</div>
{!oneDate && preset === "custom" && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="row wrap" style={{ gap: 8 }}>
<span>Repeat every</span>
<span>{translate("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>
<option value="daily">{translate("day(s)")}</option><option value="weekly">{translate("week(s)")}</option><option value="monthly">{translate("month(s)")}</option><option value="yearly">{translate("year(s)")}</option>
</select>
</div>
{customRule.frequency === "weekly" && (
@@ -321,33 +322,33 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
</div>
)}
<div className="row wrap" style={{ gap: 8, marginTop: 8 }}>
<span>Ends</span>
<span>{translate("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>
<option value="never">{translate("never")}</option><option value="until">{translate("on date")}</option><option value="count">{translate("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.until && <DateField aria-label={translate("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>
<div className="field"><label>{translate("Calendar")}</label>
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? "An occurrence cannot be moved to another calendar on its own" : undefined} 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 className="field"><label>{translate("Location")}</label><input className="input" value={location} onChange={(e) => setLocation(e.target.value)} placeholder={translate("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>{translate("Meeting link")}</label><input className="input" value={vurl} onChange={(e) => setVurl(e.target.value)} placeholder={translate("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" />
<RecipientInput value={attendees} onChange={setAttendees} placeholder={translate("Add guests by name or email")} />
</div>
{attendees.length > 0 && (
<>
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
<Switch checked={sendInvites} onChange={setSendInvites} label={translate("Send invitation emails to guests")} />
{Object.keys(fb).length > 0 && (
<div className="freebusy">
<div className="hint">Availability on {formatNumericDate(start)}</div>
@@ -370,16 +371,16 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
</>
)}
</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>{translate("Description")}</label><textarea className="textarea" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} /></div>
<div className="field">
<label>Reminders</label>
<label>{translate("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>
<button className="icon-btn sm danger" onClick={() => setAlerts(alerts.filter((_, j) => j !== i))} aria-label={translate("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>
@@ -389,17 +390,17 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
{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>
{!oneDate && <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 className="field"><label>{translate("Status")}</label><select className="select" value={status} onChange={(e) => setStatus(e.target.value as typeof status)}><option value="confirmed">{translate("Confirmed")}</option><option value="tentative">{translate("Tentative")}</option><option value="cancelled">{translate("Cancelled")}</option></select></div>
<div className="field"><label>{translate("Show as")}</label><select className="select" value={freeBusy} onChange={(e) => setFreeBusy(e.target.value as typeof freeBusy)}><option value="busy">{translate("Busy")}</option><option value="free">{translate("Free")}</option></select></div>
{!oneDate && <div className="field"><label>{translate("Visibility")}</label><select className="select" value={privacy} onChange={(e) => setPrivacy(e.target.value as typeof privacy)}><option value="public">{translate("Default")}</option><option value="private">{translate("Private")}</option><option value="secret">{translate("Secret")}</option></select></div>}
</div>
<div className="field"><label>Category</label>
<div className="field"><label>{translate("Category")}</label>
<select className="select" value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">None</option>
<option value="">{translate("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 className="field"><label>{translate("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>
+8 -7
View File
@@ -10,6 +10,7 @@ import { describeRule } from "@/lib/recurrence";
import { useCompose } from "@/store/compose";
import { useSettings } from "@/store/settings";
import { categoryOf, eventColor } from "./CalendarContextMenu";
import { t } from "@/lib/i18n";
export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventInstance; anchor: Anchor; onClose: () => void; onEdit: () => void }) {
const cal = useCalendar();
@@ -67,9 +68,9 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
return (
<Popover anchor={anchor} onClose={onClose} className="event-popover" closeOnClick={false} side="right" role="dialog" style={{ "--ev-color": color } as React.CSSProperties}>
<div className="row" style={{ justifyContent: "flex-end", gap: 0, marginBottom: -4 }}>
{canEdit && <button className="icon-btn sm" title="Edit" onClick={onEdit}><Pencil size={16} /></button>}
{canEdit && <button className="icon-btn sm danger" title="Delete" onClick={() => void del()} disabled={busy}><Trash2 size={16} /></button>}
<button className="icon-btn sm" title="Close" onClick={onClose}><X size={16} /></button>
{canEdit && <button className="icon-btn sm" title={t("Edit")} onClick={onEdit}><Pencil size={16} /></button>}
{canEdit && <button className="icon-btn sm danger" title={t("Delete")} onClick={() => void del()} disabled={busy}><Trash2 size={16} /></button>}
<button className="icon-btn sm" title={t("Close")} onClick={onClose}><X size={16} /></button>
</div>
<h3>{ev.title || "(untitled)"}</h3>
<div className="ev-line"><Clock size={15} /><span>{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone && !inst.allDay ? <span className="hint"> · {ev.timeZone}</span> : null}</span></div>
@@ -82,14 +83,14 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
<div className="ev-line"><CalIcon size={15} /><span>{`${inst.calendar?.name ?? "Calendar"}${ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}${ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}${ev.freeBusyStatus === "free" ? " · shown as free" : ""}`}</span></div>
{participants.length > 0 && (
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
<div className="row gap-8"><Users size={15} /><span>{`${participants.length} participant${participants.length === 1 ? "" : "s"}`}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
<div className="row gap-8"><Users size={15} /><span>{`${participants.length} participant${participants.length === 1 ? "" : "s"}`}</span><button className="icon-btn xs" title={t("Email everyone")} onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
{participants.map(([k, p]) => (
<div key={k} className="participant-row">
<span className={`p-status ${p.participationStatus ?? "needs-action"}`} title={p.participationStatus ?? "needs-action"} />
<span className="truncate">{p.name || participantEmail(p)}</span>
{p.roles?.owner && <span className="hint">organizer</span>}
{p.roles?.optional && <span className="hint">optional</span>}
{p.roles?.owner && <span className="hint">{t("organizer")}</span>}
{p.roles?.optional && <span className="hint">{t("optional")}</span>}
</div>
))}
</div>
@@ -97,7 +98,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
)}
{myKeys.length > 0 && !isOrganizer && (
<div className="row" style={{ marginTop: 10, gap: 6 }}>
<span className="hint">Going?</span>
<span className="hint">{t("Going?")}</span>
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("accepted")}><Check size={14} /> Yes</button>
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("tentative")}><HelpCircle size={14} /> Maybe</button>
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={busy} onClick={() => void rsvp("declined")}><X size={14} /> No</button>