diff --git a/apps/web/src/components/Sidebar.snooze.test.ts b/apps/web/src/components/Sidebar.snooze.test.ts index 16e17e4217eb..ef776e09a815 100644 --- a/apps/web/src/components/Sidebar.snooze.test.ts +++ b/apps/web/src/components/Sidebar.snooze.test.ts @@ -10,7 +10,7 @@ function localDate(year: number, month: number, day: number, hour: number, minut describe("resolveSnoozePresets", () => { it("offers one hour, three hours, evening, tomorrow, and next week in the morning", () => { // Wednesday 2026-04-08 10:00 local. - const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale"); + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale", "en-GB"); expect(presets.map((preset) => preset.id)).toEqual([ "hour", "three-hours", @@ -33,7 +33,7 @@ describe("resolveSnoozePresets", () => { }); it("whenLabel complements the label instead of repeating it", () => { - const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale"); + const presets = resolveSnoozePresets(localDate(2026, 4, 8, 10), "locale", "en-GB"); for (const preset of presets) { // Day words live in the label column; the time column is time-only // (plus a weekday for next week, which names a different day). @@ -47,16 +47,18 @@ describe("resolveSnoozePresets", () => { it("drops the evening preset once evening is near or past", () => { expect( - resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale").map((preset) => preset.id), + resolveSnoozePresets(localDate(2026, 4, 8, 17, 30), "locale", "en-GB").map( + (preset) => preset.id, + ), ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); expect( - resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale").map((preset) => preset.id), + resolveSnoozePresets(localDate(2026, 4, 8, 21), "locale", "en-GB").map((preset) => preset.id), ).toEqual(["hour", "three-hours", "tomorrow", "next-week"]); }); it("puts next week a full week out when today is Monday", () => { // Monday 2026-04-06. - const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale"); + const presets = resolveSnoozePresets(localDate(2026, 4, 6, 10), "locale", "en-GB"); const nextWeek = new Date(presets.find((preset) => preset.id === "next-week")!.snoozedUntil); expect(nextWeek.getDay()).toBe(1); expect(nextWeek.getDate()).toBe(13); @@ -93,4 +95,16 @@ describe("snoozeWakeDescription", () => { "18:00", ); }); + + it("uses the client locale for weekday and calendar-date labels", () => { + const weekdayWake = localDate(2026, 4, 13, 9); + const dateWake = localDate(2026, 4, 20, 9); + + expect(snoozeWakeDescription(weekdayWake.toISOString(), now, "locale", "de-DE")).toContain( + weekdayWake.toLocaleDateString("de-DE", { weekday: "short" }), + ); + expect(snoozeWakeDescription(dateWake.toISOString(), now, "locale", "de-DE")).toContain( + dateWake.toLocaleDateString("de-DE", { month: "short", day: "numeric" }), + ); + }); }); diff --git a/apps/web/src/components/Sidebar.snooze.ts b/apps/web/src/components/Sidebar.snooze.ts index e7b980279a4b..d417ec295edd 100644 --- a/apps/web/src/components/Sidebar.snooze.ts +++ b/apps/web/src/components/Sidebar.snooze.ts @@ -5,7 +5,7 @@ import { type SnoozePreset, } from "@t3tools/client-runtime/state/thread-settled"; -import { formatShortTimestamp, parseTimestampDate } from "../timestampFormat"; +import { formatShortTimestamp, getTimestampLocale, parseTimestampDate } from "../timestampFormat"; export { snoozeWakeLabel, type SnoozePreset }; @@ -18,8 +18,9 @@ function timeOfDayLabel(date: Date, timestampFormat: TimestampFormat): string { export function resolveSnoozePresets( now: Date, timestampFormat: TimestampFormat, + locale: string | undefined = getTimestampLocale(), ): ReadonlyArray { - return resolveSharedSnoozePresets(now).map((preset) => { + return resolveSharedSnoozePresets(now, locale).map((preset) => { const wake = parseTimestampDate(preset.snoozedUntil); if (wake === null) return preset; const time = timeOfDayLabel(wake, timestampFormat); @@ -27,7 +28,7 @@ export function resolveSnoozePresets( ...preset, whenLabel: preset.id === "next-week" - ? `${wake.toLocaleDateString(undefined, { weekday: "short" })} ${time}` + ? `${wake.toLocaleDateString(locale, { weekday: "short" })} ${time}` : time, }; }); @@ -41,6 +42,7 @@ export function snoozeWakeDescription( snoozedUntil: string, now: Date, timestampFormat: TimestampFormat, + locale: string | undefined = getTimestampLocale(), ): string { const wake = parseTimestampDate(snoozedUntil); if (wake === null) return ""; @@ -50,8 +52,8 @@ export function snoozeWakeDescription( const dayDelta = Math.floor((wake.getTime() - startOfToday.getTime()) / DAY_MS); if (dayDelta === 0) return time; if (dayDelta === 1) return `tomorrow ${time}`; - const weekday = wake.toLocaleDateString(undefined, { weekday: "short" }); + const weekday = wake.toLocaleDateString(locale, { weekday: "short" }); if (dayDelta < 7) return `${weekday} ${time}`; - const date = wake.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + const date = wake.toLocaleDateString(locale, { month: "short", day: "numeric" }); return `${date}, ${time}`; } diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 9dd463bb50fa..81dbf2a14182 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -53,6 +53,10 @@ function readHostSystemLocale(): string | null { const timestampLocale = resolveTimestampLocale(readHostSystemLocale()); +export function getTimestampLocale(): string | undefined { + return timestampLocale; +} + const timestampFormatterCache = new Map(); function getTimestampFormatter( diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index f5209a09e499..796dd20163a7 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -202,14 +202,32 @@ function addSnoozeDays(base: Date, days: number): Date { return next; } +type LocaleWeekInfo = { readonly firstDay: number }; +type LocaleWithWeekInfo = Intl.Locale & { + readonly weekInfo?: LocaleWeekInfo; + getWeekInfo?: () => LocaleWeekInfo; +}; + +function firstDayOfWeek(locale: string | undefined): number { + try { + const resolvedLocale = new Intl.Locale( + locale ?? Intl.DateTimeFormat().resolvedOptions().locale, + ) as LocaleWithWeekInfo; + const firstDay = resolvedLocale.getWeekInfo?.().firstDay ?? resolvedLocale.weekInfo?.firstDay; + return firstDay === undefined ? 1 : firstDay % 7; + } catch { + return 1; + } +} + /** * Shared "snooze until" choices for every client. "This evening" only * appears while it is meaningfully before evening; after that the calendar - * choices start at "Tomorrow". Calendar presets that land on the same - * instant collapse: on Sundays "Tomorrow" and "Next week" are both Monday - * morning, so only "Tomorrow" is offered. + * choices start at "Tomorrow". "Next week" follows the locale's first + * weekday. Calendar presets that land on the same instant collapse, so only + * "Tomorrow" is offered when tomorrow starts the next locale week. */ -export function resolveSnoozePresets(now: Date): ReadonlyArray { +export function resolveSnoozePresets(now: Date, locale?: string): ReadonlyArray { const inAnHour = new Date(now.getTime() + HOUR_MS); const inThreeHours = new Date(now.getTime() + 3 * HOUR_MS); const presets: SnoozePreset[] = [ @@ -245,8 +263,9 @@ export function resolveSnoozePresets(now: Date): ReadonlyArray { snoozedUntil: tomorrow.toISOString(), }); - const daysUntilMonday = (1 - now.getDay() + 7) % 7 || 7; - const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilMonday), MORNING_HOUR); + const weekStart = firstDayOfWeek(locale); + const daysUntilNextWeek = (weekStart - now.getDay() + 7) % 7 || 7; + const nextWeek = snoozeAtHour(addSnoozeDays(now, daysUntilNextWeek), MORNING_HOUR); if (nextWeek.getTime() !== tomorrow.getTime()) { presets.push({ id: "next-week", diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 8a62103950bf..fde9a5ab7261 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -349,18 +349,23 @@ describe("resolveSnoozePresets", () => { ]); }); - it("puts next week on the following Monday", () => { - const nextWeek = new Date( - resolveSnoozePresets(localDate(2026, 4, 6, 10)).find((preset) => preset.id === "next-week")! - .snoozedUntil, + it("puts next week on the locale's first weekday", () => { + const now = localDate(2026, 4, 6, 10); + const sundayStart = new Date( + resolveSnoozePresets(now, "en-US").find((preset) => preset.id === "next-week")!.snoozedUntil, ); - expect(nextWeek.getDay()).toBe(1); - expect(nextWeek.getDate()).toBe(13); + const mondayStart = new Date( + resolveSnoozePresets(now, "en-GB").find((preset) => preset.id === "next-week")!.snoozedUntil, + ); + + expect(sundayStart.getDay()).toBe(0); + expect(sundayStart.getDate()).toBe(12); + expect(mondayStart.getDay()).toBe(1); + expect(mondayStart.getDate()).toBe(13); }); - it("drops next week on Sundays, when it lands on the same Monday as tomorrow", () => { - // Sunday 2026-08-30 07:01: "Tomorrow" and "Next week" are both Monday 9:00. - const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7, 1)); + it("drops next week when it lands on the same day as tomorrow", () => { + const presets = resolveSnoozePresets(localDate(2026, 8, 30, 7, 1), "en-GB"); expect(presets.map((preset) => preset.id)).toEqual([ "hour", "three-hours",