fix(library): respect locale for cron time display

Derive the clock convention from the active locale so custom scan schedules match the rest of the interface.

Closes #1391
This commit is contained in:
neon
2026-09-13 17:59:10 +00:00
parent 614194202b
commit 2f22abe03e
5 changed files with 30 additions and 25 deletions
@@ -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
}
</script>
@@ -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')
})
+3 -2
View File
@@ -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
}
@@ -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 [
@@ -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 },