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({ />
-
- -