From a4b140b65138e5ea86fa78902e559cfca4fdf09b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:48:58 +0100 Subject: [PATCH] fix: keep out-of-office dates right when the time zone setting arrives or changes after the page opened --- .../__tests__/vacation-templates.test.tsx | 15 +++++++++++++++ components/settings/vacation-settings.tsx | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/components/settings/__tests__/vacation-templates.test.tsx b/components/settings/__tests__/vacation-templates.test.tsx index 7d99ea34d..501af2451 100644 --- a/components/settings/__tests__/vacation-templates.test.tsx +++ b/components/settings/__tests__/vacation-templates.test.tsx @@ -100,6 +100,21 @@ describe('out-of-office templates', () => { expect(sent.subject).toMatch(/^Away until Monday,? 4 January 2027$/); }); + it('follows a time zone that arrives after the page opened, without saving or shifting the dates', async () => { + const server = await openCard([], 'UTC'); + const inputs = () => document.querySelectorAll('input[type="datetime-local"]') as NodeListOf; + expect(inputs()[1].value).toBe('2027-01-03T23:00'); + // Settings sync brings the user's zone a moment later. + act(() => useSettingsStore.setState({ timeZone: 'Asia/Tokyo' })); + expect(inputs()[1].value).toBe('2027-01-04T08:00'); + await settle(); + expect(server.client.setVacationResponse).not.toHaveBeenCalled(); + // An edit afterwards is read on the new clock. + fireEvent.change(inputs()[0], { target: { value: '2026-12-22T09:00' } }); + await waitFor(() => expect(server.client.setVacationResponse).toHaveBeenCalled(), { timeout: 3000 }); + expect(server.client.setVacationResponse.mock.calls[0][0]).toMatchObject({ fromDate: '2026-12-22T00:00:00.000Z', toDate: '2027-01-03T23:00:00Z' }); + }); + it('shows and fills the dates in the time zone set in Bulwark, not the browser’s', async () => { await openCard([{ ...TEMPLATE, text: 'Back {end}', subject: '' }], 'Asia/Tokyo'); const inputs = document.querySelectorAll('input[type="datetime-local"]'); diff --git a/components/settings/vacation-settings.tsx b/components/settings/vacation-settings.tsx index b3ce2d3cd..e8c7d0d2d 100644 --- a/components/settings/vacation-settings.tsx +++ b/components/settings/vacation-settings.tsx @@ -132,14 +132,26 @@ export function VacationSettings() { // The date fields as typed ("yyyy-mm-ddThh:mm" on the browser's clock). const [fromInput, setFromInput] = useState(() => (fromDate ? utcToLocalDatetime(fromDate, timeZone) : '')); const [toInput, setToInput] = useState(() => (toDate ? utcToLocalDatetime(toDate, timeZone) : '')); + // The zone the fields' wall-clock text is in. The time zone setting can + // arrive or change after the fields were filled (settings sync): the text + // is read in the zone it was written in, then rewritten for the new zone. + const [inputZone, setInputZone] = useState(timeZone); // A field the browser holds half filled in (a date without its time) reads // as empty, without an event when it was empty before: told apart here, so // it does not pass for "no date", which leaves that end of the period open. const [halfFilled, setHalfFilled] = useState({ from: false, to: false }); const fromRef = useRef(null); const toRef = useRef(null); - const localFromDate = fieldMoment(fromInput, fromDate, timeZone); - const localToDate = fieldMoment(toInput, toDate, timeZone); + const localFromDate = fieldMoment(fromInput, fromDate, inputZone); + const localToDate = fieldMoment(toInput, toDate, inputZone); + useEffect(() => { + if (timeZone === inputZone) return; + // The same moments, shown on the new zone's clock; nothing is saved for it. + if (localFromDate) setFromInput(utcToLocalDatetime(localFromDate, timeZone)); + if (localToDate) setToInput(utcToLocalDatetime(localToDate, timeZone)); + setInputZone(timeZone); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeZone]); const datesUnusable = localFromDate === null || localToDate === null || halfFilled.from || halfFilled.to; const [localSubject, setLocalSubject] = useState(subject); const [localTextBody, setLocalTextBody] = useState(textBody); @@ -195,6 +207,7 @@ export function VacationSettings() { setLocalEnabled(stored.isEnabled); setFromInput(stored.fromDate ? utcToLocalDatetime(stored.fromDate, timeZone) : ''); setToInput(stored.toDate ? utcToLocalDatetime(stored.toDate, timeZone) : ''); + setInputZone(timeZone); setHalfFilled({ from: false, to: false }); setLocalSubject(stored.subject); setLocalTextBody(stored.textBody);