Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by three settings (Settings > General > Locale): - Language & region: automatic, or any of the 618 locales CLDR has data for, each named in its own language and script (web/src/lib/locales.ts, generated by probing Intl over the subtag space). - Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025, or ISO 8601 2025-11-22. - Time format: automatic (locale), 24-hour, or 12-hour. Automatic takes the locale Stalwart has for the account, read best-effort at login via x:Account/get (urn:stalwart:jmap) and passed to the client in the session; servers without the capability, or that deny sysAccountGet to a regular user, fall back to the browser locale. POSIX forms are normalised (de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS, uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are dropped and a script the locale already implies is not appended. Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so one line never mixes digit systems. Rewired: message list and headers, quoted reply headers, calendar (titles, weekday and hour gutters, mini calendar, agenda, popovers, invite cards, free/busy), contacts, files, sessions. No raw toLocale*String date calls are left in web/src. Native <input type="datetime-local"> pickers always follow the browser locale and cannot be restyled by a page, so the out-of-office fields echo the entered instant in the chosen format underneath. Also: month-grid day labels no longer wrap when they hold a date, and the mock server serves x:Account/get (MOCK_LOCALE, default en_US). Closes #1
This commit is contained in:
@@ -3,6 +3,7 @@ import { useLocation } from "wouter";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
import { useCalendar, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { formatDayMonth } from "@/lib/datetime";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
@@ -48,7 +49,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
const { start, end, allDay } = ctx;
|
||||
return (
|
||||
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
|
||||
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
|
||||
<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); }} />}
|
||||
<MenuSep />
|
||||
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useMemo, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
import { formatWeekday } from "@/lib/datetime";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
@@ -16,6 +17,7 @@ export function CalendarSidebar() {
|
||||
const [location, navigate] = useLocation();
|
||||
const cal = useCalendar();
|
||||
const weekStart = useSettings((s) => s.settings.weekStart);
|
||||
const locale = useSettings((s) => dateTimeKey(s.settings));
|
||||
const parts = location.split("/");
|
||||
const view = parts[2] || "week";
|
||||
const dateStr = parts[3];
|
||||
@@ -27,10 +29,7 @@ export function CalendarSidebar() {
|
||||
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
|
||||
const [share, setShare] = useState<Calendar | null>(null);
|
||||
const instances = cal.instancesIn(grid[0]!, new Date(grid[41]!.getTime() + 86400000));
|
||||
const dow = useMemo(() => {
|
||||
const names = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
return [...Array(7)].map((_, i) => names[(weekStart + i) % 7]);
|
||||
}, [weekStart]);
|
||||
const dow = useMemo(() => grid.slice(0, 7).map((d) => formatWeekday(d, "narrow")), [grid, locale]);
|
||||
|
||||
if (!cal.available) return null;
|
||||
const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCalendar, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
||||
import { formatMonthYear, formatTime } from "@/lib/format";
|
||||
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
|
||||
import { Empty, useIsMobile } from "@/ui/misc";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { EventPopover } from "./EventPopover";
|
||||
@@ -95,9 +96,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
||||
|
||||
const title =
|
||||
effectiveView === "month" ? formatMonthYear(anchor)
|
||||
: effectiveView === "week" ? `${range.start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${addDays(range.end, -1).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`
|
||||
: effectiveView === "day" ? anchor.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
||||
: `Agenda from ${anchor.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
|
||||
: effectiveView === "week" ? `${formatDayMonth(range.start)} – ${formatDate(addDays(range.end, -1))}`
|
||||
: effectiveView === "day" ? formatWeekdayDate(anchor, true)
|
||||
: `Agenda from ${formatDayMonth(anchor)}`;
|
||||
|
||||
const onEvent = (inst: EventInstance, el: Element) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
@@ -152,7 +153,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
||||
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
||||
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
|
||||
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
||||
const dow = weeks[0]!.map((d) => d.toLocaleDateString(undefined, { weekday: "short" }));
|
||||
const dow = weeks[0]!.map((d) => formatWeekday(d));
|
||||
const maxPer = 4;
|
||||
return (
|
||||
<div className="month-grid">
|
||||
@@ -165,7 +166,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
||||
const shown = evs.slice(0, maxPer);
|
||||
return (
|
||||
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
|
||||
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.getDate()}</span>
|
||||
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
|
||||
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
|
||||
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more</span>}
|
||||
</div>
|
||||
@@ -244,7 +245,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
||||
<div />
|
||||
{days.map((d) => (
|
||||
<div key={d.toISOString()} className={`wh-day ${isToday(d) ? "today" : ""}`} onClick={() => onDayHeader(d)}>
|
||||
<div className="dow">{d.toLocaleDateString(undefined, { weekday: "short" })}</div>
|
||||
<div className="dow">{formatWeekday(d)}</div>
|
||||
<div className="dnum">{d.getDate()}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -260,7 +261,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
||||
<div className="week-scroll" ref={scrollRef}>
|
||||
<div className="week-body" style={{ "--hour-h": `${HOUR_H}px` } as React.CSSProperties}>
|
||||
<div className="time-col">
|
||||
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: "numeric" })}</span>)}
|
||||
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{formatHourLabel(h)}</span>)}
|
||||
</div>
|
||||
{days.map((d) => {
|
||||
const evs = layoutOverlaps(timed(d), d);
|
||||
@@ -400,8 +401,8 @@ function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent:
|
||||
{byDay.map(({ day, items }) => (
|
||||
<div key={day.toISOString()} className="agenda-day">
|
||||
<div className={`ad-date ${isToday(day) ? "today" : ""}`}>
|
||||
{day.toLocaleDateString(undefined, { weekday: "long" })}
|
||||
<small>{day.toLocaleDateString(undefined, { month: "long", day: "numeric" })}</small>
|
||||
{formatWeekday(day, "long")}
|
||||
<small>{formatDateLong(day, false)}</small>
|
||||
</div>
|
||||
<div>
|
||||
{items.map((i) => (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ColorSwatches, Switch } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { RecipientInput } from "../compose/RecipientInput";
|
||||
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";
|
||||
|
||||
@@ -228,7 +229,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
<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 {start.toLocaleDateString(undefined, { weekday: "long" })}</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>
|
||||
@@ -282,7 +283,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
|
||||
{Object.keys(fb).length > 0 && (
|
||||
<div className="freebusy">
|
||||
<div className="hint">Availability on {start.toLocaleDateString()}</div>
|
||||
<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>
|
||||
@@ -291,7 +292,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
||||
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}: ${new Date(b.utcStart).toLocaleTimeString()} – ${new Date(b.utcEnd).toLocaleTimeString()}`} />;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user