diff --git a/web/src/components/DateTime.rail.spec.ts b/web/src/components/DateTime.rail.spec.ts index cf3f11522a..62f61747d5 100644 --- a/web/src/components/DateTime.rail.spec.ts +++ b/web/src/components/DateTime.rail.spec.ts @@ -195,7 +195,6 @@ describe("DateTime rail", () => { expect(el("datetime-timezone-select-trigger")?.textContent).toContain( "Asia/Calcutta (UTC+05:30)", ); - expect(el("date-time-cancel-btn")).toBeNull(); expect(el("date-time-apply-btn")).not.toBeNull(); }); diff --git a/web/src/components/DateTime.vue b/web/src/components/DateTime.vue index bcd00a5c08..91259b98ce 100644 --- a/web/src/components/DateTime.vue +++ b/web/src/components/DateTime.vue @@ -485,6 +485,9 @@ interface ConsumableDateTime { userChangedValue?: boolean; } +// One offset lookup per zone for the whole app, not per picker instance. +const zoneOffsets = new Map(); + export default defineComponent({ components: { OSeparator, @@ -680,8 +683,6 @@ export default defineComponent({ const filteredTimezone: any = ref(timezoneOptions); const isTimezoneSelectOpen = ref(false); - const zoneOffsets = new Map(); - // longOffset throws on engines that lack it, so the label falls back to no offset. const zoneOffset = (zone: string): string => { const cached = zoneOffsets.get(zone); diff --git a/web/src/components/DateTimeRail.vue b/web/src/components/DateTimeRail.vue index 4356ef9fbb..13badb1c29 100644 --- a/web/src/components/DateTimeRail.vue +++ b/web/src/components/DateTimeRail.vue @@ -258,9 +258,9 @@ const tabStop = computed(() => : focusOrder.value[0], ); -// The accessible name must contain the visible compact label. const hasCalendar = computed(() => props.calendar !== null && props.calendar.length > 0); +// The accessible name must contain the visible compact label. const rowName = (item: TimeRangeRailItem) => item.compact ? `${item.compact.label} (${item.label})` : item.label; diff --git a/web/src/utils/timeRangeRail.spec.ts b/web/src/utils/timeRangeRail.spec.ts index d8729eb8a4..9ed28d4bc4 100644 --- a/web/src/utils/timeRangeRail.spec.ts +++ b/web/src/utils/timeRangeRail.spec.ts @@ -120,12 +120,31 @@ describe("timeRangeRail", () => { }); }); + describe("locale", () => { + it("keeps each locale's word order for recent labels and hints", () => { + const start = us("2026-10-02T08:00:00Z"); + const end = us("2026-10-03T09:00:00Z"); + expect( + formatRecentRangeLabel(start, end, "UTC", Date.parse("2026-11-04T12:00:00Z"), "de-DE"), + ).toBe("2.–3. Okt."); + expect( + formatCalendarRailHint( + "calendar:month:0", + "UTC", + Date.parse("2026-11-04T12:00:00Z"), + "fr-FR", + ), + ).toBe("nov."); + }); + }); + describe("formatRecentDuration", () => { const dur = (seconds: number) => formatRecentDuration(0, seconds * 1_000_000); it("reads a range ending at :59 as inclusive, so a whole day is 1d", () => { expect(dur(86_399)).toBe("1d"); expect(dur(2 * 86_400 - 1)).toBe("2d"); + expect(formatRecentDuration(30_000_000, 89_000_000)).toBe("59s"); }); it("keeps the two largest non-zero units", () => { @@ -158,8 +177,8 @@ describe("timeRangeRail", () => { }); it("shows the whole Monday-to-Sunday week, not the elapsed part", () => { - expect(hint("calendar:week:0")).toBe("Nov 2–8"); - expect(hint("calendar:week:-1")).toBe("Oct 26–Nov 1"); + expect(hint("calendar:week:0")).toBe("Nov 2 – 8"); + expect(hint("calendar:week:-1")).toBe("Oct 26 – Nov 1"); }); it("shows the month name and the quarter number", () => { @@ -173,7 +192,7 @@ describe("timeRangeRail", () => { const jan = Date.parse("2026-01-07T12:00:00Z"); expect(hint("calendar:day:-1", "UTC", jan)).toBe("Jan 6"); expect(hint("calendar:week:-1", "UTC", Date.parse("2026-01-05T12:00:00Z"))).toBe( - "Dec 29, 2025–Jan 4, 2026", + "Dec 29, 2025 – Jan 4, 2026", ); expect(hint("calendar:month:-1", "UTC", jan)).toBe("Dec 2025"); expect(hint("calendar:quarter:-1", "UTC", jan)).toBe("Q4 2025"); diff --git a/web/src/utils/timeRangeRail.ts b/web/src/utils/timeRangeRail.ts index 70ae70b96e..efdb53c1ef 100644 --- a/web/src/utils/timeRangeRail.ts +++ b/web/src/utils/timeRangeRail.ts @@ -52,7 +52,7 @@ export interface TimeRangeRailItem { tooltip: I18nText | undefined; selected: boolean; disabled: boolean; - /** Recent rows only: the compact bold label and the duration chip shown instead of the full range. */ + /** Recent rows only: the compact label and the duration chip shown instead of the full range. */ compact?: { label: string; duration: string }; /** Calendar rows only: the muted date hint shown beside the label. */ hint?: string; @@ -96,17 +96,17 @@ export const formatRecentRange = ( return raw(`${start.date} ${start.time} - ${end.date} ${end.time}`); }; -const monthNames = new Map(); - -// Month names follow the picker's locale, like the sibling older-period labels. -const monthName = (locale: string, month: number): string => { - let names = monthNames.get(locale); - if (!names) { - const format = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" }); - names = Array.from({ length: 12 }, (_, m) => format.format(Date.UTC(2026, m, 1))); - monthNames.set(locale, names); - } - return names[month]; +// Intl keeps each locale's word order, as the sibling older-period labels do; thin spaces become plain ones. +const formatCivilDays = (locale: string, first: Date, last: Date, withYear: boolean): string => { + const format = new Intl.DateTimeFormat(locale, { + month: "short", + day: "numeric", + ...(withYear ? { year: "numeric" } : {}), + timeZone: "UTC", + }); + const text = + first.getTime() === last.getTime() ? format.format(first) : format.formatRange(first, last); + return text.replace(/[\u2009\u202f]/g, " "); }; const civilParts = (date: string): { year: number; month: number; day: number } => { @@ -135,22 +135,22 @@ export const formatRecentRangeLabel = ( const { start, end } = splitRecentRange(startUs, endUs, zone); const from = civilParts(start.date); const to = civilParts(end.date); - const withYear = - from.year !== currentYearIn(nowMs, zone) || to.year !== currentYearIn(nowMs, zone); - const day = (p: typeof from) => - `${monthName(locale, p.month)} ${p.day}${withYear ? `, ${p.year}` : ""}`; - if (from.year === to.year && from.month === to.month) { - if (from.day === to.day) return `${day(from)}${sameDayTimes(start.time, end.time)}`; - return `${monthName(locale, from.month)} ${from.day} – ${to.day}${withYear ? `, ${from.year}` : ""}`; - } - return `${day(from)} – ${day(to)}`; + const year = currentYearIn(nowMs, zone); + const days = formatCivilDays( + locale, + new Date(Date.UTC(from.year, from.month, from.day)), + new Date(Date.UTC(to.year, to.month, to.day)), + from.year !== year || to.year !== year, + ); + const sameDay = from.year === to.year && from.month === to.month && from.day === to.day; + return sameDay ? `${days}${sameDayTimes(start.time, end.time)}` : days; }; /** The two largest non-zero units of d/h/m/s in the span, e.g. "1d 15m", "45m", "30s". */ export const formatRecentDuration = (startUs: number, endUs: number): string => { const spanSeconds = Math.max(0, Math.floor((endUs - startUs) / MICROS_PER_SECOND)); // A range ending at :59 is inclusive of that minute, so a whole day reads 1d, not 23h 59m. - const total = spanSeconds % 60 === 59 ? spanSeconds + 1 : spanSeconds; + const total = Math.floor(endUs / MICROS_PER_SECOND) % 60 === 59 ? spanSeconds + 1 : spanSeconds; const units: [string, number][] = [ ["d", Math.floor(total / 86_400)], ["h", Math.floor((total % 86_400) / 3600)], @@ -174,21 +174,16 @@ export const formatCalendarRailHint = ( const { unit, first, last, today } = span; const currentYear = today.getUTCFullYear(); const withYear = first.getUTCFullYear() !== currentYear || last.getUTCFullYear() !== currentYear; - const day = (d: Date) => - `${monthName(locale, d.getUTCMonth())} ${d.getUTCDate()}${withYear ? `, ${d.getUTCFullYear()}` : ""}`; const yearSuffix = withYear ? ` ${first.getUTCFullYear()}` : ""; - if (unit === "day") return day(first); - if (unit === "month") return `${monthName(locale, first.getUTCMonth())}${yearSuffix}`; - if (unit === "quarter") - return `${quarterText(Math.floor(first.getUTCMonth() / 3) + 1)}${yearSuffix}`; - if (unit === "year") return String(first.getUTCFullYear()); - if ( - first.getUTCFullYear() === last.getUTCFullYear() && - first.getUTCMonth() === last.getUTCMonth() - ) { - return `${monthName(locale, first.getUTCMonth())} ${first.getUTCDate()}–${last.getUTCDate()}${withYear ? `, ${first.getUTCFullYear()}` : ""}`; + if (unit === "day" || unit === "week") return formatCivilDays(locale, first, last, withYear); + if (unit === "month") { + const month = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" }); + return `${month.format(first)}${yearSuffix}`; } - return `${day(first)}–${day(last)}`; + if (unit === "quarter") { + return `${quarterText(Math.floor(first.getUTCMonth() / 3) + 1)}${yearSuffix}`; + } + return String(first.getUTCFullYear()); }; /** Whether a range is longer than the page's query-range limit; 0 means no limit. */