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:
@@ -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