From f607f48bf2deaa3ecdf619095598316614c641b1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:35:22 +0100 Subject: [PATCH] fix: keep events in their own time zone and ask for the time first in the event form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving an event wrote it in the user's zone, so a title change moved a New York meeting to Europe/Berlin. The form now shows and saves the times on the event's own clock, with a time zone field ("Time zone: New York", or a "Time zone" link when it is yours), and the popover shows "10:00–11:00 New York (16:00–17:00 your time)". The form asks for the date and time right after the title and for the description last. Participant answers read in plain words ("Not answered yet"), the untitled preview is translated, and the quick editor grows to fit long calendar names. Changed assertions: rsvp-buttons.test expects the plain status key, and event-modal-organizer.test expects an unchanged save to keep the event's New York zone instead of rewriting it into the user's. --- .../__tests__/event-modal-organizer.test.tsx | 10 +- .../event-modal-time-zone-ux-pim.test.tsx | 158 ++++++ .../event-popover-zone-ux-pim.test.tsx | 106 ++++ .../calendar/__tests__/rsvp-buttons.test.tsx | 3 +- components/calendar/event-detail-popover.tsx | 16 + components/calendar/event-modal.tsx | 477 +++++++++++------- components/calendar/quick-create-popover.tsx | 20 +- components/calendar/rsvp-buttons.tsx | 16 +- lib/__tests__/recurrence-zone-ux-pim.test.ts | 79 +++ lib/event-time-zone.ts | 135 +++++ lib/recurrence-scope-edit.ts | 38 +- locales/ar/common.json | 18 + locales/ca/common.json | 18 + locales/cs/common.json | 18 + locales/da/common.json | 18 + locales/de/common.json | 18 + locales/en/common.json | 18 + locales/es/common.json | 18 + locales/et/common.json | 18 + locales/fa/common.json | 18 + locales/fr/common.json | 18 + locales/he/common.json | 18 + locales/hu/common.json | 18 + locales/it/common.json | 18 + locales/ja/common.json | 18 + locales/ko/common.json | 18 + locales/lv/common.json | 18 + locales/mn/common.json | 18 + locales/nb/common.json | 18 + locales/nl/common.json | 18 + locales/pl/common.json | 18 + locales/pt/common.json | 18 + locales/ro/common.json | 18 + locales/ru/common.json | 18 + locales/sk/common.json | 18 + locales/th/common.json | 18 + locales/tr/common.json | 18 + locales/uk/common.json | 18 + locales/vi/common.json | 18 + locales/zh-TW/common.json | 18 + locales/zh/common.json | 18 + 41 files changed, 1386 insertions(+), 212 deletions(-) create mode 100644 components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx create mode 100644 components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx create mode 100644 lib/__tests__/recurrence-zone-ux-pim.test.ts create mode 100644 lib/event-time-zone.ts diff --git a/components/calendar/__tests__/event-modal-organizer.test.tsx b/components/calendar/__tests__/event-modal-organizer.test.tsx index 81cb7122c..f72924f81 100644 --- a/components/calendar/__tests__/event-modal-organizer.test.tsx +++ b/components/calendar/__tests__/event-modal-organizer.test.tsx @@ -109,8 +109,8 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal organizer and a // RFC 5546: when the organizer moves an event, the attendees are asked // again; every other save keeps their answers. The editor shows and writes -// the event in the user's zone (here Tokyo, UTC+9), which is no move by -// itself. +// the event on its own clock (New York), whatever the user's zone (here +// Tokyo, UTC+9) is (pim-2). describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a move on Stalwart %s', (version) => { beforeEach(() => useSettingsStore.setState({ timeZone: 'Asia/Tokyo' })); afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); @@ -155,11 +155,11 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a expect(statuses(data)).toEqual({ org: 'accepted', bob: 'needs-action', carol: 'needs-action' }); }); - it('keeps the answers when only the time zone is rewritten', async () => { + it('keeps the answers, and the event in its own zone, when nothing changes', async () => { const data = await save(own(), () => {}); - // Written in the user's zone, at the same instant. - expect(data).toMatchObject({ start: '2026-10-12T21:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); + // Written in its own zone, as it was (pim-2); it was rewritten into the user's. + expect(data).toMatchObject({ start: '2026-10-12T08:00:00', timeZone: 'America/New_York', duration: 'PT1H' }); expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' }); }); diff --git a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx new file mode 100644 index 000000000..803cf0d02 --- /dev/null +++ b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx @@ -0,0 +1,158 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventModal } from '../event-modal'; +import { useSettingsStore } from '@/stores/settings-store'; +import { AUTO_TIME_ZONE, fromDisplayDate } from '@/lib/timezone'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import type { EventEditorState } from '@/lib/recurrence-scope-edit'; + +// pim-2: an edit kept the event's time but moved it into the user's zone +// (every save wrote timeZone: ), and there was no way to put an +// event in another zone. pim-7: the form asked for the date and time last. +// pim-19, pim-22: plain words for the participant answers and "(No title)". + +// Keys with their values, so a test can read what a line says. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => 'en', + useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }), +})); +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); + +const RIGHTS = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; +const calendars = [ + { id: 'cal', name: 'Work', color: '#123456', isDefault: true, myRights: RIGHTS }, + { id: 'home', name: 'Home', color: '#654321', isDefault: false, myRights: RIGHTS }, +] as unknown as Calendar[]; + +// 10:00-11:00 in New York on 5 November (UTC-5): 16:00-17:00 in Berlin. +const newYork = { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Call with New York office', description: '', + start: '2026-11-05T10:00:00', duration: 'PT1H', timeZone: 'America/New_York', + utcStart: '2026-11-05T15:00:00Z', utcEnd: '2026-11-05T16:00:00Z', + showWithoutTime: false, calendarIds: { cal: true }, participants: null, alerts: null, recurrenceRules: null, +} as unknown as CalendarEvent; + +function editor(event: CalendarEvent | null, extra: Partial[0]> = {}) { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + return onSave; +} + +async function save(onSave: ReturnType): Promise<{ data: Partial; state: EventEditorState }> { + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return { data: onSave.mock.calls[0][0], state: onSave.mock.calls[0][2] }; +} + +const field = (label: string) => screen.getByLabelText(label) as HTMLInputElement; + +beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); + +describe('the event editor keeps the event in its own time zone (pim-2)', () => { + it('shows the times on the event clock and says which zone that is', () => { + editor(newYork); + expect(field('form.start_time').value).toBe('10:00'); + expect(field('form.end_time').value).toBe('11:00'); + expect(screen.getByText(/^time_zone_line:/).textContent).toBe('time_zone_line:{"zone":"New York"}'); + expect(screen.getByText(/^your_time:/).textContent).toBe('your_time:{"times":"16:00–17:00"}'); + }); + + it('saves a title change with the zone and start the event had', async () => { + const onSave = editor(newYork); + fireEvent.change(field('form.title'), { target: { value: 'Call with New York office, room 2' } }); + const { data, state } = await save(onSave); + expect(data).toMatchObject({ title: 'Call with New York office, room 2', start: '2026-11-05T10:00:00', timeZone: 'America/New_York', duration: 'PT1H' }); + // The editor state stays in display dates: the same instant. + expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-11-05T15:00:00.000Z'); + expect(state.timeZoneChanged).toBeUndefined(); + }); + + it('moves the event on its own clock', async () => { + const onSave = editor(newYork); + fireEvent.change(field('form.start_time'), { target: { value: '12:00' } }); + const { data } = await save(onSave); + expect(data).toMatchObject({ start: '2026-11-05T12:00:00', timeZone: 'America/New_York', duration: 'PT1H' }); + }); + + it('creates an event in another zone through the picker', async () => { + const onSave = editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); + fireEvent.change(field('form.title'), { target: { value: 'Tokyo partner sync' } }); + // A new event is in the user's zone, behind a small link. + expect(screen.queryByText(/^time_zone_line/)).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'time_zone' })); + fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + const { data, state } = await save(onSave); + expect(data).toMatchObject({ start: '2026-11-12T10:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); + // 10:00 in Tokyo is 01:00 UTC. + expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-11-12T01:00:00.000Z'); + }); + + it('tells the series save when the user picked another zone', async () => { + const onSave = editor(newYork); + fireEvent.click(screen.getByText(/^time_zone_line:/)); + fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + const { data, state } = await save(onSave); + expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: 'Asia/Tokyo' }); + expect(state.timeZoneChanged).toBe(true); + }); + + it('leaves a floating event without a zone', async () => { + const onSave = editor({ ...newYork, timeZone: null, utcStart: undefined, utcEnd: undefined } as unknown as CalendarEvent); + expect(screen.queryByTestId('event-time-zone')).toBeNull(); + fireEvent.change(field('form.title'), { target: { value: 'Floating' } }); + const { data } = await save(onSave); + expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: null }); + }); + + it('shows the event clock beside the user one in the summary', () => { + render(); + expect(screen.getByTestId('event-zone-times').textContent) + .toBe('zone_times:{"times":"10:00–11:00","zone":"New York","local":"16:00–17:00"}'); + }); + + it('shows no zone line for an event on the user clock', () => { + render(); + expect(screen.queryByTestId('event-zone-times')).toBeNull(); + }); +}); + +describe('the event form order (pim-7)', () => { + it('asks for the time right after the title and for the description last', () => { + editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); + const order = ['form.title', 'form.all_day_event', 'form.start_date', 'form.end_time', 'recurrence.title', 'form.calendar_select', + 'form.location', 'form.meeting_link', 'participants.title', 'alerts.title', 'form.description']; + const nodes = order.map((label) => screen.getAllByText(label).find((el) => el.closest('label')) ?? screen.getAllByText(label)[0]); + for (let i = 1; i < nodes.length; i++) { + const before = nodes[i - 1].compareDocumentPosition(nodes[i]) & Node.DOCUMENT_POSITION_FOLLOWING; + expect(before, `${order[i - 1]} before ${order[i]}`).toBeTruthy(); + } + }); +}); + +describe('plain words (pim-19, pim-22)', () => { + it('says "Not answered yet" for an attendee who has not answered', () => { + const meeting = { + ...newYork, + participants: { + me: { '@type': 'Participant', name: 'Me', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + ben: { '@type': 'Participant', name: 'Ben', calendarAddress: 'mailto:ben@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + } as unknown as CalendarEvent; + render(); + expect(screen.getByText('status_needs_action')).toBeTruthy(); + expect(screen.getByText('status_accepted')).toBeTruthy(); + expect(screen.queryByText('participants.needs_action')).toBeNull(); + }); + + it('labels the grid preview of an untitled event in the user language', () => { + const onPreviewChange = vi.fn(); + editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0), onPreviewChange }); + const last = onPreviewChange.mock.calls.filter(([p]) => p).pop()![0]; + expect(last.title).toBe('events.no_title'); + }); +}); diff --git a/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx new file mode 100644 index 000000000..1b9b9dc9b --- /dev/null +++ b/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventDetailPopover } from '../event-detail-popover'; +import { QuickCreatePopover, computePosition } from '../quick-create-popover'; +import { useSettingsStore } from '@/stores/settings-store'; +import { AUTO_TIME_ZONE } from '@/lib/timezone'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// pim-2: the popover of an event in another zone said nothing about it. +// pim-19: "Needs action" for an attendee who hasn't answered. +// pim-21: the quick editor cut a long calendar name off. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => 'en', + useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }), +})); + +const calendar = { id: 'cal', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar; + +function event(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', calendarIds: { cal: true }, title: 'Call with New York office', start: '2026-11-05T10:00:00', duration: 'PT1H', + timeZone: 'America/New_York', utcStart: '2026-11-05T15:00:00Z', utcEnd: '2026-11-05T16:00:00Z', + showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false, + ...overrides, + } as unknown as CalendarEvent; +} + +function popover(e: CalendarEvent) { + render( + , + ); +} + +beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); + +describe('event popover', () => { + it('shows the times on the event clock with the user times after them (pim-2)', () => { + popover(event()); + expect(screen.getByTestId('event-zone-times').textContent) + .toBe('zone_times:{"times":"10:00–11:00","zone":"New York","local":"16:00–17:00"}'); + }); + + it('names the weekday on the event clock when its day there is another', () => { + // 08:00 on Friday in Tokyo is 00:00 on Friday in Berlin; in New York + // (the user here) it is still Thursday. + useSettingsStore.setState({ timeZone: 'America/New_York' }); + popover(event({ timeZone: 'Asia/Tokyo', start: '2026-11-06T08:00:00', utcStart: '2026-11-05T23:00:00Z', utcEnd: '2026-11-06T00:00:00Z' })); + expect(screen.getByTestId('event-zone-times').textContent) + .toBe('zone_times:{"times":"Fri 08:00–09:00","zone":"Tokyo","local":"18:00–19:00"}'); + }); + + it('shows the plain times for an event on the user clock, an all-day and a floating one', () => { + popover(event({ timeZone: 'Europe/Berlin', start: '2026-11-05T16:00:00' })); + popover(event({ id: 'e2', showWithoutTime: true, timeZone: null, start: '2026-11-05T00:00:00', duration: 'P1D' })); + popover(event({ id: 'e3', timeZone: null, utcStart: undefined, utcEnd: undefined })); + expect(screen.queryByTestId('event-zone-times')).toBeNull(); + }); + + it('says an attendee has not answered yet in plain words (pim-19)', () => { + popover(event({ + participants: { + me: { '@type': 'Participant', name: 'Me', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + ben: { '@type': 'Participant', name: 'Ben', calendarAddress: 'mailto:ben@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + eve: { '@type': 'Participant', name: 'Eve', calendarAddress: 'mailto:eve@example.com', roles: { attendee: true }, participationStatus: 'tentative' }, + }, + } as unknown as Partial)); + expect(screen.getByText('status_needs_action')).toBeTruthy(); + expect(screen.getByText('status_tentative')).toBeTruthy(); + expect(screen.queryByText('participants.needs_action')).toBeNull(); + }); +}); + +describe('quick create (pim-21)', () => { + const long = 'Stalwart Calendar (dso-ux-pim@example.org)'; + const calendars = [ + { id: 'a', name: long, color: '#3b82f6' }, + { id: 'b', name: 'Work', color: '#22c55e' }, + ] as unknown as Calendar[]; + + it('grows to fit the calendar names and names the calendar on hover', () => { + render( + , + ); + const dialog = screen.getByRole('dialog'); + // As wide as its content (the select is as wide as its longest name), from 340 px on. + expect(dialog.style.width).toBe('max-content'); + expect(dialog.style.minWidth).toBe('340px'); + expect(dialog.style.maxWidth).toContain('480px'); + expect(screen.getByRole('combobox').getAttribute('title')).toBe(long); + }); + + it('places a wider popover by its own width', () => { + Object.defineProperty(window, 'innerWidth', { value: 1366, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true }); + // Beside an anchor at the right: 460 px don't fit on the right side, so it goes left of it. + const anchor = new DOMRect(1000, 300, 150, 20); + expect(computePosition(anchor, 200, 340)).toEqual({ top: 300, left: 652 }); + expect(computePosition(anchor, 200, 460)).toEqual({ top: 300, left: 532 }); + }); +}); diff --git a/components/calendar/__tests__/rsvp-buttons.test.tsx b/components/calendar/__tests__/rsvp-buttons.test.tsx index 2e4305e3f..e3e31264b 100644 --- a/components/calendar/__tests__/rsvp-buttons.test.tsx +++ b/components/calendar/__tests__/rsvp-buttons.test.tsx @@ -30,6 +30,7 @@ describe('participation status colours', () => { it('colours a participant\'s answer the same way', () => { render(); - expect(screen.getByText('participants.accepted').className).toContain('text-green-700'); + // In plain words (pim-19): ux_pim_event.status_accepted. + expect(screen.getByText('status_accepted').className).toContain('text-green-700'); }); }); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 57c4ea3e2..1d01a9761 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import { parseDuration, getEventColor } from "./event-card"; import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; +import { eventZonedTimes, zonedTimesText } from "@/lib/event-time-zone"; import { buildRecurrenceSummary } from "./recurrence-editor"; import { formatEventAlertsLabel } from "./alert-list-editor"; import { @@ -147,6 +148,7 @@ export function EventDetailPopover({ const t = useTranslations("calendar"); const paint = useCvdColor(); const tCommon = useTranslations("common"); + const tEvent = useTranslations("ux_pim_event"); const locale = useLocale(); const popoverRef = useRef(null); const noteInputRef = useRef(null); @@ -166,6 +168,11 @@ export function EventDetailPopover({ const endDate = getEventEndDate(event); const displayEndDate = getEventDisplayEndDate(event); const isMultiDay = !isSameDay(startDate, displayEndDate); + // An event on another zone's clock shows its own times too (pim-2). + const zoned = eventZonedTimes(event, startDate, endDate); + const zonedText = zoned + ? zonedTimesText(zoned, startDate, endDate, timeFormat === "12h" ? "h:mm a" : "HH:mm", locale) + : null; const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]); // Re-computes the location link once the config is known (#903). @@ -499,6 +506,11 @@ export function EventDetailPopover({
({formatDurationDisplay(durationMinutes)})
+ {zoned && zonedText && ( +
+ {tEvent("zone_only", { times: zonedText.times, zone: zoned.zone })} +
+ )} ) ) : ( @@ -508,6 +520,10 @@ export function EventDetailPopover({ {event.showWithoutTime ? ( {t("events.all_day")} + ) : zoned && zonedText ? ( +
+ {tEvent("zone_times", { times: zonedText.times, zone: zoned.zone, local: zonedText.local })} +
) : (
{formatTime(startDate)} – {formatTime(endDate)} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 94438b553..e09308ebc 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -18,7 +18,8 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow, import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; -import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; +import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, listTimeZones, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone"; +import { displayToZoneWall, eventFormTimeZone, eventZonedTimes, timeZoneCity, zonedTimesText, zoneWallToDisplay } from "@/lib/event-time-zone"; import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; import { ParticipantAvailability } from "./participant-availability"; @@ -278,6 +279,7 @@ export function EventModal({ const labelIdBase = useId(); const labelFor = (key: string) => `${labelIdBase}-${key}`; const tCommon = useTranslations("common"); + const tEvent = useTranslations("ux_pim_event"); const locale = useLocale(); const timeFormat = useSettingsStore((s) => s.timeFormat); const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; @@ -381,11 +383,37 @@ export function EventModal({ const [virtualLocation, setVirtualLocation] = useState( event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : "" ); - const [startDate, setStartDate] = useState(formatDateInput(getInitialStart())); - const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart())); - const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd())); - const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd())); + // The zone the date and time fields are in, and a save writes: the + // event's own, the user's for a new event, none for a floating one. An + // edit used to save every event in the user's zone (pim-2). + const userTimeZone = getEffectiveTimeZone(); + const [timeZone, setTimeZone] = useState(() => eventFormTimeZone(event, userTimeZone)); + const [showZonePicker, setShowZonePicker] = useState(false); + const [initialStartWall] = useState(() => displayToZoneWall(getInitialStart(), timeZone)); + const [initialEndWall] = useState(() => displayToZoneWall(getInitialEnd(), timeZone)); + const [startDate, setStartDate] = useState(formatDateInput(initialStartWall)); + const [startTime, setStartTime] = useState(formatTimeInput(initialStartWall)); + const [endDate, setEndDate] = useState(formatDateInput(initialEndWall)); + const [endTime, setEndTime] = useState(formatTimeInput(initialEndWall)); const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false); + // The zone the fields are on: none for an all-day event. + const fieldZone = allDay ? null : timeZone; + const zoneOptions = useMemo(() => { + if (!showZonePicker) return []; + const zones = listTimeZones(); + return timeZone && !zones.includes(timeZone) ? [timeZone, ...zones] : zones; + }, [showZonePicker, timeZone]); + // The form's times on the user's clock, beside another zone's. + const formYourTimes = useMemo(() => { + if (!fieldZone || fieldZone === userTimeZone) return null; + const from = zoneWallToDisplay(new Date(`${startDate}T${startTime || "00:00"}:00`), fieldZone); + const to = zoneWallToDisplay(new Date(`${endDate}T${endTime || "00:00"}:00`), fieldZone); + if (isNaN(from.getTime()) || isNaN(to.getTime())) return null; + const time = (d: Date) => format(d, timeDisplayFmt); + const onOtherDay = !isSameDay(from, new Date(`${startDate}T00:00:00`)); + const day = onOtherDay ? `${new Intl.DateTimeFormat(locale, { weekday: "short" }).format(from)} ` : ""; + return `${day}${time(from)}–${time(to)}`; + }, [fieldZone, userTimeZone, startDate, startTime, endDate, endTime, timeDisplayFmt, locale]); // Editing the start shifts the end with it, preserving the event's current // length (end - start) - so moving the start never leaves the end before it @@ -524,9 +552,9 @@ export function EventModal({ // What the form holds against what it opened with: a form with changes // asks before X, Cancel or Escape drop them (R5). const formState = useMemo(() => ({ - title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, + title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations, - }), [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, + }), [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations]); const [initialForm] = useState(formState); const isDirty = mode === "edit" && JSON.stringify(formState) !== JSON.stringify(initialForm); @@ -537,6 +565,7 @@ export function EventModal({ const f = initialForm; setTitle(f.title); setDescription(f.description); setLocation(f.location); setVirtualLocation(f.virtualLocation); setStartDate(f.startDate); setStartTime(f.startTime); setEndDate(f.endDate); setEndTime(f.endTime); setAllDay(f.allDay); + setTimeZone(f.timeZone); setShowZonePicker(false); setCalendarId(f.calendarId); setRecurrence(f.recurrence); setCustomRule(f.customRule); setShowRecurrenceEditor(false); setUseDefaultAlerts(f.useDefaultAlerts); setAlertRows(f.alertRows); setPreservedAlerts(f.preservedAlerts); setRefreshDefaults(false); setAttendees(f.attendees); setSendInvitations(f.sendInvitations); @@ -565,10 +594,10 @@ export function EventModal({ const backToView = useCallback(() => whenDiscarded(() => { resetForm(); setMode("view"); }), [whenDiscarded, resetForm]); // The event window the attendees' free/busy is checked against. The - // fields are a wall clock in the effective zone, as the save path labels + // fields are a wall clock in the form's zone, as the save path labels // them, not in the browser's; an all-day event spans its whole days. const availabilityWindow = useMemo(() => { - const zone = getEffectiveTimeZone(); + const zone = fieldZone ?? getEffectiveTimeZone(); const instant = (value: string) => { const wall = parseLocalDateTime(value); if (!wall) return null; @@ -581,7 +610,7 @@ export function EventModal({ start: startDate ? instant(startStr) : null, end: endDate ? instant(endStr) : null, }; - }, [startDate, startTime, endDate, endTime, allDay]); + }, [startDate, startTime, endDate, endTime, allDay, fieldZone]); // An existing event's own time, as saved: Suggest times doesn't count it // as busy (the attendees who have it in their calendar show it so). @@ -590,16 +619,16 @@ export function EventModal({ : null), [event]); // A suggested slot moves the event there; the fields are a wall clock in - // the effective zone, so the instants are turned back into one. + // the form's zone, so the instants are turned back into one. const handlePickSlot = useCallback((slot: { start: Date; end: Date }) => { - const zone = getEffectiveTimeZone(); + const zone = fieldZone ?? getEffectiveTimeZone(); const from = toZonedDisplayDate(slot.start, zone); const to = toZonedDisplayDate(slot.end, zone); setStartDate(formatDateInput(from)); setStartTime(formatTimeInput(from)); setEndDate(formatDateInput(to)); setEndTime(formatTimeInput(to)); - }, []); + }, [fieldZone]); // Plugin transform: collect conflict warnings for the current event form. // Re-runs (debounced) whenever fields that affect scheduling change. @@ -683,7 +712,7 @@ export function EventModal({ description, start: startStr, duration, - timeZone: getEffectiveTimeZone(), + timeZone: fieldZone, utcStart: null, utcEnd: null, isAllDay: allDay, @@ -696,7 +725,7 @@ export function EventModal({ isRecurring: recurrence !== "none", keywords: base?.keywords ?? [], }; - }, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]); + }, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, fieldZone, location, attendees, recurrence]); const [settledFormEvent, setSettledFormEvent] = useState(formEventView); useEffect(() => { const timer = setTimeout(() => setSettledFormEvent(formEventView), 500); @@ -704,17 +733,20 @@ export function EventModal({ }, [formEventView]); const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]); - // Report live preview to parent for grid outline + // Report live preview to parent for grid outline. The label as a string: + // a new `t` on every render would report it again on each. + const noTitle = t("events.no_title"); useEffect(() => { if (!onPreviewChange || isEdit) return; const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`; const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`; - const s = new Date(startStr); - const e = new Date(endStr); + // Where the grid shows it: the fields are on the form's clock. + const s = zoneWallToDisplay(new Date(startStr), fieldZone); + const e = zoneWallToDisplay(new Date(endStr), fieldZone); if (isNaN(s.getTime()) || isNaN(e.getTime())) return; - onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId }); + onPreviewChange({ start: s, end: e, title: title || noTitle, allDay, calendarId }); return () => onPreviewChange(null); - }, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]); + }, [startDate, startTime, endDate, endTime, allDay, fieldZone, title, noTitle, calendarId, isEdit, onPreviewChange]); const statusCounts = useMemo(() => { if (!event?.participants) return null; @@ -772,9 +804,15 @@ export function EventModal({ duration = buildDuration(start, end); } - // The form's wall-clock fields are in the user's effective zone (the - // grid and prefill are display dates), so label them with that zone. - const timeZone = getEffectiveTimeZone(); + // The fields are the wall clock in the form's zone: the event's own, so + // an edit keeps it, or the one the user picked. The editor state below + // is in display dates, as the rest of the calendar. + const when = { + allDay, + start: allDay ? start : zoneWallToDisplay(start, timeZone), + end: allDay ? end : zoneWallToDisplay(end, timeZone), + }; + const timeZoneChanged = !!event && !allDay && timeZone !== initialForm.timeZone; const trimmedDescription = description.trim(); @@ -875,7 +913,7 @@ export function EventModal({ userEmail: currentUserEmails[0], userEmails: currentUserEmails, resolveName: resolveContactName, - rescheduled: !!event && (changesWhen(event, { allDay, start, end }) || (ruleChanged && !event.recurrenceId)), + rescheduled: !!event && (changesWhen(event, when) || timeZoneChanged || (ruleChanged && !event.recurrenceId)), })); } @@ -937,14 +975,15 @@ export function EventModal({ await onSave(data, shouldSendScheduling, { initialAttendees, attendees: effectiveAttendees, - when: { allDay, start, end }, + when, ruleChanged, + ...(timeZoneChanged ? { timeZoneChanged } : {}), }); } finally { savingRef.current = false; setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, timeZone, initialForm, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, eventDescription, onSave, isSaving, t]); // The user's own answer, and the owner's in a calendar shared with them // (lib/calendar-delegate.ts). @@ -1030,6 +1069,9 @@ export function EventModal({ const alertLabel = formatEventAlertsLabel(event, calendars, t); const eventCalendar = calendars.find(c => event.calendarIds[c.id]); const color = paint(getEventColor(event, eventCalendar)); + // An event on another zone's clock shows its own times too (pim-2). + const zoned = eventZonedTimes(event, startD, endD); + const zonedText = zoned ? zonedTimesText(zoned, startD, endD, timeDisplayFmt, locale) : null; return (
@@ -1108,6 +1150,11 @@ export function EventModal({
({formatDurationDisplay(durMin)})
+ {zoned && zonedText && ( +
+ {tEvent("zone_only", { times: zonedText.times, zone: zoned.zone })} +
+ )} ); } @@ -1118,6 +1165,10 @@ export function EventModal({ {event.showWithoutTime ? ( {t("events.all_day")} + ) : zoned && zonedText ? ( +
+ {tEvent("zone_times", { times: zonedText.times, zone: zoned.zone, local: zonedText.local })} +
) : (
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)} @@ -1270,19 +1321,188 @@ export function EventModal({ />
-
- -