mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-ux-p2-pim-calx' into feat/do-ux-p2-pim-fix
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}» دعوت کرد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "מ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "差出人",
|
||||
|
||||
@@ -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": "보낸 사람",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\"-д урилаа",
|
||||
|
||||
@@ -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}»",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}”",
|
||||
|
||||
@@ -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": "От",
|
||||
|
||||
@@ -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}“",
|
||||
|
||||
@@ -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": "กำหนดโดยองค์กรของคุณ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Від",
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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}」",
|
||||
|
||||
@@ -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": "发件人",
|
||||
|
||||
Reference in New Issue
Block a user