From 6381f0221f5543fa7afd90706360b7c97e599da1 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:41:33 +0000 Subject: [PATCH] fix(library): honor browser clock for cron schedules Use the browser's hour cycle when its language matches the selected interface language, preserving regional time preferences. Closes #1559 --- .../library/utils/__tests__/cron.spec.ts | 39 ++++++++++++++++++- client/src/features/library/utils/cron.ts | 6 ++- client/src/stores/__tests__/locale.spec.ts | 1 + 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/client/src/features/library/utils/__tests__/cron.spec.ts b/client/src/features/library/utils/__tests__/cron.spec.ts index c82d86357..3b7e60553 100644 --- a/client/src/features/library/utils/__tests__/cron.spec.ts +++ b/client/src/features/library/utils/__tests__/cron.spec.ts @@ -1,8 +1,21 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { isFiveFieldCronExpression } from '@bookorbit/types' import { parseCronToHuman } from '../cron' +function mockBrowserClock(locale: string, hour12: boolean): void { + const NativeDateTimeFormat = Intl.DateTimeFormat + vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function dateTimeFormat(requestedLocale, options) { + const formatter = new NativeDateTimeFormat(requestedLocale, options) + if (requestedLocale === undefined) { + vi.spyOn(formatter, 'resolvedOptions').mockReturnValue({ ...formatter.resolvedOptions(), locale, hour12 }) + } + return formatter + }) +} + +afterEach(() => vi.restoreAllMocks()) + describe('isFiveFieldCronExpression', () => { it.each(['0 4 * * *', '*/30 * * * *', '0 0 * * 1', '0 4 * * MON', '0 4 * * *', '1-5/2 * * * *', '0 4 * jan-mar *'])( 'accepts %s and previews it', @@ -21,6 +34,30 @@ describe('isFiveFieldCronExpression', () => { }) describe('parseCronToHuman', () => { + it('uses the browser clock for an English regional preference', () => { + mockBrowserClock('en-GB', false) + + expect(parseCronToHuman('31 12,0 * * *', 'en')).toBe('At 00:31 and 12:31') + }) + + it('preserves a 12-hour English browser preference', () => { + mockBrowserClock('en-US', true) + + expect(parseCronToHuman('31 12,0 * * *', 'en')).toBe('At 12:31 AM and 12:31 PM') + }) + + it('uses the selected language convention when it differs from the browser language', () => { + mockBrowserClock('en-US', true) + + expect(parseCronToHuman('31 12,0 * * *', 'nl')).toBe('At 00:31 and 12:31') + }) + + it('preserves the English convention with a Dutch browser language', () => { + mockBrowserClock('nl-BE', false) + + expect(parseCronToHuman('31 12,0 * * *', 'en')).toBe('At 12:31 AM and 12:31 PM') + }) + it('returns null for null input', () => { expect(parseCronToHuman(null, 'en')).toBeNull() }) diff --git a/client/src/features/library/utils/cron.ts b/client/src/features/library/utils/cron.ts index 82f3b6f75..b0abc5bf1 100644 --- a/client/src/features/library/utils/cron.ts +++ b/client/src/features/library/utils/cron.ts @@ -3,7 +3,11 @@ import cronstrue from 'cronstrue' export function parseCronToHuman(cron: string | null | undefined, locale: string): string | null { if (!cron) return null try { - const use24HourTimeFormat = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 === false + const browserTimeFormat = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions() + const localeTimeFormat = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions() + const browserLanguage = new Intl.Locale(browserTimeFormat.locale).language + const localeLanguage = new Intl.Locale(localeTimeFormat.locale).language + const use24HourTimeFormat = (browserLanguage === localeLanguage ? browserTimeFormat : localeTimeFormat).hour12 === false return cronstrue.toString(cron, { use24HourTimeFormat, verbose: false }) } catch { return cron diff --git a/client/src/stores/__tests__/locale.spec.ts b/client/src/stores/__tests__/locale.spec.ts index 73d44d37f..41bb1259a 100644 --- a/client/src/stores/__tests__/locale.spec.ts +++ b/client/src/stores/__tests__/locale.spec.ts @@ -29,6 +29,7 @@ describe('locale store', () => { it('matches exact locales before falling back to a supported base language', async () => { const { matchSupportedLocale } = await import('../locale') + expect(matchSupportedLocale(['en', 'en-GB', 'en-US'])).toBe('en') expect(matchSupportedLocale(['nl-NL', 'en-US'])).toBe('nl') expect(matchSupportedLocale(['de-DE', 'en-GB'])).toBe('de') expect(matchSupportedLocale(['el-GR', 'en-GB'])).toBe('el')