authorgravatar for paul@brzz.devPaul <paul@brzz.dev> 2026-08-15 13:25:54+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:25:54+00:00
logb72d5d79889ad9b4c022060be9eccdfa64733d18
tree20ce98a99c4b77e8aee9f8299d17dace677a762d
parent31d0fb6ca7e31e5505d4e69063cb50d1e59ca1dc
signature Signed by PGP key B5690EEEBB952194

fix(desktop): timestamps follow the OS locale instead of en-US (#6190)


13 files changed, 131 insertions(+), 1 deletions(-)

apps/desktop/src/app/DesktopAppIdentity.test.ts+1
......@@ -40,6 +40,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
4040 Layer.succeed(ElectronApp.ElectronApp, {
4141 metadata: Effect.die("unexpected metadata read"),
4242 name: Effect.succeed("T3 Code"),
43 systemLocale: Effect.succeed("en-US"),
4344 whenReady: Effect.void,
4445 quit: Effect.void,
4546 exit: () => Effect.void,
apps/desktop/src/app/DesktopLifecycle.test.ts+1
......@@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => {
2121 const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
2222 metadata: Effect.die("unexpected metadata read"),
2323 name: Effect.succeed("T3 Code"),
24 systemLocale: Effect.succeed("en-US"),
2425 whenReady: Effect.void,
2526 quit: Effect.void,
2627 exit: () => Effect.void,
apps/desktop/src/electron/ElectronApp.test.ts+20
......@@ -8,6 +8,7 @@ const {
88 autoUpdaterRemoveListenerMock,
99 exitMock,
1010 getAppPathMock,
11 getSystemLocaleMock,
1112 getVersionMock,
1213 isDefaultProtocolClientMock,
1314 onMock,
......@@ -29,6 +30,7 @@ const {
2930 autoUpdaterRemoveListenerMock: vi.fn(),
3031 exitMock: vi.fn(),
3132 getAppPathMock: vi.fn(() => "/app"),
33 getSystemLocaleMock: vi.fn(() => "en-GB"),
3234 getVersionMock: vi.fn(() => "1.2.3"),
3335 isDefaultProtocolClientMock: vi.fn(() => false),
3436 onMock: vi.fn(),
......@@ -60,6 +62,7 @@ vi.mock("electron", () => ({
6062 setIcon: setDockIconMock,
6163 },
6264 getAppPath: getAppPathMock,
65 getSystemLocale: getSystemLocaleMock,
6366 getVersion: getVersionMock,
6467 isDefaultProtocolClient: isDefaultProtocolClientMock,
6568 isPackaged: true,
......@@ -111,6 +114,23 @@ describe("ElectronApp", () => {
111114 }).pipe(Effect.provide(ElectronApp.layer)),
112115 );
113116
117 it.effect("reads the OS locale through the service", () =>
118 Effect.gen(function* () {
119 const electronApp = yield* ElectronApp.ElectronApp;
120
121 assert.strictEqual(yield* electronApp.systemLocale, "en-GB");
122 }).pipe(Effect.provide(ElectronApp.layer)),
123 );
124
125 it.effect("normalizes POSIX-style locale identifiers that Intl rejects", () =>
126 Effect.gen(function* () {
127 getSystemLocaleMock.mockImplementationOnce(() => "en_GB");
128 const electronApp = yield* ElectronApp.ElectronApp;
129
130 assert.strictEqual(yield* electronApp.systemLocale, "en-GB");
131 }).pipe(Effect.provide(ElectronApp.layer)),
132 );
133
114134 it.effect("reports which app metadata property failed", () =>
115135 Effect.gen(function* () {
116136 const cause = new Error("version unavailable");
apps/desktop/src/electron/ElectronApp.ts+11
......@@ -43,6 +43,13 @@ export class ElectronApp extends Context.Service<
4343 {
4444 readonly metadata: Effect.Effect<ElectronAppMetadata, ElectronAppMetadataReadError>;
4545 readonly name: Effect.Effect<string>;
46 /**
47 * The OS locale, read from the operating system rather than from Chromium's
48 * resolved application locale — the packaged app ships only the `en-US`
49 * locale pak, so `app.getLocale()` and the renderer's `Intl` default are
50 * pinned to `en-US` however the machine is configured.
51 */
52 readonly systemLocale: Effect.Effect<string>;
4653 readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>;
4754 readonly quit: Effect.Effect<void>;
4855 readonly exit: (code: number) => Effect.Effect<void>;
......@@ -119,6 +126,10 @@ export const make = ElectronApp.of({
119126 };
120127 }),
121128 name: Effect.sync(() => Electron.app.name),
129 // macOS derives this from NSLocale, which uses POSIX-style identifiers
130 // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so
131 // the tag is normalized here rather than in the renderer that consumes it.
132 systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")),
122133 whenReady: Effect.gen(function* () {
123134 const isPackaged = Electron.app.isPackaged;
124135 yield* Effect.tryPromise({
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
......@@ -34,6 +34,7 @@ import {
3434 getAppBranding,
3535 getLocalEnvironmentBootstraps,
3636 getLocalEnvironmentBearerToken,
37 getSystemLocale,
3738 getWindowFullscreenState,
3839 openExternal,
3940 probeRemoteEditors,
......@@ -50,6 +51,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
5051 yield* PreviewIpc.installPreviewEventForwarding();
5152
5253 yield* ipc.handleSync(getAppBranding);
54 yield* ipc.handleSync(getSystemLocale);
5355 yield* ipc.handleSync(getWindowFullscreenState);
5456 yield* ipc.handleSync(getLocalEnvironmentBootstraps);
5557 yield* ipc.handle(getLocalEnvironmentBearerToken);
apps/desktop/src/ipc/channels.ts+1
......@@ -15,6 +15,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1515export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
1616export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1717export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
18export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
1819export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
1920export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
2021 "desktop:get-local-environment-bearer-token";
apps/desktop/src/ipc/methods/window.ts+10
......@@ -26,6 +26,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
2626import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
2727import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
2828import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
29import * as ElectronApp from "../../electron/ElectronApp.ts";
2930import * as ElectronDialog from "../../electron/ElectronDialog.ts";
3031import * as ElectronMenu from "../../electron/ElectronMenu.ts";
3132import * as ElectronShell from "../../electron/ElectronShell.ts";
......@@ -64,6 +65,15 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({
6465 }),
6566});
6667
68export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({
69 channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL,
70 result: Schema.String,
71 handler: Effect.fn("desktop.ipc.window.getSystemLocale")(function* () {
72 const electronApp = yield* ElectronApp.ElectronApp;
73 return yield* electronApp.systemLocale;
74 }),
75});
76
6777export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
6878 channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,
6979 result: Schema.Boolean,
apps/desktop/src/preload.ts+4
......@@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
3535 }
3636 return result as ReturnType<DesktopBridge["getAppBranding"]>;
3737 },
38 getSystemLocale: () => {
39 const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
40 return typeof result === "string" ? result : null;
41 },
3842 getLocalEnvironmentBootstraps: () => {
3943 const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL);
4044 if (!Array.isArray(result)) {
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+1
......@@ -26,6 +26,7 @@ function makeElectronAppLayer(
2626 return Layer.succeed(ElectronApp.ElectronApp, {
2727 metadata: Effect.die("unexpected metadata read"),
2828 name: Effect.succeed("T3 Code"),
29 systemLocale: Effect.succeed("en-US"),
2930 whenReady: Effect.void,
3031 quit: Effect.void,
3132 exit: () => Effect.void,
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1
......@@ -31,6 +31,7 @@ const environmentInput = {
3131const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
3232 metadata: Effect.die("unexpected metadata read"),
3333 name: Effect.succeed("T3 Code"),
34 systemLocale: Effect.succeed("en-US"),
3435 whenReady: Effect.void,
3536 quit: Effect.void,
3637 exit: () => Effect.void,
apps/web/src/timestampFormat.test.ts+35
......@@ -12,6 +12,7 @@ import {
1212 formatTimestamp,
1313 getRelativeTimeState,
1414 getTimestampFormatOptions,
15 resolveTimestampLocale,
1516} from "./timestampFormat";
1617
1718describe("getTimestampFormatOptions", () => {
......@@ -41,6 +42,40 @@ describe("getTimestampFormatOptions", () => {
4142 });
4243});
4344
45describe("resolveTimestampLocale", () => {
46 it("defers to the runtime default when the host reports no locale", () => {
47 expect(resolveTimestampLocale(null)).toBeUndefined();
48 expect(resolveTimestampLocale(undefined)).toBeUndefined();
49 expect(resolveTimestampLocale(" ")).toBeUndefined();
50 });
51
52 it("uses a BCP-47 tag reported by the host", () => {
53 expect(resolveTimestampLocale("en-GB")).toBe("en-GB");
54 });
55
56 it("defers to the runtime default rather than throwing on an unusable tag", () => {
57 // The desktop bridge normalizes POSIX identifiers before reporting them, so
58 // anything Intl still rejects here falls back instead of breaking every
59 // timestamp in the UI.
60 expect(resolveTimestampLocale("not a locale")).toBeUndefined();
61 expect(resolveTimestampLocale("en_GB")).toBeUndefined();
62 });
63
64 it("renders the host locale's hour cycle under the locale setting", () => {
65 const formatAt1544 = (systemLocale: string | null) =>
66 new Intl.DateTimeFormat(resolveTimestampLocale(systemLocale), {
67 ...getTimestampFormatOptions("locale", false),
68 timeZone: "UTC",
69 })
70 .format(new Date("2026-04-07T15:44:00.000Z"))
71 // ICU separates the day period with a narrow no-break space.
72 .replace(/[  ]/g, " ");
73
74 expect(formatAt1544("en-GB")).toBe("15:44");
75 expect(formatAt1544("en-US")).toBe("3:44 PM");
76 });
77});
78
4479describe("formatRelativeTimeUntilLabel", () => {
4580 beforeEach(() => {
4681 vi.useFakeTimers();
apps/web/src/timestampFormat.ts+37-1
......@@ -20,6 +20,39 @@ export function getTimestampFormatOptions(
2020 };
2121}
2222
23/**
24 * Pick the locale to format wall-clock times in, given the locale the host
25 * reports. Hosts that report nothing fall back to `undefined`, which is the
26 * runtime default and the right answer in a browser.
27 *
28 * A host reports a locale only when it knows better than the runtime does —
29 * see `getSystemLocale` on the desktop bridge for why desktop does.
30 */
31export function resolveTimestampLocale(
32 systemLocale: string | null | undefined,
33): string | undefined {
34 const tag = systemLocale?.trim();
35 if (!tag) return undefined;
36
37 try {
38 // Every timestamp in the UI runs through this formatter, so a tag the host
39 // could not normalize falls back rather than throwing. Throws on a
40 // structurally invalid tag; a well-formed tag ICU has no data for resolves
41 // here and is left to ICU's own fallback.
42 Intl.DateTimeFormat.supportedLocalesOf([tag]);
43 return tag;
44 } catch {
45 return undefined;
46 }
47}
48
49function readHostSystemLocale(): string | null {
50 if (typeof window === "undefined") return null;
51 return window.desktopBridge?.getSystemLocale?.() ?? null;
52}
53
54const timestampLocale = resolveTimestampLocale(readHostSystemLocale());
55
2356const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
2457
2558function getTimestampFormatter(
......@@ -33,7 +66,7 @@ function getTimestampFormatter(
3366 }
3467
3568 const formatter = new Intl.DateTimeFormat(
36 undefined,
69 timestampLocale,
3770 getTimestampFormatOptions(timestampFormat, includeSeconds),
3871 );
3972 timestampFormatterCache.set(cacheKey, formatter);
......@@ -51,6 +84,9 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma
5184 return getTimestampFormatter(timestampFormat, true).format(date);
5285}
5386
87// Deliberately not the host locale: the tooltip's ordinal suffix and
88// day-before-month order below are English, so a localized month alone would
89// read "4th Juni 2026". Localizing the whole label is a separate change.
5490const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });
5591
5692function ordinalSuffix(day: number): string {
packages/contracts/src/ipc.ts+7
......@@ -1021,6 +1021,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({
10211021
10221022export interface DesktopBridge {
10231023 getAppBranding: () => DesktopAppBranding | null;
1024 /**
1025 * The OS locale as a BCP-47 tag, which the renderer cannot read for itself:
1026 * the packaged app ships only the `en-US` Chromium locale pak, so
1027 * `navigator.language` and the default `Intl` locale are pinned to `en-US`
1028 * regardless of OS settings.
1029 */
1030 getSystemLocale?: () => string | null;
10241031 // One bootstrap per pool instance currently registered with bootstrap
10251032 // info (omits instances whose backend hasn't produced a config yet).
10261033 // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID.