Merge branch 'feat/do-ux-p2-pim-calx' into feat/do-ux-p2-pim-fix

This commit is contained in:
Linus Rath
2026-11-04 14:07:24 +01:00
54 changed files with 1451 additions and 227 deletions
+8
View File
@@ -638,6 +638,14 @@ html[data-window-controls] [data-own-window-close] {
display: none;
}
/*
* A sheet docked at the window's end (the calendar's event and task panels) has its title
* and close at the top and Save at the foot: toasts stand beside it, not on it (pim-p2-19).
*/
body:has([data-end-sheet]) [data-toast-container] {
inset-inline-end: calc(var(--end-sheet-width, 400px) + 1.25rem);
}
/* Toasts that step out of the focus's way go to the top end corner: below the controls there. */
html[data-window-controls] [data-toast-container][data-dodging] {
top: calc(var(--titlebar-row-height) + 0.75rem) !important;
@@ -0,0 +1,156 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import { useTaskStore } from '@/stores/task-store';
// The whole calendar page, with the shell around it stubbed out and the
// stores holding a fixed set of calendars and events.
const media = vi.hoisted(() => ({ mobile: false, desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => media.mobile,
useIsDesktop: () => !media.mobile,
// `desktop` is the full desktop: below it the sidebar opens over the page.
useIsFullDesktop: () => media.desktop,
useIsTablet: () => !media.mobile && !media.desktop,
useMediaQuery: () => false,
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
const config = vi.hoisted(() => ({ demoMode: false }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => config }));
const lite = vi.hoisted(() => ({ on: false }));
vi.mock('@/lib/lite', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/lite')>()),
get IS_LITE() { return lite.on; },
}));
import { CalendarApp } from '../calendar-app';
// The first render loads the whole page and is slow under load.
vi.setConfig({ testTimeout: 30000 });
const RIGHTS = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
};
const calendars = [{
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
}] as unknown as Calendar[];
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00',
duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true },
participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed',
...overrides,
} as unknown as CalendarEvent;
}
const client = {
getAccountId: () => 'acc',
getMaxParticipantsPerEvent: () => null,
supportsCalendars: () => true,
queryCalendarEvents: vi.fn().mockResolvedValue([]),
getCalendarEvent: vi.fn().mockResolvedValue(null),
getCalendarTasks: vi.fn().mockResolvedValue([]),
};
const store = {
fetchCalendars: vi.fn(),
fetchEvents: vi.fn(),
refreshAllSubscriptions: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
rsvpEvent: vi.fn(),
};
function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') {
for (const fn of Object.values(store)) fn.mockReset();
store.createEvent.mockImplementation(async (_c: unknown, data: Partial<CalendarEvent>) => ({ ...makeEvent(), ...data, id: 'new' }));
store.updateEvent.mockResolvedValue(true);
store.deleteEvent.mockResolvedValue(true);
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' });
useCalendarStore.setState({
calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode,
selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
...store,
} as never);
useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never);
useToastStore.setState({ toasts: [] } as never);
useTaskStore.setState({ tasks: [] } as never);
client.getCalendarTasks.mockResolvedValue([]);
}
function renderApp() {
return render(<CalendarApp />);
}
beforeEach(() => {
media.mobile = false;
media.desktop = true;
setUp([makeEvent({
description: 'Agenda in the doc',
locations: { l: { '@type': 'Location', name: 'Room 4' } },
participants: {
me: { '@type': 'Participant', email: 'me@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
ben: { '@type': 'Participant', email: 'ben@example.com', name: 'Ben', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as never)]);
useCalendarStore.setState({ participantIdentities: [{ id: 'pi', name: 'Me', calendarAddress: 'mailto:me@example.com', isDefault: true }] } as never);
});
// pim-p2-17: Duplicate saved a copy one day later at once and opened it to
// view, so a mistaken click left an event behind. Google and Outlook open the
// editor with an unsaved copy at the same time instead.
describe('Duplicate opens an unsaved copy in the editor', () => {
async function duplicateFromMenu() {
renderApp();
fireEvent.contextMenu(screen.getAllByRole('button', { name: /Standup/ })[0], { clientX: 50, clientY: 50 });
fireEvent.click(await screen.findByRole('menuitem', { name: 'events.duplicate' }));
return screen.findByRole('dialog', { name: 'events.create' });
}
it('saves nothing until Save, and the form holds the copy at the same date and time', async () => {
await duplicateFromMenu();
expect((screen.getByLabelText('form.title') as HTMLInputElement).value).toBe('Standup');
expect((screen.getByLabelText('form.start_date') as HTMLInputElement).value).toBe('2026-10-12');
expect((screen.getByLabelText('form.start_time') as HTMLInputElement).value).toBe('10:00');
expect(store.createEvent).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'form.save' }));
await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1));
const data = store.createEvent.mock.calls[0][1] as Partial<CalendarEvent>;
expect(data).toMatchObject({ title: 'Standup', start: '2026-10-12T10:00:00', calendarIds: { cal: true }, description: 'Agenda in the doc' });
expect(data.uid).toBeTruthy();
expect(data.uid).not.toBe('u1');
expect(Object.values(data.locations ?? {})[0]?.name).toBe('Room 4');
const emails = Object.values(data.participants ?? {}).map((p) => p.email);
expect(emails).toContain('ben@example.com');
// The copy is the user's own: the user organizes it.
const owner = Object.values(data.participants ?? {}).find((p) => p.roles?.owner);
expect(owner?.email).toBe('me@example.com');
});
it('leaves nothing behind when the copy is closed', async () => {
await duplicateFromMenu();
fireEvent.click(screen.getByRole('button', { name: 'form.cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'events.create' })).toBeNull());
expect(store.createEvent).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,37 @@
import { render, renderHook, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { EventCard } from '../event-card';
import { useDisplayHours } from '@/hooks/use-display-hours';
import type { CalendarEvent } from '@/lib/jmap/types';
// pim-p2-19: the part of "Night train to Vienna" after midnight starts at the
// top of the day, scrolled out of view, so the week showed a block with no
// title. The title line now sticks under the grid's header while the block is
// in sight. A block that hid its overflow was a scroller of its own, and a
// sticky line inside one sticks to the block, not to the grid.
const train = {
id: 'n1', '@type': 'Event', uid: 'n1', title: 'Night train to Vienna', start: '2026-11-07T00:00:00',
duration: 'PT7H40M', showWithoutTime: false, status: 'confirmed', calendarIds: { cal: true }, participants: null,
} as unknown as CalendarEvent;
describe('the title of a long block stays in sight', () => {
it('sticks the title line under the header and clips the block without making it a scroller', () => {
render(<EventCard event={train} variant="block" continuesBefore />);
const card = screen.getByRole('button', { name: /Night train to Vienna/ });
expect(card.className).toContain('overflow-clip');
expect(card.className).not.toContain('overflow-hidden');
const line = card.querySelector('[data-event-title-line]') as HTMLElement;
expect(line.className).toContain('sticky');
expect(line.className).toContain('top-[var(--grid-header-height,0px)]');
expect(line.textContent).toContain('Night train to Vienna');
});
it('tells the grid how tall its header is, for the sticky title', () => {
const pane = document.createElement('div');
const header = document.createElement('div');
Object.defineProperty(header, 'offsetHeight', { value: 96 });
renderHook(() => useDisplayHours({ scrollRef: { current: pane }, headerRef: { current: header }, minHourHeight: 48 }));
expect(pane.style.getPropertyValue('--grid-header-height')).toBe('96px');
});
});
@@ -50,7 +50,16 @@ async function save(onSave: ReturnType<typeof vi.fn>): Promise<{ data: Partial<C
const field = (label: string) => screen.getByLabelText(label) as HTMLInputElement;
// The picker is Settings' searchable one (pim-p2-5): type a city, pick it.
async function pickZone(city: string) {
const search = await screen.findByRole('combobox', { name: 'time_zone_search' });
fireEvent.change(search, { target: { value: city } });
fireEvent.click(await screen.findByRole('option', { name: new RegExp(`^${city}`) }));
}
beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' }));
// jsdom has no layout; the picker scrolls its active option into view.
beforeEach(() => { Element.prototype.scrollIntoView = vi.fn(); });
afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE }));
describe('the event editor keeps the event in its own time zone (pim-2)', () => {
@@ -109,7 +118,7 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () =>
// 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' } });
await pickZone('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.
@@ -119,7 +128,7 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () =>
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' } });
await pickZone('Tokyo');
const { data, state } = await save(onSave);
expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: 'Asia/Tokyo' });
expect(state.timeZoneChanged).toBe(true);
@@ -145,6 +154,40 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () =>
});
});
describe('the time zone picker is the one Settings has (pim-p2-5)', () => {
it('lists cities with their offset, common ones first, and no "Automatic"', async () => {
editor(newYork);
fireEvent.click(screen.getByText(/^time_zone_line:/));
const options = await screen.findAllByRole('option');
const names = options.map((o) => o.textContent ?? '');
// Common zones lead: UTC, then Los Angeles; no raw ids like "Africa/Abidjan" first.
expect(names[0]).toMatch(/^UTC/);
expect(names.some((n) => n.startsWith('New York') && /GMT-0[45]:00/.test(n))).toBe(true);
expect(names.some((n) => n.includes('/'))).toBe(false);
expect(screen.queryByText(/time_zone\.auto/)).toBeNull();
});
it('finds a zone by typing its city and saves the IANA id', async () => {
const onSave = editor(newYork);
fireEvent.click(screen.getByText(/^time_zone_line:/));
await pickZone('Kolkata');
const { data } = await save(onSave);
expect(['Asia/Kolkata', 'Asia/Calcutta']).toContain(data.timeZone);
});
it("shows an event's old zone id by its current city name", () => {
editor({ ...newYork, timeZone: 'Europe/Kiev' } as CalendarEvent);
expect(screen.getByText(/^time_zone_line:/).textContent).toBe('time_zone_line:{"zone":"Kyiv"}');
});
it('gives the "Time zone" link the link colour, not the near-white primary of the dark theme (pim-p2-19)', () => {
editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) });
const link = screen.getByRole('button', { name: 'time_zone' });
expect(link.className).toContain('dark:text-blue-400');
expect(link.className).not.toContain('text-primary');
});
});
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) });
@@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useSettingsStore } from '@/stores/settings-store';
import { AUTO_TIME_ZONE } from '@/lib/timezone';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// pim-p2-19: the German popover read "16:00 – 17:00 (1h)"; German writes "1 Std.".
const lang = vi.hoisted(() => ({ locale: 'de' }));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
useLocale: () => lang.locale,
useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
}));
import { EventDetailPopover } from '../event-detail-popover';
const calendar = { id: 'cal', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar;
function popover(duration: string) {
const event = {
id: 'e1', calendarIds: { cal: true }, title: 'Team sync', start: '2026-11-05T16:00:00', duration,
timeZone: 'Europe/Berlin', showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false,
} as unknown as CalendarEvent;
render(
<EventDetailPopover event={event} calendar={calendar} anchorRect={new DOMRect(100, 100, 50, 20)}
onEdit={vi.fn()} onDelete={vi.fn()} onDuplicate={vi.fn()} onClose={vi.fn()} onSaveNote={vi.fn()}
currentUserEmails={['me@example.com']} />,
);
}
beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' }));
afterEach(() => {
useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE });
lang.locale = 'de';
});
describe('the popover writes the duration in the app language', () => {
it('in German', () => {
popover('PT1H');
expect(screen.getByText('(1 Std.)')).toBeInTheDocument();
expect(screen.queryByText('(1h)')).toBeNull();
});
it('with hours and minutes', () => {
popover('PT1H30M');
expect(screen.getByText('(1 Std. 30 Min.)')).toBeInTheDocument();
});
it('in English', () => {
lang.locale = 'en';
popover('PT45M');
expect(screen.getByText('(45 min)')).toBeInTheDocument();
});
});
@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Calendar, CalendarTask } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useSettingsStore } from '@/stores/settings-store';
import { useTaskStore } from '@/stores/task-store';
// pim-p2-12: a completed task still said "Overdue" in red, and the sidebar
// counted tasks due earlier today as overdue while the list didn't.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
useFormatter: () => ({
dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('en-US', options).format(d),
}),
}));
import { TaskListView, isTaskOverdue } from '../task-list-view';
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
const calendars = [{ id: 'cal', name: 'To-do', color: '#123456', isTasksOnly: true }] as unknown as Calendar[];
function task(overrides: Partial<CalendarTask>): CalendarTask {
return {
id: 't', title: 'Pay the dentist invoice', calendarIds: { cal: true }, progress: 'needs-action',
priority: 0, due: '2026-10-20', showWithoutTime: true, ...overrides,
} as unknown as CalendarTask;
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 10, 4, 15, 0));
});
afterEach(() => {
vi.useRealTimers();
useTaskStore.setState({ tasks: [] } as never);
});
describe('done tasks are never overdue', () => {
it('shows a completed task past its date with the plain date in grey', () => {
render(<TaskListView tasks={[task({ progress: 'completed' })]} calendars={calendars} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} />);
expect(screen.queryByText('tasks.overdue')).toBeNull();
const date = screen.getByText('Oct 20');
expect(date.className).toContain('text-muted-foreground');
expect(date.className).not.toMatch(/text-red/);
});
it('still warns about an open task past its date', () => {
render(<TaskListView tasks={[task({})]} calendars={calendars} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} />);
expect(screen.getByText('tasks.overdue')).toBeInTheDocument();
});
it('says so for cancelled tasks too, and not for one due later today', () => {
expect(isTaskOverdue(task({ progress: 'cancelled' }))).toBe(false);
expect(isTaskOverdue(task({ progress: 'completed' }))).toBe(false);
expect(isTaskOverdue(task({}))).toBe(true);
expect(isTaskOverdue(task({ due: '2026-11-04T09:00:00', showWithoutTime: false }))).toBe(false);
});
it('counts overdue tasks in the sidebar by the same rule as the list', () => {
useSettingsStore.setState({ enableCalendarTasks: true } as never);
useTaskStore.setState({
tasks: [
task({ id: 'a' }),
task({ id: 'b', progress: 'completed' }),
// Due this morning: today, not overdue, as the list says.
task({ id: 'c', due: '2026-11-04T09:00:00', showWithoutTime: false }),
],
} as never);
const client = { getAccountId: () => 'me' } as unknown as IJMAPClient;
render(<CalendarSidebarPanel calendars={calendars} selectedCalendarIds={['cal']} onToggleVisibility={() => {}} client={client} />);
expect(screen.getByText(/^1 tasks\.filter_overdue$/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,42 @@
import { readFileSync } from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
// pim-p2-19: toasts at the top right covered the event sheet's title and
// close button; at the bottom right they covered its Save. They stand beside
// a sheet docked at the window's end now. jsdom has no layout, so the rule is
// checked through the cascade: the stylesheet's own rule, applied in jsdom.
const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8');
function endSheetRule(): string {
const match = /body:has\(\[data-end-sheet\]\) \[data-toast-container\] \{[^}]*\}/.exec(css);
expect(match, 'no end-sheet rule for toasts in globals.css').not.toBeNull();
return match![0];
}
describe('toasts beside an end sheet', () => {
it('move the stack past the sheet while one is open', () => {
const style = document.createElement('style');
style.textContent = endSheetRule();
document.head.appendChild(style);
const container = document.createElement('div');
container.setAttribute('data-toast-container', '');
document.body.appendChild(container);
expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).not.toContain('400px');
const sheet = document.createElement('div');
sheet.setAttribute('data-end-sheet', '');
document.body.appendChild(sheet);
expect(getComputedStyle(container).getPropertyValue('inset-inline-end')).toBe('calc(var(--end-sheet-width, 400px) + 1.25rem)');
style.remove();
container.remove();
sheet.remove();
});
it('marks the calendar event and task panels as end sheets', () => {
const app = readFileSync(path.join(process.cwd(), 'components', 'calendar', 'calendar-app.tsx'), 'utf8');
expect(app).toMatch(/data-testid="event-panel" data-end-sheet=""/);
expect(app).toMatch(/data-end-sheet="" className="w-\[400px\][^"]*"[\s\S]{0,40}<TaskModal/);
});
});
+44 -54
View File
@@ -60,7 +60,6 @@ import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context";
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
import { useEventUserEmails } from "@/hooks/use-event-user-emails";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
import { descriptionWithNote } from "@/lib/calendar-description";
import { filterTasksByCalendars } from "@/lib/calendar-tasks";
import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
@@ -68,7 +67,7 @@ import { deleteOccurrence, saveOccurrenceEdit, type EventEditorState } from "@/l
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { getClientByLocalAccountId } from "@/stores/client-registry";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { displayNow, getEffectiveTimeZone } from "@/lib/timezone";
import { accountAddress, buildCopyToCalendarData, copyTargetsFor, distinctAccountDefaultColors, eventTargets, preferredTarget, taskTargets } from "@/lib/calendar-targets";
import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
@@ -285,6 +284,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const [quickDraft, setQuickDraft] = useState<QuickCreateDraft | null>(null);
const [defaultModalTitle, setDefaultModalTitle] = useState<string | undefined>();
const [modalStartsInEdit, setModalStartsInEdit] = useState(false);
// Duplicate opens the editor on an unsaved copy of this event (pim-p2-17).
const [copySource, setCopySource] = useState<CalendarEvent | null>(null);
useEffect(() => {
if (!showEventModal) setCopySource(null);
}, [showEventModal]);
const [showTaskModal, setShowTaskModal] = useState(false);
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
@@ -745,6 +749,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setQuickDraft(null);
setDefaultModalTitle(undefined);
setEditEvent(null);
setCopySource(null);
const d = date || selectedDate;
setDefaultModalDate(d);
setDefaultModalEndDate(endDate);
@@ -757,6 +762,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => whenEditorsDiscarded(() => {
setModalStartsInEdit(startInForm);
setCopySource(null);
setEditEvent(event);
setDefaultModalDate(undefined);
setShowTaskModal(false);
@@ -1346,24 +1352,24 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
};
}, [quickDraft, pendingPreview, t]);
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
try {
const created = await createEvent(client, data);
if (!created) {
toast.error(t("notifications.event_error"));
return;
}
focusCalendarOnEvent(created);
toast.success(t("notifications.event_duplicated"));
setEditEvent(created);
setDefaultModalDate(undefined);
} catch (error) {
toast.error(describeError(error, t("notifications.event_error")));
setShowEventModal(false);
setEditEvent(null);
}
}, [client, createEvent, focusCalendarOnEvent, describeError, t]);
// Duplicate, from every place that offers it: the editor opens on a copy at
// the same date and time, as in Google and Outlook. Nothing is saved until
// Save, so a mistaken click leaves nothing behind (pim-p2-17). The copy goes
// to the original's calendar when the user can write there.
const handleDuplicateEvent = useCallback((event: CalendarEvent) => whenEditorsDiscarded(() => {
const primary = getPrimaryCalendarId(event);
const calendar = calendars.find((c) => c.id === primary);
setDefaultCalendarIdForCreate(calendar && !calendar.isTasksOnly && canCreateEventsIn(calendar, isSubscriptionCalendar) ? calendar.id : undefined);
setEditEvent(null);
setCopySource(event);
setModalStartsInEdit(true);
setDefaultModalDate(undefined);
setDefaultModalEndDate(undefined);
setDefaultModalTitle(undefined);
setShowTaskModal(false);
setEditTask(null);
setShowEventModal(true);
}), [calendars, isSubscriptionCalendar, whenEditorsDiscarded]);
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
@@ -1551,20 +1557,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
handleDeleteEvent(detailEvent.id, hasParticipants || undefined);
}, [detailEvent, closeDetail, handleDeleteEvent]);
const handleDuplicateFromDetail = useCallback(async () => {
if (!detailEvent || !client) return;
const data = buildDuplicateEventData(detailEvent);
const handleDuplicateFromDetail = useCallback(() => {
if (!detailEvent) return;
const source = detailEvent;
closeDetail();
try {
const created = await createEvent(client, data);
if (created) {
toast.success(t("notifications.event_duplicated"));
openEditModal(created);
}
} catch (error) {
toast.error(describeError(error, t("notifications.event_error")));
}
}, [detailEvent, client, createEvent, closeDetail, openEditModal, describeError, t]);
handleDuplicateEvent(source);
}, [detailEvent, closeDetail, handleDuplicateEvent]);
const handleSaveNoteFromDetail = useCallback(async (note: string) => {
if (!detailEvent || !client) return;
@@ -1579,19 +1577,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
}, [detailEvent, client, updateEvent, describeError, t]);
const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => {
if (!client) { toast.error(t("notifications.event_error")); return; }
const data = buildDuplicateEventData(event);
try {
const created = await createEvent(client, data);
if (created) {
toast.success(t("notifications.event_duplicated"));
openEditModal(created);
}
} catch (error) {
toast.error(describeError(error, t("notifications.event_error")));
}
}, [client, createEvent, openEditModal, describeError, t]);
const handleDuplicateContextMenu = useCallback((event: CalendarEvent) => {
handleDuplicateEvent(event);
}, [handleDuplicateEvent]);
const handleExportICS = useCallback((event: CalendarEvent) => {
try {
@@ -2465,10 +2453,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
on small screens, so the week keeps its width (pim-25). Above
the views' sticky headers (z-50) inside this isolated row. */}
{!isMobile && showEventModal && (
<div data-testid="event-panel" className="absolute inset-y-0 end-0 z-[55] w-[400px] max-w-full border-s border-border bg-background shadow-xl overflow-hidden">
<div data-testid="event-panel" data-end-sheet="" className="absolute inset-y-0 end-0 z-[55] w-[400px] max-w-full border-s border-border bg-background shadow-xl overflow-hidden">
<EventModal
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
key={`${editEvent?.id ?? (copySource ? `copy:${copySource.id}` : 'new')}${modalStartsInEdit ? ':edit' : ''}`}
event={editEvent}
copyOf={copySource}
calendars={displayCalendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
@@ -2482,9 +2471,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onRsvp={handleRsvp}
onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined}
onDirtyChange={setEventEditorDirty}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
onClose={() => { setShowEventModal(false); setEditEvent(null); setCopySource(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
onPreviewChange={setPendingPreview}
currentUserEmails={userEmailsFor(editEvent)}
currentUserEmails={userEmailsFor(editEvent ?? copySource)}
isSubscriptionCalendar={isSubscriptionCalendar}
maxParticipants={maxParticipants}
isMobile={false}
@@ -2494,7 +2483,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{/* Desktop task panel */}
{!isMobile && showTaskModal && (
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<div data-end-sheet="" className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<TaskModal
key={editTask?.id ?? 'new-task'}
task={editTask}
@@ -2638,8 +2627,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{showEventModal && isMobile && (
<EventModal
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
key={`${editEvent?.id ?? (copySource ? `copy:${copySource.id}` : 'new')}${modalStartsInEdit ? ':edit' : ''}`}
event={editEvent}
copyOf={copySource}
calendars={displayCalendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
@@ -2653,8 +2643,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onRsvp={handleRsvp}
onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined}
onDirtyChange={setEventEditorDirty}
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
currentUserEmails={userEmailsFor(editEvent)}
onClose={() => { setShowEventModal(false); setEditEvent(null); setCopySource(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
currentUserEmails={userEmailsFor(editEvent ?? copySource)}
isSubscriptionCalendar={isSubscriptionCalendar}
maxParticipants={maxParticipants}
isMobile={true}
@@ -21,9 +21,8 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { taskListsOf } from "@/components/calendar/task-list-view";
import { isTaskOverdue, taskListsOf } from "@/components/calendar/task-list-view";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { displayNow } from "@/lib/timezone";
import { readableTextOn } from "@/lib/color-transform";
import { shareRecipientNames, useShareRecipientLabels } from "@/hooks/use-share-recipient-labels";
import { useCvdColor } from "@/hooks/use-cvd-color";
@@ -125,10 +124,8 @@ export function CalendarSidebarPanel({
const setViewMode = useCalendarStore((s) => s.setViewMode);
const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]);
const overdueTaskCount = useMemo(() => {
const now = displayNow();
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
}, [tasks]);
// The same rule as the task list's Overdue filter (pim-p2-12).
const overdueTaskCount = useMemo(() => tasks.filter(isTaskOverdue).length, [tasks]);
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<Calendar>();
+8 -2
View File
@@ -450,8 +450,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
data-continues-after={continuesAfter || undefined}
className={cn(
// A block box: an inline one hangs its line's descent below the
// grid's last hour and makes the filled pane scroll.
"relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
// grid's last hour and makes the filled pane scroll. Clipped, not
// hidden: hidden makes the block a scroller and its title can't stick.
"relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-clip",
"hover:opacity-90 transition-opacity cursor-pointer",
// Cut at the edge of the visible hours: a square edge and an arrow
// say the event runs on past it.
@@ -470,6 +471,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{/* Shorter blocks have no room for a second line at the 60px hour, so
their start time follows the title on the same line, when the
block is wide enough to keep the title readable. */}
{/* The title stays in sight under the grid's header while the block
is: the morning part of a night train started at midnight, scrolled
out of view, and showed as a block with no name (pim-p2-19). */}
<div data-event-title-line className="sticky top-[var(--grid-header-height,0px)]">
<div className="@container">
<div className="flex items-baseline gap-1 min-w-0">
<span dir="auto" className={cn("font-medium truncate", isInactive && "line-through")}>{event.title || t("events.no_title")}</span>
@@ -487,6 +492,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
</div>
</div>
)}
</div>
{visibleCalendarName && (
<div data-calendar-name dir="auto" className="opacity-85 text-[0.65625rem] truncate">{visibleCalendarName}</div>
)}
+14 -6
View File
@@ -114,12 +114,20 @@ function computePosition(
};
}
function formatDurationDisplay(minutes: number): string {
if (minutes < 60) return `${minutes}min`;
/** "1 hr 30 min", "1 Std. 30 Min.": the language's own short units (pim-p2-19). */
export function formatDurationDisplay(minutes: number, locale: string): string {
const unit = (value: number, name: "hour" | "minute") => {
try {
return new Intl.NumberFormat(locale, { style: "unit", unit: name, unitDisplay: "short" }).format(value);
} catch {
return `${value}${name === "hour" ? "h" : "min"}`;
}
};
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (m === 0) return `${h}h`;
return `${h}h${m}min`;
if (h === 0) return unit(m, "minute");
if (m === 0) return unit(h, "hour");
return `${unit(h, "hour")} ${unit(m, "minute")}`;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>, locale: string): string | null {
@@ -506,7 +514,7 @@ export function EventDetailPopover({
</span>
</div>
<div className="text-muted-foreground text-xs">
({formatDurationDisplay(durationMinutes)})
({formatDurationDisplay(durationMinutes, locale)})
</div>
{zoned && zonedText && (
<div className="text-muted-foreground text-xs" data-testid="event-zone-times">
@@ -529,7 +537,7 @@ export function EventDetailPopover({
) : (
<div className="text-muted-foreground">
<bdi dir="ltr">{formatTime(startDate)} – {formatTime(endDate)}</bdi>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes, locale)})</span>
</div>
)}
</>
+63 -43
View File
@@ -19,7 +19,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, listTimeZones, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone";
import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone";
import { TimeZonePicker } from "@/components/settings/time-zone-picker";
import { addToWall, compareWall, displayToZoneWall, eventFormTimeZone, eventZonedTimes, timeZoneCity, wallFields, zonedTimesText, zoneFieldsToDisplay, zoneWallDurationMs, zoneWallToDisplay } from "@/lib/event-time-zone";
import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
@@ -44,7 +45,6 @@ import { changesWhen, type EditorAttendee, type EventEditorState } from "@/lib/r
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store";
import { generateUUID } from "@/lib/utils";
import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { useIsPaneScoped } from "@/hooks/use-pane-context";
@@ -88,7 +88,13 @@ interface EventModalProps {
*/
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, editor?: EventEditorState) => void | Promise<void>;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onDuplicate?: (data: Partial<CalendarEvent>) => void;
/** Opens the editor on an unsaved copy of the event (copyOf). */
onDuplicate?: (event: CalendarEvent) => void;
/**
* A new event that starts as a copy of this one: same date and time, text,
* places, repeat, alerts and guests. Nothing is saved until Save (pim-p2-17).
*/
copyOf?: CalendarEvent | null;
/** `sentBy`: answering for the calendar's owner (lib/calendar-delegate.ts). */
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus'], sentBy?: string) => void;
onClose: () => void;
@@ -269,6 +275,7 @@ export function EventModal({
onSave,
onDelete,
onDuplicate,
copyOf = null,
onRsvp,
onClose,
onPreviewChange,
@@ -301,6 +308,8 @@ export function EventModal({
? "absolute inset-0 z-40 flex flex-col bg-background"
: "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]";
const isEdit = !!event;
// What the form starts from: the event, or for a copy the event copied.
const seed = event ?? copyOf;
const formatEventDate = useFormatEventDate();
const { formatDateRangeLabel } = useCalendarLocale();
const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit");
@@ -348,9 +357,9 @@ export function EventModal({
}, [event, currentUserEmails]);
const existingParticipants = useMemo(() => {
if (!event) return [];
return getParticipantList(event, { resolveName: resolveContactName });
}, [event, resolveContactName]);
if (!seed) return [];
return getParticipantList(seed, { resolveName: resolveContactName });
}, [seed, resolveContactName]);
const organizerInfo = useMemo(() => {
if (!event?.participants) return null;
@@ -359,7 +368,7 @@ export function EventModal({
}, [event, existingParticipants]);
const getInitialStart = (): Date => {
if (event?.start) return getEventStartDate(event);
if (seed?.start) return getEventStartDate(seed);
if (defaultDate) {
const d = new Date(defaultDate);
if (defaultEndDate) return d;
@@ -373,31 +382,31 @@ export function EventModal({
};
const getInitialEnd = (): Date => {
if (event?.start) {
if (event.showWithoutTime) {
return getEventDisplayEndDate(event);
if (seed?.start) {
if (seed.showWithoutTime) {
return getEventDisplayEndDate(seed);
}
return getEventEndDate(event);
return getEventEndDate(seed);
}
if (defaultEndDate) return new Date(defaultEndDate);
return addHours(getInitialStart(), 1);
};
const [title, setTitle] = useState(event?.title || defaultTitle || "");
const [title, setTitle] = useState(seed?.title || defaultTitle || "");
// An HTML description is shown and edited as its text.
const eventDescription = useMemo(() => (event ? descriptionAsText(event) : ""), [event]);
const eventDescription = useMemo(() => (seed ? descriptionAsText(seed) : ""), [seed]);
const [description, setDescription] = useState(eventDescription);
const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
seed?.locations ? Object.values(seed.locations)[0]?.name || "" : ""
);
const [virtualLocation, setVirtualLocation] = useState(
event?.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || "" : ""
seed?.virtualLocations ? Object.values(seed.virtualLocations)[0]?.uri || "" : ""
);
// 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<string | null>(() => eventFormTimeZone(event, userTimeZone));
const [timeZone, setTimeZone] = useState<string | null>(() => eventFormTimeZone(seed, userTimeZone));
const [showZonePicker, setShowZonePicker] = useState(false);
const [initialStartWall] = useState(() => displayToZoneWall(getInitialStart(), timeZone));
const [initialEndWall] = useState(() => displayToZoneWall(getInitialEnd(), timeZone));
@@ -405,14 +414,9 @@ export function EventModal({
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);
const [allDay, setAllDay] = useState(seed?.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;
@@ -468,13 +472,13 @@ export function EventModal({
[calendars, isSubscriptionCalendar, eventLogin]
);
const [recurrence, setRecurrence] = useState<RecurrenceOption>(() => {
if (!event?.recurrenceRules?.length) return "none";
const rule = event.recurrenceRules[0];
if (!seed?.recurrenceRules?.length) return "none";
const rule = seed.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom";
});
const [customRule, setCustomRule] = useState<CalendarRecurrenceRule | null>(() => {
if (!event?.recurrenceRules?.length) return null;
const rule = event.recurrenceRules[0];
if (!seed?.recurrenceRules?.length) return null;
const rule = seed.recurrenceRules[0];
return isSimpleRecurrenceRule(rule) ? null : rule;
});
// The recurrence the editor opened with: a rule the user did not touch is
@@ -502,11 +506,11 @@ export function EventModal({
[customRule, t, locale]
);
// A new event starts on the calendar's default alerts.
const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (event ? event.useDefaultAlerts === true : true));
const [useDefaultAlerts, setUseDefaultAlerts] = useState(() => (seed ? seed.useDefaultAlerts === true : true));
// Alerts the rows can't represent (absolute triggers, offsets after the
// start or relative to the end) are kept here so they survive a save.
const [initialAlerts] = useState(() =>
event && !event.useDefaultAlerts ? alertRowsOf(event.alerts) : { rows: [], preserved: {} }
seed && !seed.useDefaultAlerts ? alertRowsOf(seed.alerts) : { rows: [], preserved: {} }
);
const [preservedAlerts, setPreservedAlerts] = useState<Record<string, CalendarEventAlert>>(initialAlerts.preserved);
const [alertRows, setAlertRows] = useState<AlertRow[]>(initialAlerts.rows);
@@ -545,7 +549,19 @@ export function EventModal({
// The attendees the editor opened with: a save for every occurrence of a
// series applies only those the user added or removed.
const [initialAttendees] = useState<EditorAttendee[]>(() => {
if (!event?.participants) return [];
if (!seed?.participants) return [];
// A copy is the user's own event: everyone else is invited, its old
// organizer too, and the user isn't a guest of it.
if (!event) {
const mine = new Set(currentUserEmails.map((e) => e.toLowerCase()));
return existingParticipants.reduce<{ name: string; email: string }[]>((acc, p) => {
const key = p.email.trim().toLowerCase();
if (!key || mine.has(key)) return acc;
mine.add(key);
acc.push({ name: p.name, email: p.email.trim() });
return acc;
}, []);
}
// Never seed the invite list with the organizer or with a repeated address:
// handleSave writes the organizer separately, so either would round-trip
// into a duplicate entry every time the event is saved (#731). The
@@ -859,9 +875,13 @@ export function EventModal({
// invitation e-mails then render as a "Description" heading over blank space.
// An HTML description the user did not touch stays as it was; one they
// edited is saved as the plain text it now is.
const keepsHtml = !!event && isHtmlDescription(event) && trimmedDescription === eventDescription.trim();
const keepsHtml = !!seed && isHtmlDescription(seed) && trimmedDescription === eventDescription.trim();
if ((trimmedDescription || event) && !keepsHtml) {
Object.assign(data, plainDescriptionFields(event, trimmedDescription));
} else if (keepsHtml && !event) {
// A copy keeps the formatted description it was made from.
data.description = seed.description;
data.descriptionContentType = seed.descriptionContentType;
}
if (!event) {
@@ -1011,7 +1031,7 @@ export function EventModal({
savingRef.current = false;
setIsSaving(false);
}
}, [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]);
}, [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, seed, eventDescription, onSave, isSaving, t]);
// The user's own answer, and the owner's in a calendar shared with them
// (lib/calendar-delegate.ts).
@@ -1028,7 +1048,7 @@ export function EventModal({
const handleDuplicate = useCallback(() => {
if (!event || !onDuplicate) return;
onDuplicate(buildDuplicateEventData(event));
onDuplicate(event);
}, [event, onDuplicate]);
const titleRef = useRef<HTMLInputElement>(null);
@@ -1426,17 +1446,16 @@ export function EventModal({
<div data-testid="event-time-zone">
{showZonePicker ? (
<>
<label htmlFor={labelFor("time_zone")} className="text-sm font-medium mb-1 block">{tEvent("time_zone")}</label>
<select
id={labelFor("time_zone")}
<div className="text-sm font-medium mb-1">{tEvent("time_zone")}</div>
{/* Settings' picker (pim-p2-5): cities with offsets, common zones first, searchable. */}
<TimeZonePicker
value={fieldZone}
onChange={(e) => setTimeZone(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-hidden"
>
{zoneOptions.map((zone) => (
<option key={zone} value={zone}>{zone.replace(/_/g, " ")}</option>
))}
</select>
onChange={setTimeZone}
withAuto={false}
label={tEvent("time_zone")}
className="w-full"
defaultOpen
/>
</>
) : fieldZone !== userTimeZone ? (
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
@@ -1448,7 +1467,8 @@ export function EventModal({
)}
</div>
) : (
<button type="button" onClick={() => setShowZonePicker(true)} className="text-xs text-primary hover:underline">
// A link in both themes: dark's primary is near white and read as a label (pim-p2-19).
<button type="button" onClick={() => setShowZonePicker(true)} className="text-xs text-blue-600 dark:text-blue-400 underline-offset-2 hover:underline">
{tEvent("time_zone")}
</button>
)}
+19 -7
View File
@@ -125,9 +125,24 @@ function getTaskPriorityIcon(priority: number) {
type FormatDate = ReturnType<typeof useDisplayDateFormatter>["dateTime"];
function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<typeof useTranslations>, timeFormat: string, formatDate: FormatDate): { label: string; className: string } {
/** Done and cancelled tasks are never overdue (pim-p2-12); a task due today isn't yet. */
export function isTaskOverdue(task: Pick<CalendarTask, "due" | "progress">): boolean {
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
const dueDate = parseISO(task.due);
return isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate);
}
export function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<typeof useTranslations>, timeFormat: string, formatDate: FormatDate, done = false): { label: string; className: string } {
const dueDate = parseISO(due);
const overdue = isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate);
const overdue = !done && isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate);
// A finished task's date is a fact, not a warning: plain date, grey.
if (done) {
const formatted = showWithoutTime
? formatDate(dueDate, { month: "short", day: "numeric" })
: formatDate(dueDate, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: timeFormat === "12h" });
return { label: formatted, className: "text-muted-foreground" };
}
if (isDisplayToday(dueDate)) {
return {
@@ -184,10 +199,7 @@ export function filterTasksForList(
result = result.filter(task => task.progress === "completed");
break;
case "overdue":
result = result.filter(task => {
if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
});
result = result.filter(isTaskOverdue);
break;
}
@@ -260,7 +272,7 @@ export function TaskListView({
const cal = calendars.find(c => task.calendarIds[c.id]);
const isCompleted = task.progress === "completed";
const priorityIcon = getTaskPriorityIcon(task.priority);
const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate) : null;
const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate, task.progress === "completed" || task.progress === "cancelled") : null;
return (
<div
@@ -0,0 +1,262 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CalendarInvitationBanner } from '../calendar-invitation-banner';
import type { Email } from '@/lib/jmap/types';
const mocks = vi.hoisted(() => {
const pushMock = vi.fn();
const clientMock = {
parseCalendarEvents: vi.fn(),
getAccountId: vi.fn(() => 'mail-account'),
getCalendarsAccountId: vi.fn(() => 'calendar-account'),
getCalendarEvent: vi.fn(),
queryCalendarEvents: vi.fn(async (): Promise<unknown[]> => []),
// Who owns a shared or group account (Principal/get).
getAccountOwners: vi.fn(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, id === 'team'
? { id, type: 'group', name: 'team@example.com', email: 'team@example.com' }
: { id, type: 'individual', name: id + '@example.com', email: id + '@example.com' }]))),
};
const sourceClientMock = {
parseCalendarEvents: vi.fn(),
fetchBlob: vi.fn(),
getAccountId: vi.fn(() => 'source-mail-account'),
getCalendarsAccountId: vi.fn(() => 'source-calendar-account'),
};
const authState = {
client: clientMock,
primaryIdentity: { email: 'user@example.com' },
identities: [{ email: 'user@example.com' }] as Array<{ email: string }>,
getClientForAccount: vi.fn((id: string) => (id === 'login-b' ? sourceClientMock : undefined)),
};
const emailState = {
viewingAccountId: null as string | null,
selectedMailbox: 'inbox',
mailboxes: [] as Array<Record<string, unknown>>,
accountMailboxes: {} as Record<string, Array<Record<string, unknown>>>,
};
const settingsState = {
calendarInvitationParsingEnabled: true,
};
const calendarState = {
calendars: [{ id: 'cal-1', name: 'Primary', color: '#2563eb', isDefault: true }],
supportsCalendar: true,
importEvents: vi.fn(),
rsvpEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
events: [] as Array<Record<string, unknown>>,
setSelectedDate: vi.fn(),
};
const useCalendarStoreMock = ((selector?: (state: typeof calendarState) => unknown) => (
typeof selector === 'function' ? selector(calendarState) : calendarState
)) as {
(selector?: (state: typeof calendarState) => unknown): unknown;
getState: () => typeof calendarState;
setState: (updater: Partial<typeof calendarState> | ((state: typeof calendarState) => Partial<typeof calendarState> | typeof calendarState)) => void;
};
useCalendarStoreMock.getState = () => calendarState;
useCalendarStoreMock.setState = (updater) => {
const nextState = typeof updater === 'function' ? updater(calendarState) : updater;
Object.assign(calendarState, nextState);
};
return {
pushMock,
clientMock,
sourceClientMock,
authState,
emailState,
settingsState,
calendarState,
useCalendarStoreMock,
};
});
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, string>) => {
const strings: Record<string, string> = {
loading: 'Loading event details…',
title: 'Calendar Invitation',
organizer: 'Organized by {name}',
attendees: 'attendees',
add_to_calendar: 'Add to calendar',
added: 'Added to calendar',
rsvp_sent: 'Response sent',
already_in_calendar: 'Already in your calendar',
your_response: 'Your response: {status}',
response_accepted: 'Accepted',
response_needed: 'Needs response',
actor_response_info: '{name} responded {status}.',
actor_sent_info: 'Sent by {name}.',
actor_counter_info: '{name} proposed changes to this event.',
actor_refresh_info: '{name} asked for the latest event details.',
actor_declined_counter_info: '{name} declined the counter proposal.',
actor_note: 'Note: {comment}',
actor_unknown: 'Someone',
sender_mismatch_unverified_info: 'This invitation was sent from {sender}, while the organizer listed in the calendar data is {organizer}, and the message could not be verified.',
attendee_mismatch_unverified_info: 'This response was sent from {sender}, while the attendee answering in the calendar data is {attendee}, and the message could not be verified.',
action_failed: 'Could not complete that calendar action.',
proposal_applied: 'Proposed changes applied.',
proposed_changes: 'Proposed changes',
change_title: 'Title',
change_time: 'Time',
change_location: 'Location',
change_description: 'Description',
change_empty: 'None',
change_from_to: '{before} -> {after}',
apply_proposal: 'Apply proposed changes',
review_proposal: 'Review proposal',
review_request: 'Review request',
view_in_calendar: 'View in calendar',
organizer_role: 'You organize this event',
accept: 'Accept',
maybe: 'Maybe',
decline: 'Decline',
select_calendar: 'Select calendar',
participant_limit: 'The server allows at most {max} participants per event.',
answered_accepted: 'You accepted on {date}.',
answered_declined: 'You declined on {date}.',
response_declined: 'Declined',
request_info: 'Respond to let the organizer know.',
repeats: 'Repeats: {rule}',
declined_dates: 'You declined {dates}.',
declined_many: 'You declined {count} meetings of this series.',
answers_series: 'Your answer is for every meeting in the series.',
'recurrence.weekly': 'Weekly',
'recurrence.on_days': 'on {days}',
};
let message = strings[key] ?? key;
if (values) {
for (const [name, value] of Object.entries(values)) {
message = message.replace(`{${name}}`, String(value));
}
}
return message;
},
useLocale: () => 'en-US',
useFormatter: () => ({
dateTime: (date: Date) => date.toISOString(),
}),
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: mocks.pushMock }),
}));
vi.mock('@/stores/auth-store', () => ({
useAuthStore: (selector: (state: typeof mocks.authState) => unknown) => selector(mocks.authState),
}));
vi.mock('@/stores/email-store', () => ({
useEmailStore: (selector: (state: typeof mocks.emailState) => unknown) => selector(mocks.emailState),
}));
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: (selector: (state: typeof mocks.settingsState) => unknown) => selector(mocks.settingsState),
}));
vi.mock('@/stores/calendar-store', () => ({
useCalendarStore: mocks.useCalendarStoreMock,
}));
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: 'email-1',
threadId: 'thread-1',
mailboxIds: { inbox: true },
keywords: {},
size: 512,
receivedAt: '2026-03-16T10:00:00Z',
hasAttachment: true,
attachments: [
{
partId: '1',
blobId: 'blob-1',
size: 256,
type: 'text/calendar; method=REQUEST',
name: 'invite.ics',
},
],
...overrides,
};
}
// pim-p2-8 (and pim-23): a weekly invitation where one week was declined said
// "Your response: Declined. You declined on Wed, Nov 4." while the calendar
// showed the following weeks unanswered, and nothing said it repeats. The
// card now reads the series as stored: its own answer, the declined weeks by
// date, that it repeats, and that the buttons answer every week.
const me = { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'needs-action' };
const ben = { '@type': 'Participant', email: 'ben@example.com', name: 'Ben', roles: { owner: true, attendee: true }, participationStatus: 'accepted' };
const rule = { '@type': 'RecurrenceRule', frequency: 'weekly', byDay: [{ '@type': 'NDay', day: 'tu' }] };
const invitation = {
uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M', timeZone: 'Europe/Berlin',
recurrenceRules: [rule], participants: { me, ben },
};
// The series as Stalwart 1.0 stores it: Nov 3 declined on its own, as a pointer patch.
const master = {
id: 'series-1', uid: 'weekly-1', title: 'Weekly sync with Ben', start: '2026-11-03T16:00:00', duration: 'PT30M',
timeZone: 'Europe/Berlin', recurrenceRules: [rule], participants: { me, ben },
recurrenceOverrides: { '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } },
};
// What the grid holds: the expanded occurrences, the declined one first.
const nov3 = {
...master, id: 'occ-nov3', recurrenceId: '2026-11-03T16:00:00', recurrenceOverrides: null,
participants: { me: { ...me, participationStatus: 'declined' }, ben }, updated: '2026-11-04T09:00:00Z',
};
const nov10 = { ...master, id: 'occ-nov10', recurrenceId: '2026-11-10T16:00:00', start: '2026-11-10T16:00:00', recurrenceOverrides: null };
describe('an invitation to a series', () => {
beforeEach(() => {
mocks.settingsState.calendarInvitationParsingEnabled = true;
mocks.clientMock.parseCalendarEvents.mockReset();
mocks.clientMock.queryCalendarEvents.mockReset();
mocks.calendarState.rsvpEvent.mockReset();
mocks.calendarState.events = [nov3, nov10] as never;
mocks.clientMock.parseCalendarEvents.mockResolvedValue([invitation]);
mocks.clientMock.queryCalendarEvents.mockImplementation(async () => [master]);
mocks.authState.identities = [{ email: 'user@example.com' }];
});
it("shows the series' own answer and the declined week by its date", async () => {
render(<CalendarInvitationBanner email={makeEmail()} />);
expect(await screen.findByText('You declined Nov 3.')).toBeInTheDocument();
expect(screen.queryByText('Your response: Declined')).not.toBeInTheDocument();
expect(screen.queryByText(/^You declined on/)).not.toBeInTheDocument();
expect(screen.getByText('Your response: Needs response')).toBeInTheDocument();
expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'weekly-1' });
});
it('says that it repeats, and that an answer is for every week', async () => {
render(<CalendarInvitationBanner email={makeEmail()} />);
expect(await screen.findByTestId('invitation-repeats')).toHaveTextContent('Repeats: Weekly on Tue');
expect(await screen.findByText('Your answer is for every meeting in the series.')).toBeInTheDocument();
});
it('answers the series, not the week the calendar held first', async () => {
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
render(<CalendarInvitationBanner email={makeEmail()} />);
await screen.findByText('You declined Nov 3.');
fireEvent.click(screen.getByText('Accept'));
await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalled());
expect(mocks.calendarState.rsvpEvent.mock.calls[0][1]).toBe('series-1');
expect(mocks.calendarState.rsvpEvent.mock.calls[0][3]).toBe('accepted');
});
it('leaves a one-off invitation as it was', async () => {
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ ...invitation, uid: 'once', recurrenceRules: null }]);
render(<CalendarInvitationBanner email={makeEmail()} />);
await screen.findByText('Weekly sync with Ben');
expect(screen.queryByTestId('invitation-repeats')).not.toBeInTheDocument();
expect(screen.queryByTestId('invitation-answers-series')).not.toBeInTheDocument();
});
});
@@ -18,6 +18,7 @@ import {
AlertCircle,
ChevronDown,
ChevronUp,
Repeat,
} from '@/components/icons';
import { useTranslations, useLocale } from 'next-intl';
import { InvitationPrintSync } from '@/lib/print/invitation-print';
@@ -39,6 +40,8 @@ import {
invitationAnsweredAt,
invitationAnsweredKey,
findParticipantByEmails,
isSeriesInvitation,
seriesAnswers,
extractMethodFromRawIcs,
type EventSummary,
type InvitationMethod,
@@ -55,6 +58,7 @@ import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message';
import { useAccountOwner } from '@/components/calendar/answer-bars';
import { useCollectionNames } from "@/hooks/use-collection-names";
import { resolveTimeZone } from '@/lib/timezone';
import { buildRecurrenceSummary } from '@/components/calendar/recurrence-editor';
interface InvitationChangeItem {
label: string;
@@ -384,6 +388,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const shownName = useCollectionNames();
const t = useTranslations('email_viewer.calendar_invitation');
const tEvent = useTranslations('ux_pim_event');
const tCalendar = useTranslations('calendar');
const tSeries = useTranslations('ux_p2_pim_calx');
const describeError = useCalendarErrorMessage();
const locale = useLocale();
const router = useRouter();
@@ -479,6 +485,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
// What removing a cancelled meeting from the calendar takes, read from the
// events stored with its uid; null until known.
const [cancelRemoval, setCancelRemoval] = useState<CancellationRemoval | null>(null);
// An invitation to a series is answered as a series (pim-p2-8): the series as
// stored, with the occurrences answered on their own. The calendar holds the
// occurrences, and the first of them was read as the answer.
const [seriesMaster, setSeriesMaster] = useState<CalendarEvent | null>(null);
const attachment = findCalendarAttachment(email);
@@ -577,6 +587,20 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
const method = detectedMethod !== 'unknown' ? detectedMethod : rawIcsMethod;
const isCancellation = method === 'cancel';
const seriesUid = isSeriesInvitation(parsedEvent) ? parsedEvent?.uid ?? null : null;
useEffect(() => {
setSeriesMaster(null);
if (!client || !supportsCalendar || !seriesUid || sharedMailAccount) return;
let current = true;
client.queryCalendarEvents({ uid: seriesUid })
.then((stored) => {
const master = stored.find((e) => e.uid === seriesUid && !e.recurrenceId && !e.isShared) ?? null;
if (current) setSeriesMaster(master);
})
.catch(() => { /* the occurrence the calendar holds stands in */ });
return () => { current = false; };
}, [client, supportsCalendar, seriesUid, sharedMailAccount]);
useEffect(() => {
setCancelRemoval(null);
if (!client || !supportsCalendar || !isCancellation || !parsedEvent?.uid) return;
@@ -634,9 +658,14 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
// answering there would answer for them (or, on Stalwart 0.16, for
// whichever address it picks; the duplicate attendees and wrong senders of
// #634).
const existingEvent = parsedEvent?.uid
const storedCopy = parsedEvent?.uid
? storeEvents.find((e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared))
: null;
const existingEvent = seriesMaster ?? storedCopy;
const isSeries = isSeriesInvitation(parsedEvent);
const seriesRule = isSeries && parsedEvent?.recurrenceRules?.[0]
? buildRecurrenceSummary(parsedEvent.recurrenceRules[0], tCalendar, locale)
: null;
const currentUserParticipant = existingEvent
? findParticipantByEmails(existingEvent, actingEmails)
@@ -679,6 +708,21 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
})
: null;
const bannerInfo = answeredInfo ?? getBannerInfo(t, method, userIsOrganizer, supportsCalendar);
// "You declined Nov 3": the weeks answered no on their own, by their date.
const declinedOccurrences = seriesMaster && method === 'request' && !rsvpStatus
? seriesAnswers(seriesMaster, actingEmails).declined
: [];
const occurrenceDate = (recurrenceId: string) => {
const at = new Date(`${recurrenceId.slice(0, 19)}Z`);
return isNaN(at.getTime()) ? recurrenceId : new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' }).format(at);
};
const declinedInfo = declinedOccurrences.length === 0
? null
: declinedOccurrences.length > 3
? tSeries('declined_many', { count: declinedOccurrences.length })
: tSeries('declined_dates', {
dates: new Intl.ListFormat(locale, { type: 'conjunction' }).format(declinedOccurrences.map(occurrenceDate)),
});
const trustAssessment = parsedEvent && !userIsSender ? getInvitationTrustAssessment(parsedEvent, email, method, identityEmails) : null;
const trustMessage = trustAssessment ? getTrustMessage(t, trustAssessment) : null;
const participationLabel = getParticipationLabel(t, currentRsvp);
@@ -1043,6 +1087,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
</span>
</span>
)}
{seriesRule && (
<span className="flex items-center gap-1.5 min-w-0" data-testid="invitation-repeats">
<Repeat className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{tSeries('repeats', { rule: seriesRule })}</span>
</span>
)}
{summary.location && (
<span className="flex items-center gap-1.5 min-w-0">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
@@ -1112,9 +1162,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)}
{/* Info / actor messages */}
{showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && (
{showDetails && (bannerInfo || declinedInfo || actorMessage || actorSummary?.participationComment) && (
<div className="space-y-0.5 text-xs text-muted-foreground">
{bannerInfo && <p className="leading-relaxed">{bannerInfo}</p>}
{declinedInfo && <p className="leading-relaxed" data-testid="invitation-declined-occurrences">{declinedInfo}</p>}
{cancellationOutdated && <p className="leading-relaxed">{t('cancel_outdated')}</p>}
{actorMessage && <p>{actorMessage}</p>}
{actorSummary?.participationComment && (
@@ -1206,6 +1257,9 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
<X className="w-3.5 h-3.5" />
{t('decline')}
</button>
{isSeries && (
<span className="text-xs text-muted-foreground" data-testid="invitation-answers-series">{tSeries('answers_series')}</span>
)}
<div className="w-px h-5 bg-border mx-1" />
</>
)}
+22 -5
View File
@@ -12,7 +12,23 @@ import { getEffectiveLocale } from '@/i18n/detect-locale';
* GMT+01:00"), the common zones first. Searching finds a zone by city, id,
* offset ("+05:30", "UTC-5") or its name in the UI language.
*/
export function TimeZonePicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
export function TimeZonePicker({
value,
onChange,
withAuto = true,
label,
className = 'w-64 max-w-full',
defaultOpen,
}: {
value: string;
onChange: (value: string) => void;
/** "Automatic (<browser zone>)" first. Off where a zone has to be named, as for an event. */
withAuto?: boolean;
/** The field's name, when it isn't Settings' "Time zone". */
label?: string;
className?: string;
defaultOpen?: boolean;
}) {
const t = useTranslations('settings.language_region');
const tz = useTranslations('uxs_general');
@@ -40,11 +56,11 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
group,
});
return [
{ value: AUTO_TIME_ZONE, label: t('time_zone.auto', { zone: timeZoneCity(browserTimeZone) }) },
...(withAuto ? [{ value: AUTO_TIME_ZONE, label: t('time_zone.auto', { zone: timeZoneCity(browserTimeZone) }) }] : []),
...common.map(toOption(tz('time_zone_common'))),
...all.map(toOption(tz('time_zone_all'))),
];
}, [zones, value, browserTimeZone, t, tz, offsets]);
}, [zones, value, browserTimeZone, t, tz, offsets, withAuto]);
// "CET" and "GMT+1" find their zones first (settings-p2-20).
const rank = useCallback(
(query: string, option: SearchableOption, wordsMatch: boolean) =>
@@ -55,13 +71,14 @@ export function TimeZonePicker({ value, onChange }: { value: string; onChange: (
const current = options.find((option) => option.value === value);
return (
<SearchableSelect
className="w-64 max-w-full"
className={className}
defaultOpen={defaultOpen}
menuClassName="w-72"
value={value}
options={options}
onSelect={onChange}
rank={rank}
ariaLabel={`${t('time_zone.label')}: ${current?.label ?? timeZoneCity(value)}`}
ariaLabel={`${label ?? t('time_zone.label')}: ${current?.label ?? timeZoneCity(value)}`}
searchLabel={tz('time_zone_search')}
noMatchLabel={tz('time_zone_no_match')}
renderValue={(option) => (
+5 -2
View File
@@ -40,6 +40,8 @@ interface SearchableSelectProps {
* their order.
*/
rank?: (query: string, option: SearchableOption, wordsMatch: boolean) => number | null;
/** Opens the list on mount, for a field that appears because the user asked to change it. */
defaultOpen?: boolean;
}
/**
@@ -58,10 +60,11 @@ export function SearchableSelect({
className,
menuClassName,
rank,
defaultOpen = false,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const [active, setActive] = useState(() => (defaultOpen ? Math.max(0, options.findIndex((o) => o.value === value)) : 0));
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
+7 -1
View File
@@ -58,7 +58,13 @@ export function useDisplayHours({ scrollRef, minHourHeight, headerRef, revealAt
useLayoutEffect(() => {
const root = scrollRef.current;
if (!root) return;
const measure = () => setAvailable(Math.max(0, root.clientHeight - (headerRef?.current?.offsetHeight ?? 0)));
const measure = () => {
const headerHeight = headerRef?.current?.offsetHeight ?? 0;
setAvailable(Math.max(0, root.clientHeight - headerHeight));
// Where an event's title sticks while its block runs above the visible
// hours: an overnight event's morning part (pim-p2-19).
root.style?.setProperty("--grid-header-height", `${headerHeight}px`);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
+269
View File
@@ -0,0 +1,269 @@
import { test, expect, type Page } from '@playwright/test';
import { addDays, format } from 'date-fns';
import { contextOptions } from './helpers/ent';
import { JmapClient } from './helpers/jmap';
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
/**
* Usability pass 2, calendar fixes pim-p2-5 (the event's time zone picker),
* pim-p2-17 (Duplicate opens an unsaved copy) and pim-p2-8 (an invitation to
* a series), against a VM 106 Stalwart (1.0 and 0.16). Every UI step is
* checked on the server.
*
* Accounts: dso-ux-pim (A) and dso-ux-pim-b (B, the organiser of the series),
* password IT_DSO_PASSWORD. The spec makes its own items, titled
* "UXP2-calx <run> …", and removes them again on both accounts.
*/
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
const A = 'dso-ux-pim@example.org';
const B = 'dso-ux-pim-b@example.org';
const CORE = 'urn:ietf:params:jmap:core';
const CAL = 'urn:ietf:params:jmap:calendars';
const MAIL = 'urn:ietf:params:jmap:mail';
const RUN = Date.now().toString(36);
const TZ = 'Europe/Berlin';
const PREFIX = `UXP2-calx ${RUN}`;
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
type Participant = { calendarAddress?: string; participationStatus?: string };
type Obj = Record<string, unknown> & {
id: string; uid?: string; title?: string; start?: string; timeZone?: string | null; duration?: string;
recurrenceOverrides?: Record<string, Record<string, unknown>> | null;
participants?: Record<string, Participant> | null;
};
async function defaultCalendar(c: JmapClient): Promise<string> {
const r = await c.request([['Calendar/get', { accountId: c.accountId, properties: ['id', 'name', 'isDefault'] }, '0']], [CORE, CAL]);
const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>;
return (list.find((cal) => cal.isDefault) ?? list[0]).id;
}
/** Events whose title starts with `prefix`, as the server keeps them (base events, not expanded). */
async function objects(c: JmapClient, prefix: string): Promise<Obj[]> {
const r = await c.request([
['CalendarEvent/query', { accountId: c.accountId, limit: 1000 }, 'q'],
['CalendarEvent/get', {
accountId: c.accountId,
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
properties: ['id', 'uid', 'title', 'start', 'timeZone', 'duration', 'participants'],
}, 'g'],
], [CORE, CAL]);
return (r.methodResponses[1][1].list as Obj[]).filter((o) => typeof o.title === 'string' && o.title.startsWith(prefix));
}
/** Stalwart 1.0 refuses recurrenceOverrides in a get with other properties: on their own. */
async function overrides(c: JmapClient, id: string): Promise<Record<string, Record<string, unknown>>> {
const r = await c.request([['CalendarEvent/get', { accountId: c.accountId, ids: [id], properties: ['id', 'recurrenceOverrides'] }, '0']], [CORE, CAL]);
return (r.methodResponses[0][1].list?.[0]?.recurrenceOverrides ?? {}) as Record<string, Record<string, unknown>>;
}
async function destroyAll(c: JmapClient, prefix: string): Promise<void> {
const ids = (await objects(c, prefix)).map((o) => o.id);
if (ids.length) await c.request([['CalendarEvent/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CAL]);
}
async function mails(c: JmapClient, word: string): Promise<string[]> {
const r = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: word }, limit: 50 }, 'q']], [CORE, MAIL]);
return r.methodResponses[0][1].ids as string[];
}
async function destroyMails(c: JmapClient, word: string): Promise<void> {
const ids = await mails(c, word);
if (ids.length) await c.request([['Email/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, MAIL]);
}
async function createEvent(c: JmapClient, event: Record<string, unknown>): Promise<string> {
const r = await c.request([['CalendarEvent/set', {
accountId: c.accountId,
create: { e: { '@type': 'Event', calendarIds: { [await defaultCalendar(c)]: true }, ...event } },
}, '0']], [CORE, CAL]);
const id = r.methodResponses[0][1].created?.e?.id as string | undefined;
expect(id, JSON.stringify(r.methodResponses[0][1].notCreated ?? {})).toBeTruthy();
return id!;
}
async function login(page: Page, email: string): Promise<void> {
await neutralizeDevOverlay(page);
await page.goto('/');
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
await page.fill('#username', email);
await page.fill('#password', PASSWORD);
await page.click('button[type="submit"]');
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
}
// Event chips are buttons named "<title>, <time>, <calendar>".
const card = (page: Page, title: string) => page.locator(`button[aria-label^="${title},"]`).first();
const day = (offset: number) => format(addDays(new Date(), offset), 'yyyy-MM-dd');
test.afterAll(async () => {
if (!PASSWORD) return;
for (const who of [A, B]) {
const c = await JmapClient.connect(who, PASSWORD);
await destroyAll(c, PREFIX);
await destroyMails(c, PREFIX);
}
});
test.describe('The event editor (pim-p2-5, pim-p2-17)', () => {
test.describe.configure({ mode: 'serial' });
test('picks a time zone by typing its city and saves the zone id', async ({ browser }) => {
test.setTimeout(240_000);
const a = await JmapClient.connect(A, PASSWORD);
const when = day(10);
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ });
const page = await context.newPage();
await login(page, A);
await page.goto(`/calendar/week/${when}`);
await expect(page.locator('[data-testid="calendar-item"]').first()).toBeVisible({ timeout: 60000 });
const title = page.getByLabel('Title', { exact: true });
await expect(async () => {
await page.keyboard.press('n');
await expect(title).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 60000 });
await title.fill(`${PREFIX} Kolkata call`);
await page.getByLabel('Start date', { exact: true }).fill(when);
await page.getByLabel('Start time', { exact: true }).fill('10:00');
await page.getByLabel('End date', { exact: true }).fill(when);
await page.getByLabel('End time', { exact: true }).fill('11:00');
await page.getByRole('button', { name: 'Time zone', exact: true }).click();
// The list opens with a search: cities and offsets, no raw ids.
const search = page.getByRole('combobox', { name: 'Search time zones' });
await expect(search).toBeFocused();
await expect(page.getByRole('option', { name: /^New York/ }).first()).toBeVisible();
await expect(page.getByRole('option', { name: /Africa\/Abidjan/ })).toHaveCount(0);
await search.fill('Kolkata');
await expect(page.getByRole('option', { name: /^Kolkata.*GMT\+05:30/ })).toBeVisible();
await search.press('Enter');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(async () => {
const [e] = await objects(a, `${PREFIX} Kolkata call`);
return e && { start: e.start, timeZone: e.timeZone };
}, { timeout: 30000 }).toMatchObject({ start: `${when}T10:00:00` });
const [saved] = await objects(a, `${PREFIX} Kolkata call`);
expect(['Asia/Kolkata', 'Asia/Calcutta']).toContain(saved.timeZone);
await context.close();
});
test('Duplicate creates nothing until Save, at the same date and time', async ({ browser }) => {
test.setTimeout(240_000);
const a = await JmapClient.connect(A, PASSWORD);
const when = day(11);
const name = `${PREFIX} retro`;
await createEvent(a, { title: name, uid: `uxp2-calx-dup-${RUN}`, start: `${when}T14:00:00`, timeZone: TZ, duration: 'PT1H' });
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ });
const page = await context.newPage();
await login(page, A);
await page.goto(`/calendar/week/${when}`);
await expect(card(page, name)).toBeVisible({ timeout: 60000 });
// Closed without saving: nothing on the server.
await card(page, name).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
const editor = page.getByRole('dialog', { name: 'Create event' });
await expect(editor).toBeVisible();
await expect(page.getByLabel('Title', { exact: true })).toHaveValue(name);
await expect(page.getByLabel('Start date', { exact: true })).toHaveValue(when);
await expect(page.getByLabel('Start time', { exact: true })).toHaveValue('14:00');
await page.waitForTimeout(2000);
expect(await objects(a, name)).toHaveLength(1);
await editor.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(editor).toBeHidden();
await page.waitForTimeout(2000);
expect(await objects(a, name)).toHaveLength(1);
// Saved: a second event, same day and time.
await card(page, name).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await expect(editor).toBeVisible();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(async () => (await objects(a, name)).length, { timeout: 30000 }).toBe(2);
const both = await objects(a, name);
expect(both.map((e) => e.start)).toEqual([`${when}T14:00:00`, `${when}T14:00:00`]);
expect(new Set(both.map((e) => e.uid)).size).toBe(2);
await context.close();
});
});
test.describe('An invitation to a series (pim-p2-8)', () => {
test('says it repeats, names the week declined on its own, and answers the series', async ({ browser }) => {
test.setTimeout(300_000);
const a = await JmapClient.connect(A, PASSWORD);
const b = await JmapClient.connect(B, PASSWORD);
const first = day(14);
const second = day(21);
const name = `${PREFIX} weekly sync`;
const uid = `uxp2-calx-series-${RUN}`;
// B invites A to a weekly series; Stalwart delivers the invitation itself.
const invite = await b.request([['CalendarEvent/set', {
accountId: b.accountId,
sendSchedulingMessages: true,
create: {
i: {
'@type': 'Event', title: name, uid, start: `${first}T16:00:00`, duration: 'PT30M', timeZone: TZ,
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 4 },
calendarIds: { [await defaultCalendar(b)]: true },
participants: {
o: { '@type': 'Participant', calendarAddress: `mailto:${B}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
p: { '@type': 'Participant', calendarAddress: `mailto:${A}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true },
},
},
},
}, '0']], [CORE, CAL]);
expect(invite.methodResponses[0][1].created?.i?.id, JSON.stringify(invite.methodResponses[0][1].notCreated ?? {})).toBeTruthy();
await expect.poll(async () => (await mails(a, PREFIX)).length, { timeout: 90000 }).toBeGreaterThan(0);
const [mailId] = await mails(a, PREFIX);
await expect.poll(async () => (await objects(a, name)).length, { timeout: 60000 }).toBeGreaterThan(0);
const [copy] = await objects(a, name);
const mine = Object.entries(copy.participants ?? {}).find(([, p]) => p.calendarAddress?.toLowerCase() === `mailto:${A}`);
expect(mine, JSON.stringify(copy.participants)).toBeTruthy();
const [pid] = mine!;
// A declines the second week only, as the calendar does: a pointer patch
// (Stalwart 1.0), or the whole participants map where that is refused.
const recurrenceId = `${second}T16:00:00`;
const pointer = await a.request([['CalendarEvent/set', {
accountId: a.accountId,
update: { [copy.id]: { [`recurrenceOverrides/${recurrenceId}`]: { [`participants/${pid}/participationStatus`]: 'declined' } } },
}, '0']], [CORE, CAL]);
const [kind, answer] = pointer.methodResponses[0];
if (kind === 'error' || answer.notUpdated?.[copy.id]) {
const participants = structuredClone(copy.participants ?? {});
participants[pid] = { ...participants[pid], participationStatus: 'declined' };
const whole = await a.request([['CalendarEvent/set', {
accountId: a.accountId,
update: { [copy.id]: { [`recurrenceOverrides/${recurrenceId}`]: { participants } } },
}, '0']], [CORE, CAL]);
expect(whole.methodResponses[0][1].notUpdated, JSON.stringify(whole.methodResponses[0][1].notUpdated ?? {})).toBeFalsy();
}
expect(Object.keys(await overrides(a, copy.id))).toContain(recurrenceId);
const context = await browser.newContext({ ...contextOptions(), timezoneId: TZ });
const page = await context.newPage();
await login(page, A);
await page.goto(`/mail/message/${mailId}`);
// It repeats, the series is unanswered, and the declined week is named by its date.
await expect(page.getByTestId('invitation-repeats')).toContainText('Weekly', { timeout: 60000 });
const declinedDate = format(new Date(`${second}T12:00:00`), 'MMM d');
await expect(page.getByTestId('invitation-declined-occurrences')).toHaveText(`You declined ${declinedDate}.`, { timeout: 30000 });
await expect(page.getByText('Your response: Declined')).toHaveCount(0);
await expect(page.getByText(/^You declined on /)).toHaveCount(0);
await expect(page.getByTestId('invitation-answers-series')).toBeVisible();
// Accept answers the series; the declined week stays declined.
await page.getByRole('button', { name: 'Accept', exact: true }).first().click();
await expect.poll(async () => {
const [e] = await objects(a, name);
return Object.values(e?.participants ?? {}).find((p) => p.calendarAddress?.toLowerCase() === `mailto:${A}`)?.participationStatus;
}, { timeout: 30000 }).toBe('accepted');
expect(Object.keys(await overrides(a, copy.id))).toContain(recurrenceId);
await context.close();
});
});
-60
View File
@@ -1,60 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { CalendarEvent } from '@/lib/jmap/types';
import { buildDuplicateEventData } from '../calendar-duplicate';
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'ev1',
uid: 'original-uid',
title: 'Standup',
description: 'Daily sync',
start: '2026-10-05T09:30:00',
duration: 'PT30M',
timeZone: 'Europe/Berlin',
showWithoutTime: false,
calendarIds: { cal1: true },
status: 'tentative',
...overrides,
} as CalendarEvent;
}
describe('buildDuplicateEventData', () => {
it('keeps the meeting link (#1170)', () => {
const virtualLocations = {
v1: { '@type': 'VirtualLocation', name: 'Video call', uri: 'https://meet.example.org/abc' },
} as unknown as CalendarEvent['virtualLocations'];
const data = buildDuplicateEventData(makeEvent({ virtualLocations }));
expect(data.virtualLocations).toEqual(virtualLocations);
expect(data.virtualLocations).not.toBe(virtualLocations);
});
// Without the type the copy's HTML would be stored, and shown, as text.
it('keeps the format of an HTML description', () => {
const data = buildDuplicateEventData(makeEvent({ description: '<p>Daily</p>', descriptionContentType: 'text/html' }));
expect(data).toMatchObject({ description: '<p>Daily</p>', descriptionContentType: 'text/html' });
});
it('moves a timed event one day later in its own time zone with a fresh uid', () => {
const data = buildDuplicateEventData(makeEvent());
expect(data).toMatchObject({
title: 'Standup',
start: '2026-10-06T09:30:00',
duration: 'PT30M',
timeZone: 'Europe/Berlin',
calendarIds: { cal1: true },
status: 'confirmed',
});
expect(data.uid).toBeTruthy();
expect(data.uid).not.toBe('original-uid');
expect(data.id).toBeUndefined();
});
it('sends all-day copies as floating midnight starts', () => {
const data = buildDuplicateEventData(makeEvent({
start: '2026-10-05T00:00:00',
duration: 'P1D',
showWithoutTime: true,
}));
expect(data).toMatchObject({ start: '2026-10-06T00:00:00', showWithoutTime: true, timeZone: null });
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { isSeriesInvitation, seriesAnswers } from '../calendar-invitation';
import type { CalendarEvent } from '@/lib/jmap/types';
// pim-p2-8: the series' own answer, and the occurrences declined on their own.
// Stalwart 1.0 stores an occurrence's answer as a pointer patch, 0.16 as the
// whole participants map.
const me = { '@type': 'Participant', email: 'me@example.org', roles: { attendee: true }, participationStatus: 'accepted' };
const ben = { '@type': 'Participant', email: 'ben@example.org', roles: { owner: true }, participationStatus: 'accepted' };
function series(overrides: Record<string, unknown>): Partial<CalendarEvent> {
return {
uid: 'w', start: '2026-11-03T16:00:00', recurrenceRules: [{ frequency: 'weekly' }],
participants: { me, ben }, recurrenceOverrides: overrides,
} as unknown as Partial<CalendarEvent>;
}
describe('seriesAnswers', () => {
it('reads a pointer patch (Stalwart 1.0)', () => {
const result = seriesAnswers(series({
'2026-11-17T16:00:00': { 'participants/me/participationStatus': 'declined' },
'2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' },
'2026-11-10T16:00:00': { title: 'Moved room' },
}), ['me@example.org']);
expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00', '2026-11-17T16:00:00'] });
});
it('reads a whole participants map (Stalwart 0.16) and skips removed weeks', () => {
const result = seriesAnswers(series({
'2026-11-03T16:00:00': { participants: { me: { ...me, participationStatus: 'declined' }, ben } },
'2026-11-10T16:00:00': { excluded: true },
}), ['ME@example.org']);
expect(result).toEqual({ status: 'accepted', declined: ['2026-11-03T16:00:00'] });
});
it('lists none when the whole series is declined', () => {
const declined = { ...me, participationStatus: 'declined' };
const event = { ...series({ '2026-11-03T16:00:00': { 'participants/me/participationStatus': 'declined' } }), participants: { me: declined, ben } };
expect(seriesAnswers(event as unknown as Partial<CalendarEvent>, ['me@example.org'])).toEqual({ status: 'declined', declined: [] });
});
it('tells a series invitation from one for a single occurrence', () => {
expect(isSeriesInvitation(series({}))).toBe(true);
expect(isSeriesInvitation({ ...series({}), recurrenceId: '2026-11-03T16:00:00' } as Partial<CalendarEvent>)).toBe(false);
expect(isSeriesInvitation({ uid: 'x' })).toBe(false);
});
});
-35
View File
@@ -1,35 +0,0 @@
import { addDays, format, parseISO } from "date-fns";
import type { CalendarEvent } from "@/lib/jmap/types";
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
import { generateUUID } from "@/lib/utils";
/**
* Create payload for a copy of `event` one day later. Shared by the editor,
* the detail popover and the context menu - each used to build its own copy
* and two of them dropped the meeting link (`virtualLocations`, #1170).
* The start stays in the event's own wall clock and time zone.
*/
export function buildDuplicateEventData(event: CalendarEvent): Partial<CalendarEvent> {
const newStart = addDays(parseISO(event.start), 1);
const data: Partial<CalendarEvent> = {
uid: generateUUID(),
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
calendarIds: { ...event.calendarIds },
status: "confirmed",
freeBusyStatus: event.freeBusyStatus,
privacy: event.privacy,
};
// Without its type an HTML description would be stored as plain text.
if (event.descriptionContentType) data.descriptionContentType = event.descriptionContentType;
if (event.locations) data.locations = structuredClone(event.locations);
if (event.virtualLocations) data.virtualLocations = structuredClone(event.virtualLocations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
return sanitizeOutgoingCalendarEventData(data);
}
+30
View File
@@ -2,6 +2,7 @@ import type { Email, Attachment, CalendarEvent, CalendarParticipant, EmailBodyPa
import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
import { hasAlignedAuthenticationPass } from '@/lib/email-headers';
import { fromWallClock, getTimeZoneOffsetMs, isValidTimeZone, parseLocalDateTime, type WallClock } from '@/lib/timezone';
import { applyOverridePatch } from '@/lib/recurrence-instances';
export type InvitationMethod =
| 'publish'
@@ -797,6 +798,35 @@ export function invitationAnsweredAt(
* user's identities, primary first: an invite may be addressed to an alias
* identity rather than the primary address (#1283).
*/
/**
* An invitation to a series is answered as a series: the answer is the series'
* own, and occurrences answered no on their own are listed apart (pim-p2-8).
* The card said "You declined" for a weekly meeting when one week was declined,
* read from whichever occurrence the calendar held first. `master` is the
* series as stored, with its recurrenceOverrides; `declined` holds the
* recurrence ids (wall clock in the event's zone), oldest first.
*/
export function seriesAnswers(
master: Partial<CalendarEvent>,
emails: readonly string[],
): { status: string | null; declined: string[] } {
const own = findParticipantByEmails(master, emails);
const status = own?.participant.participationStatus ?? null;
const declined: string[] = [];
for (const [recurrenceId, patch] of Object.entries(master.recurrenceOverrides ?? {})) {
if (!patch || typeof patch !== 'object' || (patch as { excluded?: unknown }).excluded === true) continue;
const occurrence = applyOverridePatch(master, patch as Record<string, unknown>);
const mine = findParticipantByEmails(occurrence, emails);
if (mine?.participant.participationStatus === 'declined' && status !== 'declined') declined.push(recurrenceId);
}
return { status, declined: declined.sort() };
}
/** Whether a parsed invitation is for a whole series, not one occurrence of it. */
export function isSeriesInvitation(event: Partial<CalendarEvent> | null | undefined): boolean {
return !!event?.recurrenceRules?.length && !event.recurrenceId;
}
export function findParticipantByEmails(
event: Partial<CalendarEvent>,
emails: readonly string[]
+3 -2
View File
@@ -1,5 +1,6 @@
import { format, isSameDay } from "date-fns";
import type { CalendarEvent } from "@/lib/jmap/types";
import { timeZoneCity as friendlyCity } from "@/lib/time-zone-choices";
import {
canonicalTimeZone,
fromDisplayDate,
@@ -101,8 +102,8 @@ export function zoneWallDurationMs(a: WallClock, b: WallClock, zone: string | nu
/** "America/New_York" as people say it: "New York". */
export function timeZoneCity(zone: string): string {
const last = zone.split("/").pop() || zone;
return last.replace(/_/g, " ");
// Settings' names (pim-p2-5): current city names ("Kyiv", not "Kiev").
return friendlyCity(zone);
}
/** Whether the clocks in `a` and `b` read alike at `instant`. */
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "رفضت الدعوة.",
"answered_tentative_nodate": "أجبت بـ\"ربما\"."
},
"ux_p2_pim_calx": {
"repeats": "يتكرر: {rule}",
"declined_dates": "رفضت موعد {dates}.",
"declined_many": "رفضت {count} من مواعيد هذه السلسلة.",
"answers_series": "ينطبق ردك على كل مواعيد السلسلة."
},
"calendar_event_notifications": {
"someone": "شخص ما",
"invited": "دعاك {name} إلى \"{title}\"",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Heu rebutjat.",
"answered_tentative_nodate": "Heu respost potser."
},
"ux_p2_pim_calx": {
"repeats": "Es repeteix: {rule}",
"declined_dates": "Has rebutjat el {dates}.",
"declined_many": "Has rebutjat {count} reunions d'aquesta sèrie.",
"answers_series": "La teva resposta val per a totes les reunions de la sèrie."
},
"calendar_event_notifications": {
"someone": "Algú",
"invited": "{name} t'ha convidat a \"{title}\"",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Odmítli jste.",
"answered_tentative_nodate": "Odpověděli jste možná."
},
"ux_p2_pim_calx": {
"repeats": "Opakuje se: {rule}",
"declined_dates": "Odmítli jste {dates}.",
"declined_many": "Odmítli jste {count} schůzek této série.",
"answers_series": "Vaše odpověď platí pro všechny schůzky série."
},
"advanced_search": {
"title": "Pokročilé hledání",
"from": "Od",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Du afslog.",
"answered_tentative_nodate": "Du svarede måske."
},
"ux_p2_pim_calx": {
"repeats": "Gentages: {rule}",
"declined_dates": "Du afslog {dates}.",
"declined_many": "Du afslog {count} møder i denne serie.",
"answers_series": "Dit svar gælder alle møder i serien."
},
"calendar_event_notifications": {
"someone": "Nogen",
"invited": "{name} har inviteret dig til \"{title}\"",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Sie haben abgesagt.",
"answered_tentative_nodate": "Sie haben mit Vielleicht geantwortet."
},
"ux_p2_pim_calx": {
"repeats": "Wiederholung: {rule}",
"declined_dates": "Sie haben am {dates} abgesagt.",
"declined_many": "Sie haben {count} Termine dieser Serie abgesagt.",
"answers_series": "Ihre Antwort gilt für alle Termine der Serie."
},
"advanced_search": {
"title": "Erweiterte Suche",
"from": "Von",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "You declined.",
"answered_tentative_nodate": "You answered maybe."
},
"ux_p2_pim_calx": {
"repeats": "Repeats: {rule}",
"declined_dates": "You declined {dates}.",
"declined_many": "You declined {count} meetings of this series.",
"answers_series": "Your answer counts for every meeting in the series."
},
"calendar_event_notifications": {
"someone": "Someone",
"invited": "{name} invited you to \"{title}\"",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Rechazaste.",
"answered_tentative_nodate": "Respondiste quizás."
},
"ux_p2_pim_calx": {
"repeats": "Se repite: {rule}",
"declined_dates": "Rechazaste el {dates}.",
"declined_many": "Rechazaste {count} reuniones de esta serie.",
"answers_series": "Tu respuesta vale para todas las reuniones de la serie."
},
"advanced_search": {
"title": "Búsqueda avanzada",
"from": "De",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Keeldusite.",
"answered_tentative_nodate": "Vastasite võib-olla."
},
"ux_p2_pim_calx": {
"repeats": "Kordub: {rule}",
"declined_dates": "Lükkasite tagasi kuupäeva {dates}.",
"declined_many": "Lükkasite tagasi {count} selle sarja kohtumist.",
"answers_series": "Teie vastus kehtib sarja kõigile kohtumistele."
},
"calendar_event_notifications": {
"someone": "Keegi",
"invited": "{name} kutsus sind sündmusele \"{title}\"",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "رد کردید.",
"answered_tentative_nodate": "پاسخ «شاید» دادید."
},
"ux_p2_pim_calx": {
"repeats": "تکرار: {rule}",
"declined_dates": "جلسهٔ {dates} را رد کردید.",
"declined_many": "{count} جلسه از این سری را رد کردید.",
"answers_series": "پاسخ شما برای همهٔ جلسه‌های این سری است."
},
"calendar_event_notifications": {
"someone": "کسی",
"invited": "{name} شما را به «{title}» دعوت کرد",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Vous avez refusé.",
"answered_tentative_nodate": "Vous avez répondu peut-être."
},
"ux_p2_pim_calx": {
"repeats": "Se répète : {rule}",
"declined_dates": "Vous avez refusé le {dates}.",
"declined_many": "Vous avez refusé {count} réunions de cette série.",
"answers_series": "Votre réponse vaut pour toutes les réunions de la série."
},
"advanced_search": {
"title": "Recherche avancée",
"from": "De",
+6
View File
@@ -5127,6 +5127,12 @@
"answered_declined_nodate": "דחית.",
"answered_tentative_nodate": "ענית 'אולי'."
},
"ux_p2_pim_calx": {
"repeats": "חוזר: {rule}",
"declined_dates": "דחית את {dates}.",
"declined_many": "דחית {count} פגישות בסדרה הזו.",
"answers_series": "התשובה שלך חלה על כל הפגישות בסדרה."
},
"advanced_search": {
"title": "חיפוש מתקדם",
"from": "מ",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Ön elutasította.",
"answered_tentative_nodate": "Ön talánnal válaszolt."
},
"ux_p2_pim_calx": {
"repeats": "Ismétlődik: {rule}",
"declined_dates": "Elutasította: {dates}.",
"declined_many": "A sorozat {count} alkalmát utasította el.",
"answers_series": "A válasza a sorozat minden alkalmára vonatkozik."
},
"calendar_event_notifications": {
"someone": "Valaki",
"invited": "{name} meghívott a(z) „{title}” eseményre",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Hai rifiutato.",
"answered_tentative_nodate": "Hai risposto forse."
},
"ux_p2_pim_calx": {
"repeats": "Si ripete: {rule}",
"declined_dates": "Hai rifiutato il {dates}.",
"declined_many": "Hai rifiutato {count} riunioni di questa serie.",
"answers_series": "La tua risposta vale per tutte le riunioni della serie."
},
"advanced_search": {
"title": "Ricerca avanzata",
"from": "Da",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "辞退しました。",
"answered_tentative_nodate": "未定と回答しました。"
},
"ux_p2_pim_calx": {
"repeats": "繰り返し: {rule}",
"declined_dates": "{dates} は辞退しました。",
"declined_many": "このシリーズの {count} 件を辞退しました。",
"answers_series": "回答はシリーズのすべての会議に適用されます。"
},
"advanced_search": {
"title": "詳細検索",
"from": "差出人",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "거절했습니다.",
"answered_tentative_nodate": "미정으로 응답했습니다."
},
"ux_p2_pim_calx": {
"repeats": "반복: {rule}",
"declined_dates": "{dates} 일정을 거절했습니다.",
"declined_many": "이 시리즈의 일정 {count}개를 거절했습니다.",
"answers_series": "답변은 시리즈의 모든 일정에 적용됩니다."
},
"advanced_search": {
"title": "상세 검색",
"from": "보낸 사람",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Jūs noraidījāt.",
"answered_tentative_nodate": "Jūs atbildējāt varbūt."
},
"ux_p2_pim_calx": {
"repeats": "Atkārtojas: {rule}",
"declined_dates": "Jūs atteicāt {dates}.",
"declined_many": "Jūs atteicāt {count} šīs sērijas sapulces.",
"answers_series": "Jūsu atbilde attiecas uz visām sērijas sapulcēm."
},
"advanced_search": {
"title": "Izvērstā meklēšana",
"from": "No",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Та татгалзсан.",
"answered_tentative_nodate": "Та магадгүй гэж хариулсан."
},
"ux_p2_pim_calx": {
"repeats": "Давтагдана: {rule}",
"declined_dates": "Та {dates}-ны уулзалтаас татгалзсан.",
"declined_many": "Та энэ цувралын {count} уулзалтаас татгалзсан.",
"answers_series": "Таны хариу цувралын бүх уулзалтад хамаарна."
},
"calendar_event_notifications": {
"someone": "Хэн нэгэн",
"invited": "{name} таныг \"{title}\"-д урилаа",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Du avslo.",
"answered_tentative_nodate": "Du svarte kanskje."
},
"ux_p2_pim_calx": {
"repeats": "Gjentas: {rule}",
"declined_dates": "Du avslo {dates}.",
"declined_many": "Du avslo {count} møter i denne serien.",
"answers_series": "Svaret ditt gjelder alle møtene i serien."
},
"calendar_event_notifications": {
"someone": "Noen",
"invited": "{name} har invitert deg til «{title}»",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Je hebt geweigerd.",
"answered_tentative_nodate": "Je hebt misschien geantwoord."
},
"ux_p2_pim_calx": {
"repeats": "Herhaalt: {rule}",
"declined_dates": "U hebt {dates} afgeslagen.",
"declined_many": "U hebt {count} afspraken van deze reeks afgeslagen.",
"answers_series": "Uw antwoord geldt voor alle afspraken in de reeks."
},
"advanced_search": {
"title": "Geavanceerd zoeken",
"from": "Van",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Odrzucono.",
"answered_tentative_nodate": "Odpowiedziano „może”."
},
"ux_p2_pim_calx": {
"repeats": "Powtarza się: {rule}",
"declined_dates": "Odrzucono {dates}.",
"declined_many": "Odrzucono {count} spotkań z tej serii.",
"answers_series": "Twoja odpowiedź dotyczy wszystkich spotkań z serii."
},
"advanced_search": {
"title": "Wyszukiwanie zaawansowane",
"from": "Od",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Você recusou.",
"answered_tentative_nodate": "Você respondeu talvez."
},
"ux_p2_pim_calx": {
"repeats": "Repete-se: {rule}",
"declined_dates": "Você recusou {dates}.",
"declined_many": "Você recusou {count} reuniões desta série.",
"answers_series": "Sua resposta vale para todas as reuniões da série."
},
"advanced_search": {
"title": "Pesquisa avançada",
"from": "De",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Ai refuzat.",
"answered_tentative_nodate": "Ai răspuns poate."
},
"ux_p2_pim_calx": {
"repeats": "Se repetă: {rule}",
"declined_dates": "Ați refuzat {dates}.",
"declined_many": "Ați refuzat {count} întâlniri din această serie.",
"answers_series": "Răspunsul dvs. este pentru toate întâlnirile din serie."
},
"calendar_event_notifications": {
"someone": "Cineva",
"invited": "{name} te-a invitat la „{title}”",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Вы отклонили.",
"answered_tentative_nodate": "Вы ответили «может быть»."
},
"ux_p2_pim_calx": {
"repeats": "Повторяется: {rule}",
"declined_dates": "Вы отклонили встречу {dates}.",
"declined_many": "Вы отклонили встреч этой серии: {count}.",
"answers_series": "Ваш ответ относится ко всем встречам серии."
},
"advanced_search": {
"title": "Расширенный поиск",
"from": "От",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Odmietli ste.",
"answered_tentative_nodate": "Odpovedali ste možno."
},
"ux_p2_pim_calx": {
"repeats": "Opakuje sa: {rule}",
"declined_dates": "Odmietli ste {dates}.",
"declined_many": "Odmietli ste {count} stretnutí tejto série.",
"answers_series": "Vaša odpoveď platí pre všetky stretnutia série."
},
"calendar_event_notifications": {
"someone": "Niekto",
"invited": "{name} vás pozval(a) na „{title}“",
+6
View File
@@ -4645,6 +4645,12 @@
"answered_declined_nodate": "คุณปฏิเสธแล้ว",
"answered_tentative_nodate": "คุณตอบว่าอาจจะ"
},
"ux_p2_pim_calx": {
"repeats": "ทำซ้ำ: {rule}",
"declined_dates": "คุณปฏิเสธวันที่ {dates}",
"declined_many": "คุณปฏิเสธ {count} การประชุมในชุดนี้",
"answers_series": "คำตอบของคุณใช้กับทุกการประชุมในชุดนี้"
},
"settings": {
"title": "การตั้งค่า",
"org_locked": "กำหนดโดยองค์กรของคุณ",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Reddettiniz.",
"answered_tentative_nodate": "Belki dediniz."
},
"ux_p2_pim_calx": {
"repeats": "Tekrarlanır: {rule}",
"declined_dates": "{dates} toplantısını reddettiniz.",
"declined_many": "Bu serideki {count} toplantıyı reddettiniz.",
"answers_series": "Yanıtınız serideki tüm toplantılar için geçerlidir."
},
"calendar_event_notifications": {
"someone": "Birisi",
"invited": "{name} sizi \"{title}\" etkinliğine davet etti",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "Ви відхилили.",
"answered_tentative_nodate": "Ви відповіли «можливо»."
},
"ux_p2_pim_calx": {
"repeats": "Повторюється: {rule}",
"declined_dates": "Ви відхилили зустріч {dates}.",
"declined_many": "Ви відхилили зустрічей цієї серії: {count}.",
"answers_series": "Ваша відповідь стосується всіх зустрічей серії."
},
"advanced_search": {
"title": "Розширений пошук",
"from": "Від",
+6
View File
@@ -5245,6 +5245,12 @@
"answered_declined_nodate": "Bạn đã từ chối.",
"answered_tentative_nodate": "Bạn đã trả lời có thể."
},
"ux_p2_pim_calx": {
"repeats": "Lặp lại: {rule}",
"declined_dates": "Bạn đã từ chối ngày {dates}.",
"declined_many": "Bạn đã từ chối {count} cuộc họp trong chuỗi này.",
"answers_series": "Câu trả lời của bạn áp dụng cho mọi cuộc họp trong chuỗi."
},
"calendar_event_notifications": {
"someone": "Ai đó",
"invited": "{name} đã mời bạn tham gia \"{title}\"",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "你已拒絕。",
"answered_tentative_nodate": "你已回覆暫定。"
},
"ux_p2_pim_calx": {
"repeats": "重複:{rule}",
"declined_dates": "您已拒絕 {dates} 的會議。",
"declined_many": "您已拒絕此系列中的 {count} 場會議。",
"answers_series": "您的回覆適用於此系列的所有會議。"
},
"calendar_event_notifications": {
"someone": "有人",
"invited": "{name} 邀請您參加「{title}」",
+6
View File
@@ -5233,6 +5233,12 @@
"answered_declined_nodate": "你已拒绝。",
"answered_tentative_nodate": "你已答复暂定。"
},
"ux_p2_pim_calx": {
"repeats": "重复:{rule}",
"declined_dates": "您已拒绝 {dates} 的会议。",
"declined_many": "您已拒绝此系列中的 {count} 次会议。",
"answers_series": "您的回复适用于此系列的所有会议。"
},
"advanced_search": {
"title": "高级搜索",
"from": "发件人",