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) =>...@@ -40,6 +40,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
40 Layer.succeed(ElectronApp.ElectronApp, {40 Layer.succeed(ElectronApp.ElectronApp, {
41 metadata: Effect.die("unexpected metadata read"),41 metadata: Effect.die("unexpected metadata read"),
42 name: Effect.succeed("T3 Code"),42 name: Effect.succeed("T3 Code"),
43 systemLocale: Effect.succeed("en-US"),
43 whenReady: Effect.void,44 whenReady: Effect.void,
44 quit: Effect.void,45 quit: Effect.void,
45 exit: () => Effect.void,46 exit: () => Effect.void,
apps/desktop/src/app/DesktopLifecycle.test.ts+1
...@@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => {...@@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => {
21 const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {21 const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
22 metadata: Effect.die("unexpected metadata read"),22 metadata: Effect.die("unexpected metadata read"),
23 name: Effect.succeed("T3 Code"),23 name: Effect.succeed("T3 Code"),
24 systemLocale: Effect.succeed("en-US"),
24 whenReady: Effect.void,25 whenReady: Effect.void,
25 quit: Effect.void,26 quit: Effect.void,
26 exit: () => Effect.void,27 exit: () => Effect.void,
apps/desktop/src/electron/ElectronApp.test.ts+20
...@@ -8,6 +8,7 @@ const {...@@ -8,6 +8,7 @@ const {
8 autoUpdaterRemoveListenerMock,8 autoUpdaterRemoveListenerMock,
9 exitMock,9 exitMock,
10 getAppPathMock,10 getAppPathMock,
11 getSystemLocaleMock,
11 getVersionMock,12 getVersionMock,
12 isDefaultProtocolClientMock,13 isDefaultProtocolClientMock,
13 onMock,14 onMock,
...@@ -29,6 +30,7 @@ const {...@@ -29,6 +30,7 @@ const {
29 autoUpdaterRemoveListenerMock: vi.fn(),30 autoUpdaterRemoveListenerMock: vi.fn(),
30 exitMock: vi.fn(),31 exitMock: vi.fn(),
31 getAppPathMock: vi.fn(() => "/app"),32 getAppPathMock: vi.fn(() => "/app"),
33 getSystemLocaleMock: vi.fn(() => "en-GB"),
32 getVersionMock: vi.fn(() => "1.2.3"),34 getVersionMock: vi.fn(() => "1.2.3"),
33 isDefaultProtocolClientMock: vi.fn(() => false),35 isDefaultProtocolClientMock: vi.fn(() => false),
34 onMock: vi.fn(),36 onMock: vi.fn(),
...@@ -60,6 +62,7 @@ vi.mock("electron", () => ({...@@ -60,6 +62,7 @@ vi.mock("electron", () => ({
60 setIcon: setDockIconMock,62 setIcon: setDockIconMock,
61 },63 },
62 getAppPath: getAppPathMock,64 getAppPath: getAppPathMock,
65 getSystemLocale: getSystemLocaleMock,
63 getVersion: getVersionMock,66 getVersion: getVersionMock,
64 isDefaultProtocolClient: isDefaultProtocolClientMock,67 isDefaultProtocolClient: isDefaultProtocolClientMock,
65 isPackaged: true,68 isPackaged: true,
...@@ -111,6 +114,23 @@ describe("ElectronApp", () => {...@@ -111,6 +114,23 @@ describe("ElectronApp", () => {
111 }).pipe(Effect.provide(ElectronApp.layer)),114 }).pipe(Effect.provide(ElectronApp.layer)),
112 );115 );
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
114 it.effect("reports which app metadata property failed", () =>134 it.effect("reports which app metadata property failed", () =>
115 Effect.gen(function* () {135 Effect.gen(function* () {
116 const cause = new Error("version unavailable");136 const cause = new Error("version unavailable");
apps/desktop/src/electron/ElectronApp.ts+11
...@@ -43,6 +43,13 @@ export class ElectronApp extends Context.Service<...@@ -43,6 +43,13 @@ export class ElectronApp extends Context.Service<
43 {43 {
44 readonly metadata: Effect.Effect<ElectronAppMetadata, ElectronAppMetadataReadError>;44 readonly metadata: Effect.Effect<ElectronAppMetadata, ElectronAppMetadataReadError>;
45 readonly name: Effect.Effect<string>;45 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>;
46 readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>;53 readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>;
47 readonly quit: Effect.Effect<void>;54 readonly quit: Effect.Effect<void>;
48 readonly exit: (code: number) => Effect.Effect<void>;55 readonly exit: (code: number) => Effect.Effect<void>;
...@@ -119,6 +126,10 @@ export const make = ElectronApp.of({...@@ -119,6 +126,10 @@ export const make = ElectronApp.of({
119 };126 };
120 }),127 }),
121 name: Effect.sync(() => Electron.app.name),128 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, "-")),
122 whenReady: Effect.gen(function* () {133 whenReady: Effect.gen(function* () {
123 const isPackaged = Electron.app.isPackaged;134 const isPackaged = Electron.app.isPackaged;
124 yield* Effect.tryPromise({135 yield* Effect.tryPromise({
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 getAppBranding,34 getAppBranding,
35 getLocalEnvironmentBootstraps,35 getLocalEnvironmentBootstraps,
36 getLocalEnvironmentBearerToken,36 getLocalEnvironmentBearerToken,
37 getSystemLocale,
37 getWindowFullscreenState,38 getWindowFullscreenState,
38 openExternal,39 openExternal,
39 probeRemoteEditors,40 probeRemoteEditors,
...@@ -50,6 +51,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -50,6 +51,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
50 yield* PreviewIpc.installPreviewEventForwarding();51 yield* PreviewIpc.installPreviewEventForwarding();
5152
52 yield* ipc.handleSync(getAppBranding);53 yield* ipc.handleSync(getAppBranding);
54 yield* ipc.handleSync(getSystemLocale);
53 yield* ipc.handleSync(getWindowFullscreenState);55 yield* ipc.handleSync(getWindowFullscreenState);
54 yield* ipc.handleSync(getLocalEnvironmentBootstraps);56 yield* ipc.handleSync(getLocalEnvironmentBootstraps);
55 yield* ipc.handle(getLocalEnvironmentBearerToken);57 yield* ipc.handle(getLocalEnvironmentBearerToken);
apps/desktop/src/ipc/channels.ts+1
...@@ -15,6 +15,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";...@@ -15,6 +15,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
15export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";15export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
16export const UPDATE_CHECK_CHANNEL = "desktop:update-check";16export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
17export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";17export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
18export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
18export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";19export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
19export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =20export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
20 "desktop:get-local-environment-bearer-token";21 "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";...@@ -26,6 +26,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
26import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";26import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
27import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";27import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
28import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";28import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
29import * as ElectronApp from "../../electron/ElectronApp.ts";
29import * as ElectronDialog from "../../electron/ElectronDialog.ts";30import * as ElectronDialog from "../../electron/ElectronDialog.ts";
30import * as ElectronMenu from "../../electron/ElectronMenu.ts";31import * as ElectronMenu from "../../electron/ElectronMenu.ts";
31import * as ElectronShell from "../../electron/ElectronShell.ts";32import * as ElectronShell from "../../electron/ElectronShell.ts";
...@@ -64,6 +65,15 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({...@@ -64,6 +65,15 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({
64 }),65 }),
65});66});
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
67export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({77export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({
68 channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,78 channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL,
69 result: Schema.Boolean,79 result: Schema.Boolean,
apps/desktop/src/preload.ts+4
...@@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
35 }35 }
36 return result as ReturnType<DesktopBridge["getAppBranding"]>;36 return result as ReturnType<DesktopBridge["getAppBranding"]>;
37 },37 },
38 getSystemLocale: () => {
39 const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
40 return typeof result === "string" ? result : null;
41 },
38 getLocalEnvironmentBootstraps: () => {42 getLocalEnvironmentBootstraps: () => {
39 const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL);43 const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL);
40 if (!Array.isArray(result)) {44 if (!Array.isArray(result)) {
apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts+1
...@@ -26,6 +26,7 @@ function makeElectronAppLayer(...@@ -26,6 +26,7 @@ function makeElectronAppLayer(
26 return Layer.succeed(ElectronApp.ElectronApp, {26 return Layer.succeed(ElectronApp.ElectronApp, {
27 metadata: Effect.die("unexpected metadata read"),27 metadata: Effect.die("unexpected metadata read"),
28 name: Effect.succeed("T3 Code"),28 name: Effect.succeed("T3 Code"),
29 systemLocale: Effect.succeed("en-US"),
29 whenReady: Effect.void,30 whenReady: Effect.void,
30 quit: Effect.void,31 quit: Effect.void,
31 exit: () => Effect.void,32 exit: () => Effect.void,
apps/desktop/src/window/DesktopApplicationMenu.test.ts+1
...@@ -31,6 +31,7 @@ const environmentInput = {...@@ -31,6 +31,7 @@ const environmentInput = {
31const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {31const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
32 metadata: Effect.die("unexpected metadata read"),32 metadata: Effect.die("unexpected metadata read"),
33 name: Effect.succeed("T3 Code"),33 name: Effect.succeed("T3 Code"),
34 systemLocale: Effect.succeed("en-US"),
34 whenReady: Effect.void,35 whenReady: Effect.void,
35 quit: Effect.void,36 quit: Effect.void,
36 exit: () => Effect.void,37 exit: () => Effect.void,
apps/web/src/timestampFormat.test.ts+35
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 formatTimestamp,12 formatTimestamp,
13 getRelativeTimeState,13 getRelativeTimeState,
14 getTimestampFormatOptions,14 getTimestampFormatOptions,
15 resolveTimestampLocale,
15} from "./timestampFormat";16} from "./timestampFormat";
1617
17describe("getTimestampFormatOptions", () => {18describe("getTimestampFormatOptions", () => {
...@@ -41,6 +42,40 @@ describe("getTimestampFormatOptions", () => {...@@ -41,6 +42,40 @@ describe("getTimestampFormatOptions", () => {
41 });42 });
42});43});
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
44describe("formatRelativeTimeUntilLabel", () => {79describe("formatRelativeTimeUntilLabel", () => {
45 beforeEach(() => {80 beforeEach(() => {
46 vi.useFakeTimers();81 vi.useFakeTimers();
apps/web/src/timestampFormat.ts+37-1
...@@ -20,6 +20,39 @@ export function getTimestampFormatOptions(...@@ -20,6 +20,39 @@ export function getTimestampFormatOptions(
20 };20 };
21}21}
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
23const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();56const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
2457
25function getTimestampFormatter(58function getTimestampFormatter(
...@@ -33,7 +66,7 @@ function getTimestampFormatter(...@@ -33,7 +66,7 @@ function getTimestampFormatter(
33 }66 }
3467
35 const formatter = new Intl.DateTimeFormat(68 const formatter = new Intl.DateTimeFormat(
36 undefined,69 timestampLocale,
37 getTimestampFormatOptions(timestampFormat, includeSeconds),70 getTimestampFormatOptions(timestampFormat, includeSeconds),
38 );71 );
39 timestampFormatterCache.set(cacheKey, formatter);72 timestampFormatterCache.set(cacheKey, formatter);
...@@ -51,6 +84,9 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma...@@ -51,6 +84,9 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma
51 return getTimestampFormatter(timestampFormat, true).format(date);84 return getTimestampFormatter(timestampFormat, true).format(date);
52}85}
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.
54const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });90const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });
5591
56function ordinalSuffix(day: number): string {92function ordinalSuffix(day: number): string {
packages/contracts/src/ipc.ts+7
...@@ -1021,6 +1021,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({...@@ -1021,6 +1021,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({
10211021
1022export interface DesktopBridge {1022export interface DesktopBridge {
1023 getAppBranding: () => DesktopAppBranding | null;1023 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;
1024 // One bootstrap per pool instance currently registered with bootstrap1031 // One bootstrap per pool instance currently registered with bootstrap
1025 // info (omits instances whose backend hasn't produced a config yet).1032 // info (omits instances whose backend hasn't produced a config yet).
1026 // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID.1033 // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID.