fix: keep out-of-office dates right when the time zone setting arrives or changes after the page opened

This commit is contained in:
Linus Rath
2026-11-01 09:48:58 +01:00
parent 9f8d134e85
commit a4b140b651
2 changed files with 30 additions and 2 deletions
@@ -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<HTMLInputElement>;
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"]');
+15 -2
View File
@@ -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<HTMLInputElement>(null);
const toRef = useRef<HTMLInputElement>(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);