diff --git a/client/src/features/library/components/LibraryCreatorSchedule.vue b/client/src/features/library/components/LibraryCreatorSchedule.vue index 1a994aa4b..5bfad9361 100644 --- a/client/src/features/library/components/LibraryCreatorSchedule.vue +++ b/client/src/features/library/components/LibraryCreatorSchedule.vue @@ -2,11 +2,11 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { Eye } from '@lucide/vue' -import cronstrue from 'cronstrue' import { isFiveFieldCronExpression } from '@bookorbit/types' import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' +import { parseCronToHuman } from '@/features/library/utils/cron' -const { t } = useI18n() +const { t, locale } = useI18n() const props = defineProps<{ watch: boolean @@ -70,11 +70,8 @@ function humanReadableCron(cron: string | null): string { '0 0 * * 1': t('library.creator.schedule.human.weeklyMonday'), } if (map[cron]) return map[cron] - try { - return cronstrue.toString(cron) - } catch { - return t('library.creator.schedule.human.invalid') - } + const description = parseCronToHuman(cron, locale.value) + return !description || description === cron ? t('library.creator.schedule.human.invalid') : description } diff --git a/client/src/features/library/utils/__tests__/cron.spec.ts b/client/src/features/library/utils/__tests__/cron.spec.ts index bb0fd3b5f..c82d86357 100644 --- a/client/src/features/library/utils/__tests__/cron.spec.ts +++ b/client/src/features/library/utils/__tests__/cron.spec.ts @@ -8,7 +8,7 @@ describe('isFiveFieldCronExpression', () => { 'accepts %s and previews it', (expression) => { expect(isFiveFieldCronExpression(expression)).toBe(true) - expect(parseCronToHuman(expression)).not.toBe(expression) + expect(parseCronToHuman(expression, 'en')).not.toBe(expression) }, ) @@ -22,48 +22,55 @@ describe('isFiveFieldCronExpression', () => { describe('parseCronToHuman', () => { it('returns null for null input', () => { - expect(parseCronToHuman(null)).toBeNull() + expect(parseCronToHuman(null, 'en')).toBeNull() }) it('returns null for undefined input', () => { - expect(parseCronToHuman(undefined)).toBeNull() + expect(parseCronToHuman(undefined, 'en')).toBeNull() }) it('returns null for empty string', () => { - expect(parseCronToHuman('')).toBeNull() + expect(parseCronToHuman('', 'en')).toBeNull() }) - it('parses a daily cron to a human-readable string', () => { - const result = parseCronToHuman('0 2 * * *') + it('uses a 12-hour clock for locales that prefer it', () => { + const result = parseCronToHuman('17 14 * * *', 'en') expect(result).not.toBeNull() - expect(result!.toLowerCase()).toContain('02:00 am') + expect(result!.toLowerCase()).toContain('02:17 pm') + }) + + it('uses a 24-hour clock for locales that prefer it', () => { + const result = parseCronToHuman('17 14 * * *', 'nl') + expect(result).not.toBeNull() + expect(result).toContain('14:17') + expect(result).not.toMatch(/am|pm/i) }) it('parses an every-5-minutes cron', () => { - const result = parseCronToHuman('*/5 * * * *') + const result = parseCronToHuman('*/5 * * * *', 'en') expect(result).not.toBeNull() expect(result!.toLowerCase()).toContain('every 5 minutes') }) it('parses a weekly cron', () => { - const result = parseCronToHuman('0 0 * * 1') + const result = parseCronToHuman('0 0 * * 1', 'en') expect(result).not.toBeNull() expect(result!.toLowerCase()).toContain('monday') }) it('falls back to the raw cron string on invalid input', () => { const garbage = 'not-a-cron-at-all' - expect(parseCronToHuman(garbage)).toBe(garbage) + expect(parseCronToHuman(garbage, 'en')).toBe(garbage) }) it('parses a monthly cron', () => { - const result = parseCronToHuman('0 0 1 * *') + const result = parseCronToHuman('0 0 1 * *', 'en') expect(result).not.toBeNull() expect(result!.toLowerCase()).toContain('day 1') }) it('parses an hourly cron', () => { - const result = parseCronToHuman('0 * * * *') + const result = parseCronToHuman('0 * * * *', 'en') expect(result).not.toBeNull() expect(result!.toLowerCase()).toContain('every hour') }) diff --git a/client/src/features/library/utils/cron.ts b/client/src/features/library/utils/cron.ts index cda3a7ac6..82f3b6f75 100644 --- a/client/src/features/library/utils/cron.ts +++ b/client/src/features/library/utils/cron.ts @@ -1,9 +1,10 @@ import cronstrue from 'cronstrue' -export function parseCronToHuman(cron: string | null | undefined): string | null { +export function parseCronToHuman(cron: string | null | undefined, locale: string): string | null { if (!cron) return null try { - return cronstrue.toString(cron, { use24HourTimeFormat: false, verbose: false }) + const use24HourTimeFormat = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 === false + return cronstrue.toString(cron, { use24HourTimeFormat, verbose: false }) } catch { return cron } diff --git a/client/src/features/settings/libraries/components/LibraryAutomationList.vue b/client/src/features/settings/libraries/components/LibraryAutomationList.vue index 8123dbe39..d81df40e0 100644 --- a/client/src/features/settings/libraries/components/LibraryAutomationList.vue +++ b/client/src/features/settings/libraries/components/LibraryAutomationList.vue @@ -7,14 +7,14 @@ import { parseCronToHuman } from '@/features/library/utils/cron' const props = defineProps<{ library: Library }>() -const { t } = useI18n() +const { t, locale } = useI18n() /** * Every setting renders in both states with its real value, so a schedule reads as its actual * cron description rather than an icon, and "off" is stated rather than left out. */ const rows = computed(() => { - const schedule = parseCronToHuman(props.library.autoScanCronExpression) + const schedule = parseCronToHuman(props.library.autoScanCronExpression, locale.value) const off = t('settings.admin.libraries.stateOff') const on = t('settings.admin.libraries.stateOn') return [ diff --git a/client/src/features/settings/libraries/components/LibraryCapabilityStrip.vue b/client/src/features/settings/libraries/components/LibraryCapabilityStrip.vue index 297b2ab02..98cc74b28 100644 --- a/client/src/features/settings/libraries/components/LibraryCapabilityStrip.vue +++ b/client/src/features/settings/libraries/components/LibraryCapabilityStrip.vue @@ -7,14 +7,14 @@ import { parseCronToHuman } from '@/features/library/utils/cron' const props = defineProps<{ library: Library }>() -const { t } = useI18n() +const { t, locale } = useI18n() /** * Every slot renders in both states. An omitted chip and a disabled setting used to look the same, * which made a library's configuration unreadable from the list. */ const slots = computed(() => { - const schedule = parseCronToHuman(props.library.autoScanCronExpression) + const schedule = parseCronToHuman(props.library.autoScanCronExpression, locale.value) return [ { key: 'watch', icon: Eye, on: props.library.watch, label: t('settings.admin.libraries.capability.watch'), detail: null }, { key: 'schedule', icon: CalendarClock, on: Boolean(schedule), label: t('settings.admin.libraries.capability.schedule'), detail: schedule },