fix(reservations): honour the 24h time setting (#1725)

The 12h/24h rules lived in three copies and two of them ignored the setting.
They are one function now. splitReservationDateTime also truncated the
meridiem instead of converting it, so 3:00 PM became 3:00 — three in the
morning. Values already stored that way normalise when a form renders the
field, without changing the payload or the stored format.
This commit is contained in:
Maurice
2026-07-29 14:37:28 +02:00
committed by Maurice
parent 9ab9665e99
commit aaef3e3e76
7 changed files with 292 additions and 47 deletions
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { render, screen, fireEvent, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import CustomTimePicker from './CustomTimePicker';
@@ -455,6 +456,19 @@ describe('CustomTimePicker branches', () => {
expect(onChange).toHaveBeenCalledWith('05:30');
});
it('FE-W5TP-030: a meridiem typed while 24h is configured survives until blur', () => {
render(<CustomTimePicker value="" onChange={onChange} />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '5:30 pm' } });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('5:30 pm');
});
it('FE-W5TP-031: blur reads a meridiem correctly while 24h is configured', () => {
render(<CustomTimePicker value="5:30 pm" onChange={onChange} />);
fireEvent.blur(screen.getByRole('textbox'));
expect(onChange).toHaveBeenCalledWith('17:30');
});
it('FE-W5TP-024: the steppers and the clear button highlight on hover', () => {
use12h();
render(<CustomTimePicker value="00:30" onChange={onChange} />);
@@ -476,3 +490,96 @@ describe('CustomTimePicker branches', () => {
expect(clear.style.color).toBe('var(--text-faint)');
});
});
// FE-TP1725-001 to -006 — the configured time format decides how a time is shown,
// including values that were stored with a meridiem (booking check-in/check-out
// times entered while 12h was set, or carried in by an import).
describe('CustomTimePicker honours the configured time format', () => {
const onChange = vi.fn();
const steppers = () => screen.getAllByRole('button').filter(b => b.textContent?.trim() === '');
beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
});
const use = (time_format: string) => seedStore(useSettingsStore, { settings: buildSettings({ time_format }) });
it('FE-TP1725-001: shows a stored meridiem value in 24h', () => {
use('24h');
render(<CustomTimePicker value="3:00 PM" onChange={onChange} />);
expect(screen.getByRole('textbox')).toHaveProperty('value', '15:00');
});
it('FE-TP1725-002: keeps a stored meridiem value in 12h', () => {
use('12h');
render(<CustomTimePicker value="3:00 PM" onChange={onChange} />);
expect(screen.getByRole('textbox')).toHaveProperty('value', '3:00 PM');
});
it('FE-TP1725-003: converts a 24h value for a 12h user', () => {
use('12h');
render(<CustomTimePicker value="15:00" onChange={onChange} />);
expect(screen.getByRole('textbox')).toHaveProperty('value', '3:00 PM');
});
it('FE-TP1725-004: the dropdown steppers start from the converted value', () => {
use('24h');
render(<CustomTimePicker value="3:00 PM" onChange={onChange} />);
fireEvent.click(steppers()[0]);
expect(screen.getByText('15')).toBeInTheDocument();
expect(screen.getByText('00')).toBeInTheDocument();
fireEvent.click(steppers()[1]);
expect(onChange).toHaveBeenLastCalledWith('16:00');
});
it('FE-TP1725-005: the empty-field hint follows the setting', () => {
use('24h');
const { unmount } = render(<CustomTimePicker value="" onChange={onChange} />);
expect(screen.getByRole('textbox')).toHaveProperty('placeholder', '00:00');
unmount();
use('12h');
render(<CustomTimePicker value="" onChange={onChange} />);
expect(screen.getByRole('textbox')).toHaveProperty('placeholder', '2:30 PM');
});
it('FE-TP1725-006: a 24h value is left alone in 24h', () => {
use('24h');
render(<CustomTimePicker value="14:30" onChange={onChange} />);
expect(screen.getByRole('textbox')).toHaveProperty('value', '14:30');
});
// Display alone is not enough: a value stored as "3:00 PM" stays that way in the
// database until someone edits the field. The picker hands the parsed HH:MM back to
// the form instead, so the next save writes a clean value.
const Controlled = ({ initial }: { initial: string }) => {
const [v, setV] = useState(initial);
return <CustomTimePicker value={v} onChange={nv => { onChange(nv); setV(nv); }} />;
};
it('FE-TP1725-007: a stored meridiem value is handed back as HH:MM without an edit', () => {
use('24h');
render(<Controlled initial="3:00 PM" />);
expect(onChange).toHaveBeenCalledWith('15:00');
expect(screen.getByRole('textbox')).toHaveProperty('value', '15:00');
});
it('FE-TP1725-008: typing a meridiem is left alone until the field is left', () => {
use('24h');
render(<Controlled initial="" />);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '5:30 pm' } });
expect(input).toHaveProperty('value', '5:30 pm');
fireEvent.blur(input);
expect(onChange).toHaveBeenLastCalledWith('17:30');
expect(input).toHaveProperty('value', '17:30');
});
it('FE-TP1725-009: a clean value is not written back', () => {
use('24h');
render(<Controlled initial="14:30" />);
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -2,16 +2,7 @@ import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
function formatDisplay(val: string, is12h: boolean): string {
if (!val) return ''
const [h, m] = val.split(':').map(Number)
if (isNaN(h) || isNaN(m)) return val
if (!is12h) return val
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
import { formatClockTime, parseMeridiemTime } from '../../utils/formatters'
interface CustomTimePickerProps {
value: string
@@ -27,7 +18,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
const ref = useRef<HTMLDivElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
const [h, m] = (value || '').split(':').map(Number)
const [h, m] = (parseMeridiemTime(value) ?? value ?? '').split(':').map(Number)
const hour = isNaN(h) ? null : h
const minute = isNaN(m) ? null : m
@@ -41,6 +32,16 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
return () => document.removeEventListener('mousedown', handler)
}, [open])
// A value that arrives with a meridiem ("3:00 PM" — stored while 12h was configured,
// or carried in by a booking import) is handed back as HH:MM right away, so the form
// saves a clean value even when the field is never touched (#1725). While the input
// has focus this stays out of the way — handleBlur parses what was typed.
useEffect(() => {
if (inputFocused) return
const norm = parseMeridiemTime(value)
if (norm && norm !== value) onChange(norm)
}, [value, inputFocused])
const update = (newH: number, newM: number) => {
const hh = String(Math.max(0, Math.min(23, newH))).padStart(2, '0')
const mm = String(Math.max(0, Math.min(59, newM))).padStart(2, '0')
@@ -69,7 +70,9 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
onChange(raw)
if (is12h) return // let handleBlur parse 12h formats
// Anything with letters in it ("5:30 pm") is left to handleBlur — stripping
// them here would swallow the meridiem and turn 5:30 pm into 05:30.
if (is12h || /[a-z]/i.test(raw)) return
const clean = raw.replace(/[^0-9:]/g, '')
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
@@ -83,18 +86,13 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
if (!value) return
const raw = value.trim()
// Parse 12h input like "5:30 PM", "5:30pm", "530pm"
if (is12h) {
const match12 = raw.match(/^(\d{1,2}):?(\d{2})?\s*(am|pm)$/i)
if (match12) {
let h = parseInt(match12[1])
const m = match12[2] ? parseInt(match12[2]) : 0
const isPm = match12[3].toLowerCase() === 'pm'
if (h === 12) h = isPm ? 12 : 0
else if (isPm) h += 12
onChange(String(Math.min(23, h)).padStart(2, '0') + ':' + String(Math.min(59, m)).padStart(2, '0'))
return
}
// Parse 12h input like "5:30 PM", "5:30pm", "530pm". A meridiem is
// unambiguous, so it is honoured whatever the configured format is —
// otherwise the cleanup below reads "5:30 PM" as 05:30 (#1725).
const parsed12h = parseMeridiemTime(raw)
if (parsed12h) {
onChange(parsed12h)
return
}
const clean = raw.replace(/[^0-9:]/g, '')
@@ -124,7 +122,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
}}>
<input
type="text"
value={inputFocused ? value : formatDisplay(value, is12h)}
value={inputFocused ? value : formatClockTime(value, is12h)}
onChange={handleInput}
onFocus={() => setInputFocused(true)}
onBlur={() => { setInputFocused(false); handleBlur() }}