diff --git a/components/calendar/__tests__/calendar-app-event-sheet-ux-pim.test.tsx b/components/calendar/__tests__/calendar-app-event-sheet-ux-pim.test.tsx new file mode 100644 index 000000000..1721f5b88 --- /dev/null +++ b/components/calendar/__tests__/calendar-app-event-sheet-ux-pim.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, fireEvent } 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 { useTaskStore } from '@/stores/task-store'; + +// pim-25: the event editor sat beside the week grid and squeezed its seven +// columns to ~85 px at 1366x768. It now opens as a sheet over the grid from +// the right, and the grid keeps its width. + +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => false, + useIsDesktop: () => true, + useIsFullDesktop: () => true, + useIsTablet: () => false, + 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 })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) })); + +import { CalendarApp } from '../calendar-app'; + +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[]; +const event = { + 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', +} 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([]), +}; + +beforeEach(() => { + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' }); + useCalendarStore.setState({ + calendars, events: [event], supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode: 'week', + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(), + createEvent: vi.fn(), updateEvent: vi.fn(), deleteEvent: vi.fn(), rsvpEvent: vi.fn(), + } as never); + useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never); + useTaskStore.setState({ tasks: [] } as never); +}); + +describe('CalendarApp event editor (pim-25)', () => { + it('opens over the grid instead of beside it', async () => { + render(); + fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' }); + const editor = await screen.findByRole('dialog', { name: 'events.create' }); + const sheet = editor.parentElement!; + // Out of the row's flow, pinned to its end edge, above the grid. + expect(sheet.className).toMatch(/\babsolute\b/); + expect(sheet.className).toMatch(/\binset-y-0\b/); + expect(sheet.className).toMatch(/\bend-0\b/); + expect(sheet.className).not.toMatch(/\bflex-shrink-0\b/); + expect(sheet.parentElement!.className).toMatch(/\brelative\b/); + }); +}); diff --git a/components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx b/components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx new file mode 100644 index 000000000..10e31b832 --- /dev/null +++ b/components/calendar/__tests__/calendar-app-login-series-ux-pim.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, fireEvent } 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 { useTaskStore } from '@/stores/task-store'; +import { useAccountStore } from '@/stores/account-store'; + +// pim-12 review: a series of another login was deleted with the active +// login's addresses, so a meeting the user organizes as that login counted as +// someone else's: no "attendees will be told" note, and truncating the series +// didn't tell them. The event's own login's addresses decide now. + +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => false, + useIsDesktop: () => true, + useIsFullDesktop: () => true, + useIsTablet: () => false, + 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 })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) })); + +import { CalendarApp } from '../calendar-app'; + +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, +}, { + id: 'login-b:bcal', originalId: 'bcal', localAccountId: 'login-b', name: 'B Personal', color: '#654321', 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[]; +const series = { + id: 'login-b:e1', originalId: 'e1', localAccountId: 'login-b', uid: 'u1', '@type': 'Event', title: 'Team sync', + description: '', start: '2026-10-12T10:00:00', duration: 'PT1H', timeZone: null, showWithoutTime: false, + calendarIds: { 'login-b:bcal': true }, organizerCalendarAddress: 'mailto:b@example.org', + participants: { + org: { '@type': 'Participant', calendarAddress: 'mailto:b@example.org', roles: { owner: true }, participationStatus: 'accepted' }, + att: { '@type': 'Participant', calendarAddress: 'mailto:carol@example.net', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + alerts: null, recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], isOrigin: false, status: 'confirmed', +} 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([]), +}; + +beforeEach(() => { + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc', username: 'me@example.com' } as never); + useAccountStore.setState({ + accounts: [{ id: 'acc', email: 'me@example.com', username: 'me@example.com' }, { id: 'login-b', email: 'b@example.org', username: 'b@example.org' }], + activeAccountId: 'acc', + } as never); + useCalendarStore.setState({ + calendars, events: [series], supportsCalendar: true, selectedCalendarIds: ['cal', 'login-b:bcal'], viewMode: 'week', + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(), + createEvent: vi.fn(), updateEvent: vi.fn(), deleteEvent: vi.fn(), rsvpEvent: vi.fn(), + } as never); + useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: false, proInterface: false } as never); + useTaskStore.setState({ tasks: [] } as never); +}); + +describe('CalendarApp series of another login', () => { + it("tells attendees when the user organizes it as that login", async () => { + render(); + const chips = await screen.findAllByText('Team sync'); + fireEvent.contextMenu(chips[0]); + fireEvent.click(await screen.findByText('events.delete')); + expect(await screen.findByText('participants.cancel_notification')).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx new file mode 100644 index 000000000..0fc99ee3e --- /dev/null +++ b/components/calendar/__tests__/calendar-desktop-menu-ux-pim.test.tsx @@ -0,0 +1,86 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Calendar } from '@/lib/jmap/types'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useTaskStore } from '@/stores/task-store'; + +// pim-15: the desktop app's File > Import > Calendar…, on the whole calendar page +// with the shell around it stubbed out. + +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => false, + useIsDesktop: () => true, + useIsFullDesktop: () => true, + useIsTablet: () => false, + 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 })); + +import { CalendarApp } from '../calendar-app'; +import { requestAppCommand } from '@/lib/app-commands'; + +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[]; + +const client = { + getAccountId: () => 'acc', + getMaxParticipantsPerEvent: () => null, + supportsCalendars: () => true, + queryCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvent: vi.fn().mockResolvedValue(null), + getCalendarTasks: vi.fn().mockResolvedValue([]), +}; + +beforeEach(() => { + config.demoMode = false; + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' }); + useCalendarStore.setState({ + calendars, events: [], supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode: 'week', + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + fetchCalendars: vi.fn(), fetchEvents: vi.fn(), refreshAllSubscriptions: vi.fn(), + } as never); + useSettingsStore.setState({ enableCalendarTasks: true, proInterface: false } as never); + useTaskStore.setState({ tasks: [] } as never); +}); + +afterEach(() => cleanup()); + +describe('Calendar in the desktop menus', () => { + it('File > Import > Calendar… opens the calendar import', () => { + render(); + expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull(); + let taken = false; + act(() => { taken = requestAppCommand('import-calendar'); }); + expect(taken).toBe(true); + expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument(); + }); + + it('opens nothing in demo mode, where the toolbar has no import either', () => { + config.demoMode = true; + render(); + act(() => { requestAppCommand('import-calendar'); }); + expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull(); + }); +}); diff --git a/components/calendar/__tests__/calendar-search-placeholder-ux-pim.test.tsx b/components/calendar/__tests__/calendar-search-placeholder-ux-pim.test.tsx new file mode 100644 index 000000000..09b877bfa --- /dev/null +++ b/components/calendar/__tests__/calendar-search-placeholder-ux-pim.test.tsx @@ -0,0 +1,53 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createRef } from 'react'; +import { CalendarSearchField } from '../calendar-search'; + +// pim-21: at 150 % in German the search box read "Termine durchs". A field +// too narrow for "Search events" now says "Search"; its name stays whole. + +/** The field is `width` px wide inside; text measures 10px per character. */ +function stubWidth(width: number) { + vi.stubGlobal('ResizeObserver', class implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this); + } + unobserve() {} + disconnect() {} + }); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + font: '', + measureText: (text: string) => ({ width: text.length * 10 }), + } as unknown as CanvasRenderingContext2D); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('CalendarSearchField placeholder', () => { + it('says the short word in a narrow field', () => { + // "search.title" is 12 characters: 120px does not fit in 96. + stubWidth(96); + render(); + const input = screen.getByRole('searchbox'); + expect(input.getAttribute('placeholder')).toBe('search_short'); + expect(input.getAttribute('aria-label')).toBe('search.title'); + }); + + it('keeps the whole phrase where it fits', () => { + stubWidth(200); + render(); + expect(screen.getByRole('searchbox').getAttribute('placeholder')).toBe('search.title'); + }); + + it('still hands the input to the page', () => { + stubWidth(200); + const ref = createRef(); + render(); + expect(ref.current).toBe(screen.getByRole('searchbox')); + }); +}); diff --git a/components/calendar/__tests__/calendar-sidebar-rename.test.tsx b/components/calendar/__tests__/calendar-sidebar-rename.test.tsx new file mode 100644 index 000000000..7ebc22ec2 --- /dev/null +++ b/components/calendar/__tests__/calendar-sidebar-rename.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; +import { useCalendarStore } from '@/stores/calendar-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Calendar } from '@/lib/jmap/types'; + +// pim-20: renaming a calendar only worked from Settings, through a pencil +// that showed on hover. The calendar's menu has "Rename…" now, and calls +// clearing "Clear all events…" and deleting "Delete…" (both ask first). + +const work = { + id: 'work', name: 'Work', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true, + isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null, + myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }, +} as unknown as Calendar; +const shared = { ...work, id: 'x:team', name: 'Team', isShared: true, accountId: 'x', accountName: 'b@example.org' } as Calendar; +const client = { getAccountId: () => 'a' } as unknown as IJMAPClient; +let updateCalendar: ReturnType; + +beforeEach(() => { + updateCalendar = vi.fn().mockResolvedValue(undefined); + useCalendarStore.setState({ updateCalendar, viewMode: 'month', icalSubscriptions: [] } as never); +}); + +function openMenu(name: string) { + render( {}} onClearCalendar={() => {}} onDeleteCalendar={() => {}} client={client} />); + fireEvent.contextMenu(screen.getAllByTestId('calendar-item').find((el) => el.dataset.calendarName === name)!); +} + +describe('calendar menu Rename (pim-20)', () => { + it('renames the calendar from its menu', async () => { + openMenu('Work'); + const items = screen.getAllByRole('menuitem').map((el) => el.textContent); + expect(items).toContain('rename_calendar'); + expect(items).toContain('clear_all_events'); + expect(items).toContain('delete_calendar'); + fireEvent.click(screen.getByRole('menuitem', { name: 'rename_calendar' })); + const field = await screen.findByDisplayValue('Work'); + fireEvent.change(field, { target: { value: 'Office' } }); + fireEvent.click(screen.getByRole('button', { name: 'rename_confirm' })); + await waitFor(() => expect(updateCalendar).toHaveBeenCalledWith(client, 'work', { name: 'Office' })); + }); + + it('offers no Rename for a calendar someone shared', () => { + openMenu('Team'); + expect(screen.getAllByRole('menuitem').map((el) => el.textContent)).not.toContain('rename_calendar'); + }); +}); diff --git a/components/calendar/__tests__/calendar-sidebar-touch.test.tsx b/components/calendar/__tests__/calendar-sidebar-touch.test.tsx index befa7469d..7d09101b8 100644 --- a/components/calendar/__tests__/calendar-sidebar-touch.test.tsx +++ b/components/calendar/__tests__/calendar-sidebar-touch.test.tsx @@ -26,6 +26,7 @@ describe('CalendarSidebarPanel on touch screens', () => { it('opens a calendar\'s menu on a long press', async () => { render( {}} onDeleteCalendar={() => {}} client={client} />); fireEvent.touchStart(screen.getByTestId('calendar-item'), { touches: [{ clientX: 20, clientY: 20 }] }); - expect(await screen.findByRole('menuitem', { name: 'delete' }, { timeout: 2000 })).toBeInTheDocument(); + // "Delete…" in the menu, as it opens a dialog (pim-20). + expect(await screen.findByRole('menuitem', { name: 'delete_calendar' }, { timeout: 2000 })).toBeInTheDocument(); }); }); diff --git a/components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx b/components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx new file mode 100644 index 000000000..63f1aac45 --- /dev/null +++ b/components/calendar/__tests__/event-modal-login-calendars-ux-pim.test.tsx @@ -0,0 +1,54 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EventModal } from '../event-modal'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// With two logins the editor lists every login's calendars. Moving an existing +// event into another login's calendar sent that calendar's raw id to the +// event's own server, which either moved it into an unrelated calendar there +// or failed. An existing event now moves only within its own login; a new +// event can still go anywhere. + +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); + +const rights = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; +const calendars = [ + { id: 'a-personal', name: 'A Personal', color: '#123456', isDefault: true, myRights: rights }, + { id: 'a-work', name: 'A Work', color: '#123456', isDefault: false, myRights: rights }, + { id: 'login-b:b', name: 'B Personal', color: '#654321', isDefault: true, myRights: rights, localAccountId: 'login-b', originalId: 'b' }, +] as unknown as Calendar[]; + +const event = { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00', + duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { 'a-personal': true }, + participants: null, alerts: null, recurrenceRules: null, +} as unknown as CalendarEvent; + +function options(): string[] { + const select = document.querySelector('select[id$="calendar_select"]') as HTMLSelectElement | null; + return select ? within(select).getAllByRole('option').map((o) => o.textContent ?? '') : []; +} + +describe('EventModal calendar choice with several logins', () => { + it("offers only the event's own login's calendars when editing", () => { + render(); + fireEvent.click(screen.getByText('events.edit')); + expect(options()).toEqual(['A Personal', 'A Work']); + }); + + it("keeps another login's event in that login", () => { + const other = { ...event, calendarIds: { 'login-b:b': true } } as CalendarEvent; + render(); + fireEvent.click(screen.getByText('events.edit')); + // One calendar left: no select at all. + expect(options()).toEqual([]); + }); + + it('offers every login for a new event', () => { + render(); + expect(options()).toEqual(['A Personal', 'A Work', 'B Personal']); + }); +}); diff --git a/components/calendar/__tests__/event-modal-organizer.test.tsx b/components/calendar/__tests__/event-modal-organizer.test.tsx index 81cb7122c..f72924f81 100644 --- a/components/calendar/__tests__/event-modal-organizer.test.tsx +++ b/components/calendar/__tests__/event-modal-organizer.test.tsx @@ -109,8 +109,8 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal organizer and a // RFC 5546: when the organizer moves an event, the attendees are asked // again; every other save keeps their answers. The editor shows and writes -// the event in the user's zone (here Tokyo, UTC+9), which is no move by -// itself. +// the event on its own clock (New York), whatever the user's zone (here +// Tokyo, UTC+9) is (pim-2). describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a move on Stalwart %s', (version) => { beforeEach(() => useSettingsStore.setState({ timeZone: 'Asia/Tokyo' })); afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); @@ -155,11 +155,11 @@ describe.each<['0.16' | '1.0']>([['0.16'], ['1.0']])('EventModal answers after a expect(statuses(data)).toEqual({ org: 'accepted', bob: 'needs-action', carol: 'needs-action' }); }); - it('keeps the answers when only the time zone is rewritten', async () => { + it('keeps the answers, and the event in its own zone, when nothing changes', async () => { const data = await save(own(), () => {}); - // Written in the user's zone, at the same instant. - expect(data).toMatchObject({ start: '2026-10-12T21:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); + // Written in its own zone, as it was (pim-2); it was rewritten into the user's. + expect(data).toMatchObject({ start: '2026-10-12T08:00:00', timeZone: 'America/New_York', duration: 'PT1H' }); expect(statuses(data)).toEqual({ org: 'accepted', bob: 'accepted', carol: 'declined' }); }); diff --git a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx new file mode 100644 index 000000000..5ce88cfed --- /dev/null +++ b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx @@ -0,0 +1,182 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventModal } from '../event-modal'; +import { useSettingsStore } from '@/stores/settings-store'; +import { AUTO_TIME_ZONE, fromDisplayDate } from '@/lib/timezone'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import type { EventEditorState } from '@/lib/recurrence-scope-edit'; + +// pim-2: an edit kept the event's time but moved it into the user's zone +// (every save wrote timeZone: ), and there was no way to put an +// event in another zone. pim-7: the form asked for the date and time last. +// pim-19, pim-22: plain words for the participant answers and "(No title)". + +// Keys with their values, so a test can read what a line says. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => 'en', + useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }), +})); +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); + +const RIGHTS = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; +const calendars = [ + { id: 'cal', name: 'Work', color: '#123456', isDefault: true, myRights: RIGHTS }, + { id: 'home', name: 'Home', color: '#654321', isDefault: false, myRights: RIGHTS }, +] as unknown as Calendar[]; + +// 10:00-11:00 in New York on 5 November (UTC-5): 16:00-17:00 in Berlin. +const newYork = { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Call with New York office', description: '', + start: '2026-11-05T10:00:00', duration: 'PT1H', timeZone: 'America/New_York', + utcStart: '2026-11-05T15:00:00Z', utcEnd: '2026-11-05T16:00:00Z', + showWithoutTime: false, calendarIds: { cal: true }, participants: null, alerts: null, recurrenceRules: null, +} as unknown as CalendarEvent; + +function editor(event: CalendarEvent | null, extra: Partial[0]> = {}) { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + return onSave; +} + +async function save(onSave: ReturnType): Promise<{ data: Partial; state: EventEditorState }> { + fireEvent.click(screen.getByText('form.save')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + return { data: onSave.mock.calls[0][0], state: onSave.mock.calls[0][2] }; +} + +const field = (label: string) => screen.getByLabelText(label) as HTMLInputElement; + +beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); + +describe('the event editor keeps the event in its own time zone (pim-2)', () => { + it('shows the times on the event clock and says which zone that is', () => { + editor(newYork); + expect(field('form.start_time').value).toBe('10:00'); + expect(field('form.end_time').value).toBe('11:00'); + expect(screen.getByText(/^time_zone_line:/).textContent).toBe('time_zone_line:{"zone":"New York"}'); + expect(screen.getByText(/^your_time:/).textContent).toBe('your_time:{"times":"16:00–17:00"}'); + }); + + it('saves a title change with the zone and start the event had', async () => { + const onSave = editor(newYork); + fireEvent.change(field('form.title'), { target: { value: 'Call with New York office, room 2' } }); + const { data, state } = await save(onSave); + expect(data).toMatchObject({ title: 'Call with New York office, room 2', start: '2026-11-05T10:00:00', timeZone: 'America/New_York', duration: 'PT1H' }); + // The editor state stays in display dates: the same instant. + expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-11-05T15:00:00.000Z'); + expect(state.timeZoneChanged).toBeUndefined(); + }); + + // Review of pim-2: the duration and the editor state came from Dates in the + // browser's zone. 01:00-04:00 in New York on the night its clocks go + // forward lasts two hours, not three; and 02:30 exists in New York on the + // night Berlin's clocks go forward, where a browser Date moved it to 03:30. + it("counts the duration on the event's clock across its daylight-saving switch", async () => { + const onSave = editor({ ...newYork, start: '2026-03-08T01:00:00', duration: 'PT2H', utcStart: '2026-03-08T06:00:00Z', utcEnd: '2026-03-08T08:00:00Z' } as CalendarEvent); + expect(field('form.end_time').value).toBe('04:00'); + fireEvent.change(field('form.title'), { target: { value: 'Night shift handover' } }); + const { data } = await save(onSave); + expect(data).toMatchObject({ start: '2026-03-08T01:00:00', timeZone: 'America/New_York', duration: 'PT2H' }); + }); + + it("keeps a time that falls in the browser's daylight-saving gap", async () => { + const onSave = editor(newYork); + fireEvent.change(field('form.start_date'), { target: { value: '2026-03-29' } }); + fireEvent.change(field('form.end_date'), { target: { value: '2026-03-29' } }); + fireEvent.change(field('form.start_time'), { target: { value: '02:30' } }); + fireEvent.change(field('form.end_time'), { target: { value: '03:30' } }); + const { data, state } = await save(onSave); + expect(data).toMatchObject({ start: '2026-03-29T02:30:00', duration: 'PT1H' }); + // 02:30 in New York (UTC-4 since 8 March) is 06:30 UTC. + expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-03-29T06:30:00.000Z'); + }); + + it('moves the event on its own clock', async () => { + const onSave = editor(newYork); + fireEvent.change(field('form.start_time'), { target: { value: '12:00' } }); + const { data } = await save(onSave); + expect(data).toMatchObject({ start: '2026-11-05T12:00:00', timeZone: 'America/New_York', duration: 'PT1H' }); + }); + + it('creates an event in another zone through the picker', async () => { + const onSave = editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); + fireEvent.change(field('form.title'), { target: { value: 'Tokyo partner sync' } }); + // A new event is in the user's zone, behind a small link. + expect(screen.queryByText(/^time_zone_line/)).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'time_zone' })); + fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + const { data, state } = await save(onSave); + expect(data).toMatchObject({ start: '2026-11-12T10:00:00', timeZone: 'Asia/Tokyo', duration: 'PT1H' }); + // 10:00 in Tokyo is 01:00 UTC. + expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-11-12T01:00:00.000Z'); + }); + + it('tells the series save when the user picked another zone', async () => { + const onSave = editor(newYork); + fireEvent.click(screen.getByText(/^time_zone_line:/)); + fireEvent.change(field('time_zone'), { target: { value: 'Asia/Tokyo' } }); + const { data, state } = await save(onSave); + expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: 'Asia/Tokyo' }); + expect(state.timeZoneChanged).toBe(true); + }); + + it('leaves a floating event without a zone', async () => { + const onSave = editor({ ...newYork, timeZone: null, utcStart: undefined, utcEnd: undefined } as unknown as CalendarEvent); + expect(screen.queryByTestId('event-time-zone')).toBeNull(); + fireEvent.change(field('form.title'), { target: { value: 'Floating' } }); + const { data } = await save(onSave); + expect(data).toMatchObject({ start: '2026-11-05T10:00:00', timeZone: null }); + }); + + it('shows the event clock beside the user one in the summary', () => { + render(); + expect(screen.getByTestId('event-zone-times').textContent) + .toBe('zone_times:{"times":"10:00–11:00","zone":"New York","local":"16:00–17:00"}'); + }); + + it('shows no zone line for an event on the user clock', () => { + render(); + expect(screen.queryByTestId('event-zone-times')).toBeNull(); + }); +}); + +describe('the event form order (pim-7)', () => { + it('asks for the time right after the title and for the description last', () => { + editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0) }); + const order = ['form.title', 'form.all_day_event', 'form.start_date', 'form.end_time', 'recurrence.title', 'form.calendar_select', + 'form.location', 'form.meeting_link', 'participants.title', 'alerts.title', 'form.description']; + const nodes = order.map((label) => screen.getAllByText(label).find((el) => el.closest('label')) ?? screen.getAllByText(label)[0]); + for (let i = 1; i < nodes.length; i++) { + const before = nodes[i - 1].compareDocumentPosition(nodes[i]) & Node.DOCUMENT_POSITION_FOLLOWING; + expect(before, `${order[i - 1]} before ${order[i]}`).toBeTruthy(); + } + }); +}); + +describe('plain words (pim-19, pim-22)', () => { + it('says "Not answered yet" for an attendee who has not answered', () => { + const meeting = { + ...newYork, + participants: { + me: { '@type': 'Participant', name: 'Me', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + ben: { '@type': 'Participant', name: 'Ben', calendarAddress: 'mailto:ben@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + }, + } as unknown as CalendarEvent; + render(); + expect(screen.getByText('status_needs_action')).toBeTruthy(); + expect(screen.getByText('status_accepted')).toBeTruthy(); + expect(screen.queryByText('participants.needs_action')).toBeNull(); + }); + + it('labels the grid preview of an untitled event in the user language', () => { + const onPreviewChange = vi.fn(); + editor(null, { defaultDate: new Date(2026, 10, 12, 10, 0), defaultEndDate: new Date(2026, 10, 12, 11, 0), onPreviewChange }); + const last = onPreviewChange.mock.calls.filter(([p]) => p).pop()![0]; + expect(last.title).toBe('events.no_title'); + }); +}); diff --git a/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx new file mode 100644 index 000000000..1b9b9dc9b --- /dev/null +++ b/components/calendar/__tests__/event-popover-zone-ux-pim.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventDetailPopover } from '../event-detail-popover'; +import { QuickCreatePopover, computePosition } from '../quick-create-popover'; +import { useSettingsStore } from '@/stores/settings-store'; +import { AUTO_TIME_ZONE } from '@/lib/timezone'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// pim-2: the popover of an event in another zone said nothing about it. +// pim-19: "Needs action" for an attendee who hasn't answered. +// pim-21: the quick editor cut a long calendar name off. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => 'en', + useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }), +})); + +const calendar = { id: 'cal', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar; + +function event(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', calendarIds: { cal: true }, title: 'Call with New York office', start: '2026-11-05T10:00:00', duration: 'PT1H', + timeZone: 'America/New_York', utcStart: '2026-11-05T15:00:00Z', utcEnd: '2026-11-05T16:00:00Z', + showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false, + ...overrides, + } as unknown as CalendarEvent; +} + +function popover(e: CalendarEvent) { + render( + , + ); +} + +beforeEach(() => useSettingsStore.setState({ timeZone: 'Europe/Berlin' })); +afterEach(() => useSettingsStore.setState({ timeZone: AUTO_TIME_ZONE })); + +describe('event popover', () => { + it('shows the times on the event clock with the user times after them (pim-2)', () => { + popover(event()); + expect(screen.getByTestId('event-zone-times').textContent) + .toBe('zone_times:{"times":"10:00–11:00","zone":"New York","local":"16:00–17:00"}'); + }); + + it('names the weekday on the event clock when its day there is another', () => { + // 08:00 on Friday in Tokyo is 00:00 on Friday in Berlin; in New York + // (the user here) it is still Thursday. + useSettingsStore.setState({ timeZone: 'America/New_York' }); + popover(event({ timeZone: 'Asia/Tokyo', start: '2026-11-06T08:00:00', utcStart: '2026-11-05T23:00:00Z', utcEnd: '2026-11-06T00:00:00Z' })); + expect(screen.getByTestId('event-zone-times').textContent) + .toBe('zone_times:{"times":"Fri 08:00–09:00","zone":"Tokyo","local":"18:00–19:00"}'); + }); + + it('shows the plain times for an event on the user clock, an all-day and a floating one', () => { + popover(event({ timeZone: 'Europe/Berlin', start: '2026-11-05T16:00:00' })); + popover(event({ id: 'e2', showWithoutTime: true, timeZone: null, start: '2026-11-05T00:00:00', duration: 'P1D' })); + popover(event({ id: 'e3', timeZone: null, utcStart: undefined, utcEnd: undefined })); + expect(screen.queryByTestId('event-zone-times')).toBeNull(); + }); + + it('says an attendee has not answered yet in plain words (pim-19)', () => { + popover(event({ + participants: { + me: { '@type': 'Participant', name: 'Me', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' }, + ben: { '@type': 'Participant', name: 'Ben', calendarAddress: 'mailto:ben@example.com', roles: { attendee: true }, participationStatus: 'needs-action' }, + eve: { '@type': 'Participant', name: 'Eve', calendarAddress: 'mailto:eve@example.com', roles: { attendee: true }, participationStatus: 'tentative' }, + }, + } as unknown as Partial)); + expect(screen.getByText('status_needs_action')).toBeTruthy(); + expect(screen.getByText('status_tentative')).toBeTruthy(); + expect(screen.queryByText('participants.needs_action')).toBeNull(); + }); +}); + +describe('quick create (pim-21)', () => { + const long = 'Stalwart Calendar (dso-ux-pim@example.org)'; + const calendars = [ + { id: 'a', name: long, color: '#3b82f6' }, + { id: 'b', name: 'Work', color: '#22c55e' }, + ] as unknown as Calendar[]; + + it('grows to fit the calendar names and names the calendar on hover', () => { + render( + , + ); + const dialog = screen.getByRole('dialog'); + // As wide as its content (the select is as wide as its longest name), from 340 px on. + expect(dialog.style.width).toBe('max-content'); + expect(dialog.style.minWidth).toBe('340px'); + expect(dialog.style.maxWidth).toContain('480px'); + expect(screen.getByRole('combobox').getAttribute('title')).toBe(long); + }); + + it('places a wider popover by its own width', () => { + Object.defineProperty(window, 'innerWidth', { value: 1366, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true }); + // Beside an anchor at the right: 460 px don't fit on the right side, so it goes left of it. + const anchor = new DOMRect(1000, 300, 150, 20); + expect(computePosition(anchor, 200, 340)).toEqual({ top: 300, left: 652 }); + expect(computePosition(anchor, 200, 460)).toEqual({ top: 300, left: 532 }); + }); +}); diff --git a/components/calendar/__tests__/grid-reply-ux-pim.test.tsx b/components/calendar/__tests__/grid-reply-ux-pim.test.tsx new file mode 100644 index 000000000..442c2bbe2 --- /dev/null +++ b/components/calendar/__tests__/grid-reply-ux-pim.test.tsx @@ -0,0 +1,180 @@ +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { createRef } from 'react'; +import { EventCard } from '../event-card'; +import { EventContextMenu } from '../event-context-menu'; +import { CalendarAgendaView } from '../calendar-agenda-view'; +import { gridReplyState } from '@/lib/calendar-reply-state'; +import { useAccountStore } from '@/stores/account-store'; +import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types'; + +// pim-9: an invitation the user hasn't answered looked like an accepted +// event, a solid block. It is now outlined and hatched with a "?" / "Reply" +// marker that offers the answers; "maybe" gets a dashed outline; the agenda a +// hollow dot. With several logins, "the user" is the login the event is in. + +const me = ['me@example.com']; +const calendar = { id: 'cal-1', name: 'Work', color: '#2563eb' } as unknown as Calendar; + +function invitation(myStatus: CalendarParticipant['participationStatus'] | undefined, extra: Partial = {}): CalendarEvent { + const mine: Record = { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', roles: { attendee: true } }; + if (myStatus) mine.participationStatus = myStatus; + return { + id: 'ev-1', + '@type': 'Event', + uid: 'inv-1', + title: 'UXFIX-grid weekly sync', + start: '2026-11-05T10:00:00', + duration: 'PT1H', + showWithoutTime: false, + status: 'confirmed', + calendarIds: { 'cal-1': true }, + organizerCalendarAddress: 'mailto:boss@example.com', + participants: { + boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' }, + me: mine, + }, + ...extra, + } as unknown as CalendarEvent; +} + +afterEach(() => { + cleanup(); + useAccountStore.setState({ accounts: [], activeAccountId: null }); +}); + +describe('gridReplyState', () => { + it('tells an open invitation, a maybe, and nothing to answer apart', () => { + expect(gridReplyState(invitation('needs-action'), me)).toBe('needs-action'); + // JSCalendar: no status is "not answered yet". + expect(gridReplyState(invitation(undefined), me)).toBe('needs-action'); + expect(gridReplyState(invitation('tentative'), me)).toBe('tentative'); + expect(gridReplyState(invitation('accepted'), me)).toBeNull(); + expect(gridReplyState(invitation('needs-action', { status: 'cancelled' }), me)).toBeNull(); + // The organiser has nothing to answer. + expect(gridReplyState(invitation('needs-action'), ['boss@example.com'])).toBeNull(); + expect(gridReplyState(invitation('needs-action'), [])).toBeNull(); + }); +}); + +describe('EventCard of an invitation', () => { + it.each(['chip', 'span', 'block'] as const)('outlines and hatches an unanswered one, with a marker (%s)', (variant) => { + const onReply = vi.fn(); + const event = invitation('needs-action'); + render(); + const card = screen.getByRole('button', { name: /UXFIX-grid weekly sync/ }); + expect(card.dataset.replyState).toBe('needs-action'); + expect(card.style.boxShadow).toContain('inset 0 0 0 1.5px'); + expect(card.style.backgroundImage).toContain('repeating-linear-gradient'); + // Said in words too, not only by the look. + expect(card.getAttribute('aria-label')).toContain('status_needs_action'); + const marker = screen.getByRole('button', { name: 'reply_to_invitation' }); + fireEvent.click(marker); + expect(onReply).toHaveBeenCalledWith(marker, event); + }); + + it('draws a maybe with a dashed outline and no marker', () => { + render(); + const card = screen.getByRole('button'); + expect(card.dataset.replyState).toBe('tentative'); + expect(card.style.border).toContain('dashed'); + expect(screen.queryByRole('button', { name: 'reply_to_invitation' })).toBeNull(); + }); + + it('leaves an accepted invitation solid', () => { + render(); + const card = screen.getByRole('button'); + expect(card.dataset.replyState).toBeUndefined(); + expect(card.style.backgroundImage).toBe(''); + }); + + describe('with two logins', () => { + beforeEach(() => { + useAccountStore.setState({ + accounts: [ + { id: 'acc-a', email: 'me@example.com' }, + { id: 'acc-b', email: 'b@example.com' }, + ] as never, + activeAccountId: 'acc-a', + }); + }); + + const ofB = (status: CalendarParticipant['participationStatus']) => { + const event = invitation('accepted', { localAccountId: 'acc-b' } as Partial); + event.participants!.b = { '@type': 'Participant', calendarAddress: 'mailto:b@example.com', roles: { attendee: true }, participationStatus: status } as unknown as CalendarParticipant; + return event; + }; + + it('reads the other login\'s event with its own address', () => { + // A (me@) accepted, B has not answered: the event is B's, so it is open. + render(); + expect(screen.getByRole('button').dataset.replyState).toBe('needs-action'); + }); + + it('strikes through what the other login declined', () => { + render(); + expect(screen.getByRole('button').getAttribute('aria-label')).toContain('participants.declined'); + }); + }); +}); + +describe('EventContextMenu answers', () => { + const base = { + event: invitation('needs-action'), + position: { x: 10, y: 10 }, + isOpen: true, + onClose: vi.fn(), + onEdit: vi.fn(), + onDuplicate: vi.fn(), + onExportICS: vi.fn(), + onCopyTitle: vi.fn(), + onDelete: vi.fn(), + }; + + it('offers Accept, Maybe and Decline above the rest', () => { + const onRespond = vi.fn(); + render(); + const items = screen.getAllByRole('menuitem').map((item) => item.textContent); + expect(items.slice(0, 3)).toEqual(['participants.accept', 'participants.maybe', 'participants.decline']); + expect(items).toContain('events.duplicate'); + fireEvent.click(screen.getByText('participants.maybe')); + expect(onRespond).toHaveBeenCalledWith('tentative'); + }); + + it('leaves out the answer already given', () => { + render(); + expect(screen.queryByText('participants.maybe')).toBeNull(); + expect(screen.getByText('participants.accept')).toBeTruthy(); + }); + + it('shows only the answers from the marker', () => { + render(); + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)) + .toEqual(['participants.accept', 'participants.maybe', 'participants.decline']); + }); +}); + +describe('CalendarAgendaView invitations', () => { + it('draws a hollow dot and a Reply button', () => { + const onReplyEvent = vi.fn(); + const event = invitation('needs-action'); + render( + , + ); + const dots = Array.from(document.querySelectorAll('[data-agenda-dot]')).map((dot) => dot.dataset.agendaDot); + expect(dots).toEqual(['needs-action', 'tentative']); + const reply = screen.getByRole('button', { name: 'reply' }); + fireEvent.click(reply); + expect(onReplyEvent).toHaveBeenCalledWith(reply, event); + }); +}); diff --git a/components/calendar/__tests__/month-fit-ux-pim.test.tsx b/components/calendar/__tests__/month-fit-ux-pim.test.tsx new file mode 100644 index 000000000..6e8ebec34 --- /dev/null +++ b/components/calendar/__tests__/month-fit-ux-pim.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { CalendarMonthView, fitMonthWeek, monthRowHeight } from '../calendar-month-view'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// pim-6: at 1366x768 the month showed 4½ weeks and at 150 % (911x512) 2½: +// every row kept a 146px minimum. The month's weeks now share the pane and +// a day shows what fits, then "+n more". + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; + +function allDay(id: string, start: string): CalendarEvent { + return { + id, '@type': 'Event', uid: id, title: 'Event ' + id, start, duration: 'P1D', showWithoutTime: true, + calendarIds: { 'cal-1': true }, + } as unknown as CalendarEvent; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('monthRowHeight', () => { + const metrics = { overlayTop: 30, rowHeight: 26, fallback: 146 }; + it('shares the pane between the weeks of the month', () => { + expect(monthRowHeight(422, 6, metrics)).toBe(70); + expect(monthRowHeight(422, 5, metrics)).toBe(84); + }); + it('keeps the date and one entry in a tiny pane', () => { + expect(monthRowHeight(120, 6, metrics)).toBe(64); + }); + it('falls back where nothing was measured', () => { + expect(monthRowHeight(0, 6, metrics)).toBe(146); + }); +}); + +describe('fitMonthWeek', () => { + it('shows a full day whole and cuts only the fuller one', () => { + // Monday has three events, Tuesday two, room for two rows. + const segments = [ + { startIndex: 0, span: 1, row: 0 }, { startIndex: 0, span: 1, row: 1 }, { startIndex: 0, span: 1, row: 2 }, + { startIndex: 1, span: 1, row: 0 }, { startIndex: 1, span: 1, row: 1 }, + ]; + const rows = [3, 2, 0, 0, 0, 0, 0]; + const { limits, hidden } = fitMonthWeek(segments, rows, [0, 0, 0, 0, 0, 0, 0], 2); + expect(limits.slice(0, 2)).toEqual([1, 2]); + expect(hidden.slice(0, 2)).toEqual([2, 0]); + }); + + it('counts an event over several days as more where it doesn\'t fit', () => { + // A two-day event in row 1 over Monday (full) and Tuesday. + const segments = [ + { startIndex: 0, span: 1, row: 0 }, { startIndex: 0, span: 1, row: 2 }, + { startIndex: 0, span: 2, row: 1 }, + ]; + const { limits, hidden, isShown } = fitMonthWeek(segments, [3, 2, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], 2); + expect(isShown(segments[2])).toBe(false); + expect(hidden.slice(0, 2)).toEqual([2, 1]); + expect(limits[1]).toBe(1); + }); + + it('keeps the one row for an entry when the link goes beside the date', () => { + const segments = [{ startIndex: 2, span: 1, row: 0 }, { startIndex: 2, span: 1, row: 1 }]; + const { limits, hidden, isShown } = fitMonthWeek(segments, [0, 0, 2, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], 1, true); + expect(limits[2]).toBe(1); + expect(hidden[2]).toBe(1); + expect(isShown(segments[0])).toBe(true); + }); + + it('shows "+n more" alone where only one row fits', () => { + const segments = [{ startIndex: 2, span: 1, row: 0 }, { startIndex: 2, span: 1, row: 1 }, { startIndex: 3, span: 1, row: 0 }]; + const { limits, hidden } = fitMonthWeek(segments, [0, 0, 2, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], 1); + expect(limits[2]).toBe(0); + expect(hidden[2]).toBe(2); + expect(limits[3]).toBe(1); + expect(hidden[3]).toBe(0); + }); +}); + +describe('CalendarMonthView at 150 % (911x512)', () => { + function renderAt(paneHeight: number, focus: Date, rangeStart: Date, rangeEnd: Date, events: CalendarEvent[] = []) { + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(paneHeight); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(33); + render( + , + ); + return Array.from(document.querySelectorAll('[data-week]')); + } + + it('fits all five weeks of September in the pane', () => { + // The pane under the toolbar: 455px, 33 of them the weekday header. + const rows = renderAt(455, new Date(2026, 8, 9), new Date(2026, 7, 31), new Date(2026, 9, 4)); + expect(rows).toHaveLength(5); + const heights = rows.map((row) => parseInt(row.style.minHeight, 10)); + expect(heights.every((h) => h === 84)).toBe(true); + expect(heights.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(455 - 33); + }); + + it('fits six weeks of November and puts the "+n" of a full day beside its date', () => { + const rows = renderAt(455, new Date(2026, 10, 4), new Date(2026, 9, 26), new Date(2026, 11, 6), [ + allDay('a', '2026-11-04T00:00:00'), + allDay('b', '2026-11-04T00:00:00'), + allDay('c', '2026-11-05T00:00:00'), + ]); + expect(rows).toHaveLength(6); + expect(rows.every((row) => row.style.minHeight === '70px')).toBe(true); + // One row fits: Wednesday shows its first event and "+1" beside the + // date, Thursday its one event. + expect(screen.getByText('Event a')).toBeTruthy(); + expect(screen.queryByText('Event b')).toBeNull(); + expect(screen.getByText('Event c')).toBeTruthy(); + const more = screen.getAllByRole('button', { name: 'events.more' }); + expect(more).toHaveLength(1); + expect(more[0].textContent).toBe('+1'); + expect(more[0].parentElement?.style.top).toBe('4px'); + }); +}); diff --git a/components/calendar/__tests__/quick-task-due-ux-pim.test.tsx b/components/calendar/__tests__/quick-task-due-ux-pim.test.tsx new file mode 100644 index 000000000..75cca0be4 --- /dev/null +++ b/components/calendar/__tests__/quick-task-due-ux-pim.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TaskListView } from '../task-list-view'; +import { useCalendarStore } from '@/stores/calendar-store'; + +// pim-24: "Call the plumber tomorrow" in "Add a task…" made a task with that +// title and no due date. The date at the end is now the due date, shown as +// a chip before the task is made. + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 10, 4, 9, 0)); + useCalendarStore.setState({ hiddenTaskListIds: [] } as never); +}); +afterEach(() => vi.useRealTimers()); + +function setup() { + const onQuickCreate = vi.fn(); + render( {}} onToggleComplete={() => {}} onQuickCreate={onQuickCreate} />); + const input = screen.getByLabelText('tasks.quick_add_placeholder'); + return { input, onQuickCreate }; +} + +describe('quick task due date (pim-24)', () => { + it('shows the date as a chip and makes the task due then, without the word', () => { + const { input, onQuickCreate } = setup(); + fireEvent.change(input, { target: { value: 'Call the plumber tomorrow' } }); + expect(screen.getByTestId('quick-task-due')).toHaveTextContent('quick_task_due'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onQuickCreate).toHaveBeenCalledWith('Call the plumber', '2026-11-05'); + }); + + it('keeps the words in the title when the chip is dismissed', () => { + const { input, onQuickCreate } = setup(); + fireEvent.change(input, { target: { value: 'Call the plumber tomorrow' } }); + fireEvent.click(screen.getByRole('button', { name: 'quick_task_due_remove' })); + expect(screen.queryByTestId('quick-task-due')).toBeNull(); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onQuickCreate).toHaveBeenCalledWith('Call the plumber tomorrow', null); + }); + + it('makes a task without a date as before', () => { + const { input, onQuickCreate } = setup(); + fireEvent.change(input, { target: { value: 'Water the plants' } }); + expect(screen.queryByTestId('quick-task-due')).toBeNull(); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onQuickCreate).toHaveBeenCalledWith('Water the plants', null); + }); +}); diff --git a/components/calendar/__tests__/rsvp-buttons.test.tsx b/components/calendar/__tests__/rsvp-buttons.test.tsx index 2e4305e3f..e3e31264b 100644 --- a/components/calendar/__tests__/rsvp-buttons.test.tsx +++ b/components/calendar/__tests__/rsvp-buttons.test.tsx @@ -30,6 +30,7 @@ describe('participation status colours', () => { it('colours a participant\'s answer the same way', () => { render(); - expect(screen.getByText('participants.accepted').className).toContain('text-green-700'); + // In plain words (pim-19): ux_pim_event.status_accepted. + expect(screen.getByText('status_accepted').className).toContain('text-green-700'); }); }); diff --git a/components/calendar/__tests__/task-list-dates.test.tsx b/components/calendar/__tests__/task-list-dates.test.tsx index ef2738e39..f87924f08 100644 --- a/components/calendar/__tests__/task-list-dates.test.tsx +++ b/components/calendar/__tests__/task-list-dates.test.tsx @@ -19,7 +19,7 @@ const calendars = [{ id: 'cal', name: 'Privat', color: '#123456' }] as unknown a describe('TaskListView due dates', () => { it('writes the due date in the app language', () => { const task = { id: 't', title: 'Steuer', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: '2031-10-30', showWithoutTime: true } as unknown as CalendarTask; - render( {}} onToggleComplete={() => {}} />); + render( {}} onToggleComplete={() => {}} />); expect(screen.getByText('30. Okt.')).toBeInTheDocument(); expect(screen.queryByText('Oct 30')).toBeNull(); }); diff --git a/components/calendar/__tests__/task-lists-ux-pim.test.tsx b/components/calendar/__tests__/task-lists-ux-pim.test.tsx new file mode 100644 index 000000000..5521596eb --- /dev/null +++ b/components/calendar/__tests__/task-lists-ux-pim.test.tsx @@ -0,0 +1,138 @@ +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TaskListView, defaultTaskList, taskListsOf } from '../task-list-view'; +import { CalendarSidebarPanel } from '../calendar-sidebar-panel'; +import { TaskModal } from '../task-modal'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useTaskStore } from '@/stores/task-store'; +import type { Calendar, CalendarTask } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// pim-1: tasks in a task-only list ("To-do", as Thunderbird, Apple Reminders +// and DAVx⁵ make them) never showed, while the sidebar counted them: the +// list filtered by the grid's calendar selection, which leaves task-only +// lists out. A task moved into To-do vanished on save. + +function cal(id: string, name: string, extra: Partial = {}): Calendar { + return { + id, name, color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: false, + includeInAvailability: 'all', timeZone: null, shareWith: null, + myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }, + ...extra, + } as unknown as Calendar; +} + +function task(id: string, calendarId: string, extra: Partial = {}): CalendarTask { + return { + id, calendarIds: { [calendarId]: true }, '@type': 'Task', uid: id, title: id, description: '', + due: null, start: null, duration: null, timeZone: null, showWithoutTime: true, progress: 'needs-action', + priority: 0, privacy: 'public', keywords: null, categories: null, color: null, created: null, + updated: '2026-11-01T00:00:00Z', recurrenceRules: null, alerts: null, relatedTo: null, + ...extra, + } as CalendarTask; +} + +const home = cal('home', 'Home', { isDefault: true }); +const todo = cal('todo', 'To-do', { isTasksOnly: true }); +const work = cal('work', 'Work'); + +beforeEach(() => { + // The grid shows Home and Work, as fetchCalendars leaves it: never To-do. + useCalendarStore.setState({ calendars: [home, todo, work], selectedCalendarIds: ['home', 'work'], hiddenTaskListIds: [], viewMode: 'tasks' } as never); + useSettingsStore.setState({ enableCalendarTasks: true } as never); + useTaskStore.setState({ tasks: [] } as never); +}); + +const listed = () => Array.from(document.querySelectorAll('span[title^="t-"]')).map((el) => el.getAttribute('title')); + +describe('the task list (pim-1)', () => { + const tasks = [ + task('t-open', 'todo', { due: '2020-01-01T10:00:00' }), + task('t-done', 'todo', { progress: 'completed' }), + task('t-work', 'work'), + ]; + + it('lists tasks of a task-only list in every filter', () => { + const { rerender } = render( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(expect.arrayContaining(['t-open', 't-done', 't-work'])); + rerender( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(expect.arrayContaining(['t-open', 't-work'])); + rerender( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-open']); + rerender( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-done']); + }); + + it('keeps a task moved into To-do in the list', () => { + const { rerender } = render( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-moved']); + rerender( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-moved']); + }); + + it('leaves out the lists the user unticked, not the calendars the grid hides', () => { + useCalendarStore.setState({ hiddenTaskListIds: ['todo'], selectedCalendarIds: [] } as never); + render( {}} onToggleComplete={() => {}} />); + expect(listed()).toEqual(['t-work']); + }); + + it('offers task-only lists and calendars holding tasks as task lists', () => { + expect(taskListsOf([home, todo, work], tasks).map((c) => c.id)).toEqual(['todo', 'work']); + }); +}); + +describe('the sidebar in the tasks view (pim-1)', () => { + const client = { getAccountId: () => 'a' } as unknown as IJMAPClient; + + it('lists the task lists, ticked, and unticking one hides its tasks', () => { + useTaskStore.setState({ tasks: [task('t-open', 'todo')] } as never); + render( {}} client={client} />); + const section = screen.getByTestId('task-lists-section'); + const item = within(section).getByRole('checkbox', { name: /To-do/ }); + expect(item).toHaveAttribute('aria-checked', 'true'); + // The event calendars aren't offered here: they only steer the grid. + expect(screen.queryByTestId('calendar-item')).toBeNull(); + fireEvent.click(item); + expect(useCalendarStore.getState().hiddenTaskListIds).toEqual(['todo']); + expect(useCalendarStore.getState().selectedCalendarIds).toEqual(['home', 'work']); + }); + + it('shows the event calendars again outside the tasks view', () => { + useCalendarStore.setState({ viewMode: 'month' } as never); + render( {}} client={client} />); + expect(screen.queryByTestId('task-lists-section')).toBeNull(); + expect(screen.getAllByTestId('calendar-item').map((el) => el.dataset.calendarName)).toEqual(['Home', 'Work']); + }); +}); + +describe('where a new task goes (pim-8)', () => { + it('picks the first task-only list, else the default calendar', () => { + expect(defaultTaskList([home, work, todo])?.id).toBe('todo'); + expect(defaultTaskList([work, home])?.id).toBe('home'); + expect(defaultTaskList([work])?.id).toBe('work'); + }); + + // A colleague's shared to-do list, or another login's, came before the + // user's own default calendar. + it("never picks someone else's or another login's list over the user's own", () => { + const shared = { ...todo, id: 'shared-todo', isShared: true } as Calendar; + const otherLogin = { ...todo, id: 'b:todo', localAccountId: 'login-b' } as Calendar; + expect(defaultTaskList([shared, otherLogin, work, home])?.id).toBe('home'); + expect(defaultTaskList([shared, otherLogin])?.id).toBe('shared-todo'); + }); + + it('opens the task form on the task-only list', () => { + render( {}} />); + expect((screen.getByLabelText('tasks.calendar') as HTMLSelectElement).value).toBe('todo'); + }); +}); + +describe('task status words (pim-19)', () => { + it('says "Not started" and "In progress" and keeps the stored values', () => { + render( {}} />); + const select = screen.getByLabelText('tasks.progress') as HTMLSelectElement; + const options = Array.from(select.options).map((o) => [o.value, o.textContent]); + expect(options.slice(0, 2)).toEqual([['needs-action', 'task_status_not_started'], ['in-process', 'task_status_in_progress']]); + }); +}); diff --git a/components/calendar/__tests__/task-plugin-surfaces.test.tsx b/components/calendar/__tests__/task-plugin-surfaces.test.tsx index eabfe086f..3352fc27d 100644 --- a/components/calendar/__tests__/task-plugin-surfaces.test.tsx +++ b/components/calendar/__tests__/task-plugin-surfaces.test.tsx @@ -103,7 +103,8 @@ describe('task-list-actions', () => { makeTask('soon', { due: '2030-01-01' }), ]; // All is all of them: completed ones too, at the end. - expect(filterTasksForList(tasks, ['cal-1'], 'all').map((t) => t.id)).toEqual(['soon', 'later', 'done']); - expect(filterTasksForList(tasks, ['cal-1'], 'pending').map((t) => t.id)).toEqual(['soon', 'later']); + // The second argument names the task lists the user unticked (pim-1). + expect(filterTasksForList(tasks, ['cal-2'], 'all').map((t) => t.id)).toEqual(['soon', 'later', 'done']); + expect(filterTasksForList(tasks, ['cal-2'], 'pending').map((t) => t.id)).toEqual(['soon', 'later']); }); }); diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index f07640065..eb630cdb6 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -16,6 +16,9 @@ import { useScrollWindow } from "@/hooks/use-scroll-window"; import type { ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { useEventUserEmails } from "@/hooks/use-event-user-emails"; +import { gridReplyState } from "@/lib/calendar-reply-state"; +import { readableTextOn } from "@/lib/color-transform"; interface CalendarAgendaViewProps extends ScrollWindowViewProps { events: CalendarEvent[]; @@ -24,6 +27,8 @@ interface CalendarAgendaViewProps extends ScrollWindowViewProps { onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; + /** The "Reply" button of an unanswered invitation (pim-9). */ + onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void; timeFormat?: "12h" | "24h"; /** The user's calendar addresses, to mark events they declined (#1110). */ currentUserEmails?: string[]; @@ -51,12 +56,17 @@ export function CalendarAgendaView({ onHoverEvent, onHoverLeave, onContextMenuEvent, + onReplyEvent, timeFormat = "24h", currentUserEmails, onCreateEvent, }: CalendarAgendaViewProps) { const t = useTranslations("calendar"); + const tEvent = useTranslations("ux_pim_event"); + const tGrid = useTranslations("ux_pim_grid"); const paint = useCvdColor(); + // An event of another signed-in login is read with that login's addresses. + const userEmailsFor = useEventUserEmails(currentUserEmails ?? []); // Grid days / event dates are display dates (local fields = wall-clock in // the user's zone); the app-wide formatter would shift them again (#755). const intlFormatter = useDisplayDateFormatter(); @@ -248,20 +258,27 @@ export function CalendarAgendaView({ // iTIP CANCEL marks the attendee's copy with status "cancelled" // instead of deleting it (#572); a declined invitation stays // listed too (#1110). - const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails); + const userEmails = userEmailsFor(ev); + const isInactive = ev.status === "cancelled" || isDeclinedByUser(ev, userEmails); + // Not answered yet: a hollow dot and "Reply"; maybe: a dashed ring (pim-9). + const replyState = isInactive ? null : gridReplyState(ev, userEmails); const locationName = ev.locations ? Object.values(ev.locations)[0]?.name : null; const meta = [locationName, calendar?.name].filter(Boolean).join(" · "); - return ( + const row = ( ); + if (replyState !== "needs-action" || !onReplyEvent) return row; + // The answers, next to the row rather than inside its button. + return ( +
+ {row} + +
+ ); })} )} diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 35f12e35f..1263433fb 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -29,7 +29,7 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; import { CalendarDayView } from "@/components/calendar/calendar-day-view"; import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view"; -import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view"; +import { TaskListView, filterTasksForList, defaultTaskList } from "@/components/calendar/task-list-view"; import { CalendarLoadErrorNotice } from "@/components/calendar/calendar-load-error-notice"; import { TaskToolbar } from "@/components/calendar/task-toolbar"; import { PluginTaskView, usePluginTaskView } from "@/components/plugins/plugin-task-view"; @@ -58,6 +58,7 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; 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"; @@ -69,7 +70,7 @@ import { getClientByLocalAccountId } from "@/stores/client-registry"; import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message"; import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { displayNow, getEffectiveTimeZone } from "@/lib/timezone"; -import { canCreateEventsIn, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; +import { canCreateEventsIn, canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability"; import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover"; import { TASKS_LOAD_ERROR, useTaskStore } from "@/stores/task-store"; import { useContactStore } from "@/stores/contact-store"; @@ -228,6 +229,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ), [participantIdentities, identities, accountEmails] ); + // An event of another signed-in login is answered as that login (pim-12). + const userEmailsFor = useEventUserEmails(currentUserEmails); const resolveContactName = useContactNameResolver(); const [showEventModal, setShowEventModal] = useState(false); @@ -573,12 +576,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } }, [client, calendars.length, dateRange, fetchEvents, isEmbedded, proInterfaceActive]); - // Pro shell only: aggregate calendars and events from every connected - // account so the sidebar lists them all (and the views render their - // events together). The hook is a no-op outside the embedded shell. + // Aggregate calendars and events from every connected account so the + // sidebar lists them all (and the views render their events together): + // in the Pro shell, and with two or more signed-in logins (pim-12). The + // standard instance behind the Pro shell leaves the loading to the shell. const { enabled: multiAccountEnabled, accountClients } = useProMultiAccountCalendars( - isEmbedded ? dateRange?.start ?? null : null, - isEmbedded ? dateRange?.end ?? null : null, + isEmbedded || !proInterfaceActive ? dateRange?.start ?? null : null, + isEmbedded || !proInterfaceActive ? dateRange?.end ?? null : null, ); const fetchAllAccountsCalendarsFn = useCalendarStore((s) => s.fetchAllAccountsCalendars); const fetchAllAccountsEventsFn = useCalendarStore((s) => s.fetchAllAccountsEvents); @@ -870,9 +874,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = closeContextMenu: closeEventContextMenu, menuRef: eventContextMenuRef, } = useContextMenu(); + // The grid's "?" / "Reply" marker opens the answers alone (pim-9). + const [answersOnlyMenu, setAnswersOnlyMenu] = useState(false); const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => { closeDetail(); + setAnswersOnlyMenu(false); openEventContextMenu(e, event); }, [closeDetail, openEventContextMenu]); @@ -1358,7 +1365,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // One event: asked here, from every place Delete is offered (R3). A // series gets the scope question above instead. const title = eventToDelete?.title || t("events.no_title"); - const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, currentUserEmails); + const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, userEmailsFor(eventToDelete)); const ok = await confirmAction({ title: t("delete_dialog.event_title"), message: cancels @@ -1378,7 +1385,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } catch (error) { toast.error(describeError(error, t("notifications.event_delete_error"))); } - }, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, currentUserEmails, describeError, t]); + }, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, userEmailsFor, describeError, t]); const submitRsvp = useCallback(async ( eventId: string, @@ -1423,7 +1430,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = updates, editor, sendScheduling, - userEmails: currentUserEmails, + userEmails: userEmailsFor(event), resolveName: resolveContactName, }, { client, updateEvent, createEvent, findMasterEvent }); done = saved !== "stopped"; @@ -1438,7 +1445,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const deleted = await deleteOccurrence(scope, { event, sendScheduling, - userEmails: currentUserEmails, + userEmails: userEmailsFor(event), }, { client, updateEvent, deleteEvent, findMasterEvent }); done = deleted !== "stopped"; if (!deleted) { @@ -1461,7 +1468,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = } toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error"))); } - }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]); + }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, userEmailsFor, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]); // A drag or resize. A series asks which occurrences it covers, as the // editor does; one event moves at once and can be moved back. @@ -1609,11 +1616,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = if (!detailEvent || !client) return; // A target names the event and participant: the owner's in a calendar // shared with the user, or the user's own copy (lib/calendar-delegate.ts). - const participantId = target?.participantId ?? getUserParticipantId(detailEvent, currentUserEmails); + const participantId = target?.participantId ?? getUserParticipantId(detailEvent, userEmailsFor(detailEvent)); if (!participantId) return; closeDetail(); await handleRsvp(target?.eventId ?? detailEvent.id, participantId, status, target?.sentBy); - }, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]); + }, [detailEvent, client, userEmailsFor, closeDetail, handleRsvp]); // `/` and the palette's "Search events" go to the search box. const openSearch = useCallback(() => { @@ -1673,6 +1680,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = useAppCommands({ "new-event": () => openCreateModal(), "new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); }, + "import-calendar": () => { if (!demoMode) setShowImportModal(true); }, }, true); // Its View menu's Day, Week, Month and Agenda, and Edit > Search events. const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); }; @@ -1689,6 +1697,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = "view.calendar.agenda": { enabled: true, checked: normalizedViewMode === "agenda" }, "edit.search": normalizedViewMode !== "tasks", "file.newTask": enableCalendarTasks, + "file.importCalendar": !demoMode, }); useRegisterCommands(tSidebar("calendar"), [ @@ -1795,6 +1804,26 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = [editabilityContext], ); + // Answering an invitation from the grid (pim-9), as the login the event is + // in, the popover's way (handleRsvp: a series asks "this event or all"). + const gridAnswerFor = useCallback((event: CalendarEvent) => { + const emails = userEmailsFor(event); + if (!canUserRsvp(event, { ...editabilityContext, userCalendarAddresses: emails })) return null; + const participantId = getUserParticipantId(event, emails); + return participantId ? { participantId, status: event.participants?.[participantId]?.participationStatus ?? null } : null; + }, [userEmailsFor, editabilityContext]); + const handleRespondFromGrid = useCallback((event: CalendarEvent, status: CalendarParticipant['participationStatus']) => { + const answer = gridAnswerFor(event); + if (answer) void handleRsvp(event.id, answer.participantId, status); + }, [gridAnswerFor, handleRsvp]); + const handleReplyEvent = useCallback((anchor: HTMLElement, event: CalendarEvent) => { + closeDetail(); + // Nothing to answer here (no right to): the event's card says why. + if (!gridAnswerFor(event)) { handleSelectEvent(event, anchor.getBoundingClientRect()); return; } + setAnswersOnlyMenu(true); + openEventContextMenuFrom(anchor, event, "end"); + }, [closeDetail, gridAnswerFor, handleSelectEvent, openEventContextMenuFrom]); + // The event the user opened, marked in the grid (APP.md: the selected // event gets the primary ring). const selectedEventId = detailPinned ? detailEvent?.id ?? null : showEventModal ? editEvent?.id ?? null : null; @@ -1819,10 +1848,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // The plugin drawing the task view's list, if one offers it. const pluginTaskView = usePluginTaskView(); + const hiddenTaskListIds = useCalendarStore((s) => s.hiddenTaskListIds); // The tasks the task list shows; plugin buttons in its toolbar act on them. const listedTaskIds = useMemo( - () => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter).map((task) => task.id), - [taskStore.tasks, selectedCalendarIds, taskStore.filter], + () => filterTasksForList(taskStore.tasks, hiddenTaskListIds, taskStore.filter).map((task) => task.id), + [taskStore.tasks, hiddenTaskListIds, taskStore.filter], ); // Tasks on the month, week and day grids follow the calendar filter like @@ -1953,6 +1983,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onHoverLeave={handleHoverLeave} onContextMenuEvent={handleContextMenuEvent} onContextMenuEmpty={handleContextMenuEmpty} + onReplyEvent={handleReplyEvent} onCreateAllDay={handleGridCreateAllDay} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} @@ -1980,6 +2011,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onHoverLeave={handleHoverLeave} onContextMenuEvent={handleContextMenuEvent} onContextMenuEmpty={handleContextMenuEmpty} + onReplyEvent={handleReplyEvent} onCreateAtTime={handleGridCreate} onCreateAllDay={handleGridCreateAllDay} firstDayOfWeek={firstDayOfWeek} @@ -2008,6 +2040,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onHoverLeave={handleHoverLeave} onContextMenuEvent={handleContextMenuEvent} onContextMenuEmpty={handleContextMenuEmpty} + onReplyEvent={handleReplyEvent} onCreateAtTime={handleGridCreate} timeFormat={timeFormat} isMobile={isMobile} @@ -2032,6 +2065,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} onContextMenuEvent={handleContextMenuEvent} + onReplyEvent={handleReplyEvent} timeFormat={timeFormat} currentUserEmails={currentUserEmails} onCreateEvent={() => openCreateModal()} @@ -2042,20 +2076,19 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = { - // Into a calendar that takes it: the default one, else the - // first the user may write to. - const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0]; + onQuickCreate={(title, due) => { + // Into the first task-only list, else the default calendar, + // else the first the user may write to (pim-8). + const target = defaultTaskList(writableTaskCalendars); if (!client || !target) { toast.error(t("notifications.task_save_error")); return; } - taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } }) + taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true }, ...(due ? { due, showWithoutTime: true } : {}) }) .then((created) => { if (created) toast.success(t("notifications.task_created")); }) .catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error")))); }} @@ -2383,9 +2416,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {renderView()} - {/* Desktop event panel */} + {/* Desktop event panel: a sheet over the grid from the right, as + on small screens, so the week keeps its width (pim-25). Above + the views' sticky headers (z-50) inside this isolated row. */} {!isMobile && showEventModal && ( -
+
{ setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} onPreviewChange={setPendingPreview} - currentUserEmails={currentUserEmails} + currentUserEmails={userEmailsFor(editEvent)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={false} @@ -2477,6 +2512,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)} onPrint={printOff ? undefined : () => { void requestPrint({ kind: "calendar-event", event: eventContextMenu.data! }); }} editable={canEditEvent(eventContextMenu.data)} + onRespond={gridAnswerFor(eventContextMenu.data) ? (status) => handleRespondFromGrid(eventContextMenu.data!, status) : undefined} + responseStatus={gridAnswerFor(eventContextMenu.data)?.status} + answersOnly={answersOnlyMenu} /> )} @@ -2541,7 +2579,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onRsvp={handleRsvpFromDetail} onMouseEnter={() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } }} onMouseLeave={handleHoverLeave} - currentUserEmails={currentUserEmails} + currentUserEmails={userEmailsFor(detailEvent)} isSubscriptionCalendar={isSubscriptionCalendar} timeFormat={timeFormat} isMobile={isMobile} @@ -2567,7 +2605,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined} onDirtyChange={setEventEditorDirty} onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} - currentUserEmails={currentUserEmails} + currentUserEmails={userEmailsFor(editEvent)} isSubscriptionCalendar={isSubscriptionCalendar} maxParticipants={maxParticipants} isMobile={true} @@ -2650,7 +2688,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = isOpen={!!pendingScopeAction} actionType={pendingScopeAction?.type || "edit"} note={pendingScopeAction?.type === "delete" && pendingScopeAction.sendScheduling - && userOrganizes(pendingScopeAction.event, currentUserEmails) + && userOrganizes(pendingScopeAction.event, userEmailsFor(pendingScopeAction.event)) ? t("participants.cancel_notification") : undefined} eventTitle={pendingScopeAction?.event.title || t("events.no_title")} diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 05778e26a..8368b0429 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -34,6 +34,8 @@ interface CalendarDayViewProps extends ScrollWindowViewProps { onHoverLeave?: () => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void; + /** The "?" / "Reply" marker of an unanswered invitation (pim-9). */ + onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void; onCreateAtTime: (date: Date, endDate?: Date) => void; timeFormat?: "12h" | "24h"; isMobile?: boolean; @@ -80,6 +82,7 @@ export function CalendarDayView({ onHoverLeave, onContextMenuEvent, onContextMenuEmpty, + onReplyEvent, onCreateAtTime, timeFormat = "24h", isMobile, @@ -448,6 +451,7 @@ export function CalendarDayView({ onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + onReply={onReplyEvent} currentUserEmails={currentUserEmails} tabbable={key === visibleKey} /> @@ -579,6 +583,7 @@ export function CalendarDayView({ onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + onReply={onReplyEvent} currentUserEmails={currentUserEmails} tabbable={key === visibleKey} draggable={editable} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 0ae91dc00..6ad3d854a 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -25,6 +25,7 @@ import { PluginDayLabel } from "./plugin-day-label"; import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter"; import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { useEventUserEmails } from "@/hooks/use-event-user-emails"; interface CalendarMonthViewProps extends ScrollWindowViewProps { selectedDate: Date; @@ -36,6 +37,8 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps { onHoverLeave?: () => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void; + /** The "?" / "Reply" marker of an unanswered invitation (pim-9). */ + onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void; /** A click on a day's empty space: a new all-day event there (wide screens). */ onCreateAllDay?: (date: Date) => void; /** Open a day in the day view (its date number, or its "N more" link). */ @@ -78,6 +81,67 @@ export function monthGridMetrics(isMobile: boolean, textScale: number) { return { overlayTop, rowHeight, chipHeight, baseRowMinHeight }; } +/** + * The height of a week on a wide screen (pim-6): the weeks of the month in + * view share the pane, so the whole month shows without scrolling, at 150 % + * zoom too. Never less than the date and one entry. + */ +export function monthRowHeight( + availableHeight: number, + weeksInMonth: number, + { overlayTop, rowHeight, fallback }: { overlayTop: number; rowHeight: number; fallback: number }, +): number { + if (availableHeight <= 0) return fallback; + return Math.max(overlayTop + 4 + rowHeight + 4, Math.floor(availableHeight / Math.max(1, weeksInMonth))); +} + +interface FitSegment { startIndex: number; span: number; row: number } + +/** + * What a fixed-height week shows of each day (pim-6). A day with no more + * entries than `capacity` rows shows them all; a fuller one shows one row + * fewer and "+n more" in the last. An event over several days shows only + * where all its days have room for its row, and counts as more elsewhere. + * `limits[d]` is the first row day d doesn't draw an entry in (its "+n more" + * row when `hidden[d]` > 0). With `moreInHeader` the link sits beside the + * date instead and every row shows an entry: a week of one row (150 % zoom) + * shows each day's first event, not a lone "+2 more". + */ +export function fitMonthWeek( + segments: FitSegment[], + dayEventRows: number[], + dayTaskCounts: number[], + capacity: number, + moreInHeader = false, +): { limits: number[]; hidden: number[]; isShown: (segment: FitSegment) => boolean } { + const limits = dayEventRows.map((rows, d) => (!moreInHeader && rows + dayTaskCounts[d] > capacity ? capacity - 1 : capacity)); + const isShown = (segment: FitSegment) => { + for (let d = segment.startIndex; d < segment.startIndex + segment.span; d++) { + if (segment.row >= limits[d]) return false; + } + return true; + }; + const countHidden = () => limits.map((limit, d) => { + const events = segments.filter((s) => d >= s.startIndex && d < s.startIndex + s.span && !isShown(s)).length; + const tasks = dayTaskCounts[d] - Math.min(dayTaskCounts[d], Math.max(0, limit - dayEventRows[d])); + return events + tasks; + }); + let hidden = countHidden(); + // An event hidden for a neighbour's sake needs a "+n more" row here too. + for (let pass = 0; !moreInHeader && pass < limits.length; pass++) { + let changed = false; + hidden.forEach((count, d) => { + if (count > 0 && limits[d] === capacity) { + limits[d] = capacity - 1; + changed = true; + } + }); + if (!changed) break; + hidden = countHidden(); + } + return { limits, hidden, isShown }; +} + export function CalendarMonthView({ selectedDate, focus, @@ -96,6 +160,7 @@ export function CalendarMonthView({ onHoverLeave, onContextMenuEvent, onContextMenuEmpty, + onReplyEvent, onCreateAllDay, onOpenDay, isMobile, @@ -111,6 +176,8 @@ export function CalendarMonthView({ const t = useTranslations("calendar"); const paint = useCvdColor(); const describeError = useCalendarErrorMessage(); + // An event of another signed-in login is read with that login's addresses. + const userEmailsFor = useEventUserEmails(currentUserEmails ?? []); const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView); const timedStyle = useSettingsStore((state) => state.calendarMonthTimedEvents); // On mobile the month view collapses events to dots unless the user opted @@ -222,12 +289,20 @@ export function CalendarMonthView({ return () => observer.disconnect(); }, []); const availableHeight = Math.max(0, viewportHeight - headerHeight); - const rowMinHeight = Math.max(baseRowMinHeight, Math.floor(availableHeight / 6)); + // Wide screens: the weeks of the month navigated to fill the pane (pim-6), + // also while scrolling freely, so the rows keep their height. Phones keep + // six rows that grow with their chips. + const weeksInMonth = Math.max(4, Math.round(getMonthGridDays(focus.date).length / 7)); + const rowMinHeight = isMobile + ? Math.max(baseRowMinHeight, Math.floor(availableHeight / 6)) + : monthRowHeight(availableHeight, weeksInMonth, { overlayTop, rowHeight, fallback: baseRowMinHeight }); // On wide screens a week keeps its height: a day with more entries than // fit shows the first ones and a "N more" link (as in Google Calendar). // Phones keep growing the row to fit. const rowCapacity = Math.max(1, Math.floor((rowMinHeight - overlayTop - 4 - 8) / rowHeight)); const capRows = showChips && !isMobile; + // A week of one row has its "+n more" beside the date (pim-6). + const moreInHeader = capRows && rowCapacity === 1; // The month the user is looking at: the one with the most days in view. It // dims the other months' days and drives the title. One point on the screen @@ -400,19 +475,21 @@ export function CalendarMonthView({ if (newStartISO === data.originalStart) return; const client = useAuthStore.getState().client; if (!client) return; - const moved = movedEventUpdates(event, { start: newStartISO }, currentUserEmails ?? []); + const moved = movedEventUpdates(event, { start: newStartISO }, userEmailsFor(event)); if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "move"); else await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages); } catch (error) { toast.error(describeError(error, t("notifications.event_move_error"))); } - }, [t, describeError, currentUserEmails, onMoveEvent]); + }, [t, describeError, userEmailsFor, onMoveEvent]); return (
{weekSegments.map(({ week, segments, rowCount, dayEventRows, dayTasks, contentRows }) => { - const overflowing = capRows && contentRows > rowCapacity; - // Rows drawn per day; the last one is taken by the "N more" link. - const rowLimit = overflowing ? rowCapacity - 1 : Infinity; - const hiddenPerDay = overflowing - ? week.map((_, dayIndex) => { - const hiddenEvents = segments.filter((segment) => - segment.row >= rowLimit - && dayIndex >= segment.startIndex - && dayIndex < segment.startIndex + segment.span, - ).length; - const hiddenTasks = dayTasks[dayIndex].filter((_, taskIndex) => dayEventRows[dayIndex] + taskIndex >= rowLimit).length; - return hiddenEvents + hiddenTasks; - }) - : null; + // A fixed-height week: each day shows what fits and "+n more" (pim-6). + const fit = capRows ? fitMonthWeek(segments, dayEventRows, dayTasks.map((list) => list.length), rowCapacity, moreInHeader) : null; + const hiddenPerDay = fit?.hidden ?? null; // Rows that grow with their content make room for the new event's preview. const previewRows = !capRows && pendingPreview && week.some((day) => checkIsSameDay(pendingPreview.start, day)) ? 1 : 0; return (
@@ -534,7 +600,7 @@ export function CalendarMonthView({ const calId = getPrimaryCalendarId(ev); const cal = calId ? calendarMap.get(calId) : undefined; const evColor = paint(ev.color || cal?.color || "#3b82f6"); - const inactive = ev.status === "cancelled" || isDeclinedByUser(ev, currentUserEmails); + const inactive = ev.status === "cancelled" || isDeclinedByUser(ev, userEmailsFor(ev)); return ( (onOpenDay ?? onSelectDate)(week[dayIndex])} moreLabel={t("events.more", { count: hiddenPerDay?.[dayIndex] ?? 0 })} + moreInHeader={moreInHeader} paint={paint} /> )} @@ -623,14 +692,17 @@ type WeekSegment = ReturnType[number]; * across the following ones. */ function MonthCellEntries({ - dayIndex, week, segments, rowLimit, rowCount, dayEventRows, dayTasks, hidden, overlayTop, rowHeight, chipHeight, + dayIndex, week, segments, rowLimit, isShown, rowCount, dayEventRows, dayTasks, hidden, overlayTop, rowHeight, chipHeight, isMobile, calendarMap, pendingPreview, lastRow, selectedEventId, currentUserEmails, canEditEvent, inView, timedStyle, - onSelectEvent, onHoverEvent, onHoverLeave, onContextMenuEvent, onToggleTaskComplete, onSelectTask, onShowMore, moreLabel, paint, + onSelectEvent, onHoverEvent, onHoverLeave, onContextMenuEvent, onReplyEvent, onToggleTaskComplete, onSelectTask, onShowMore, moreLabel, moreInHeader = false, paint, }: { dayIndex: number; week: Date[]; segments: WeekSegment[]; + /** This day's first row without an entry: its "N more" row when some are hidden. */ rowLimit: number; + /** Whether a fixed-height week has room for an event on all its days. */ + isShown?: (segment: WeekSegment) => boolean; rowCount: number; dayEventRows: number[]; dayTasks: CalendarTask[][]; @@ -653,10 +725,13 @@ function MonthCellEntries({ onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; + onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void; onToggleTaskComplete?: (task: CalendarTask) => void; onSelectTask?: (task: CalendarTask) => void; onShowMore: () => void; moreLabel: string; + /** The link goes beside the date ("+2"), named in full. */ + moreInHeader?: boolean; paint: (color: string) => string; }) { const day = week[dayIndex]; @@ -675,7 +750,7 @@ function MonthCellEntries({ onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(), onContextMenu: (e: React.MouseEvent) => e.stopPropagation(), }; - const daySegments = segments.filter((segment) => segment.startIndex === dayIndex && segment.row < rowLimit); + const daySegments = segments.filter((segment) => segment.startIndex === dayIndex && (isShown ? isShown(segment) : segment.row < rowLimit)); const tasks = dayTasks[dayIndex].filter((_, taskIndex) => dayEventRows[dayIndex] + taskIndex < rowLimit); const preview = pendingPreview && isSameDay(pendingPreview.start, day) ? pendingPreview : null; @@ -703,6 +778,7 @@ function MonthCellEntries({ onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + onReply={onReplyEvent} currentUserEmails={currentUserEmails} draggable={canEditEvent(segment.event)} tabbable={spanDays.some(inView)} @@ -730,7 +806,22 @@ function MonthCellEntries({
); })} - {hidden > 0 && ( + {hidden > 0 && moreInHeader && ( +
+ +
+ )} + {hidden > 0 && !moreInHeader && (
@@ -533,7 +580,22 @@ export function CalendarSidebarPanel({ )} )} - {multiAccountMode && localAccountGroups.length > 0 ? ( + {inTasksView ? ( +
+ {renderSectionHeader('__task_lists__', tUx('task_lists'))} + {!collapsedAccountGroups.has('__task_lists__') && taskListGroups.map((group) => ( +
+ {group.label && ( +
+
+ )} + {group.lists.map((cal) => renderCalendarItem(cal, true))} +
+ ))} +
+ ) : multiAccountMode && localAccountGroups.length > 0 ? ( <> {localAccountGroups.map((group, idx) => { const expanded = !collapsedAccountGroups.has(group.key); @@ -581,7 +643,7 @@ export function CalendarSidebarPanel({ {t('my_calendars')}
- {owned.map(renderCalendarItem)} + {owned.map((cal) => renderCalendarItem(cal))}
)} @@ -592,7 +654,7 @@ export function CalendarSidebarPanel({ {sg.label}
- {sg.calendars.map(renderCalendarItem)} + {sg.calendars.map((cal) => renderCalendarItem(cal))}
))} @@ -627,7 +689,7 @@ export function CalendarSidebarPanel({ {group.accountName}
- {group.calendars.map(renderCalendarItem)} + {group.calendars.map((cal) => renderCalendarItem(cal))}
))} @@ -638,6 +700,16 @@ export function CalendarSidebarPanel({ {renderCalendarMenu()} + setRenaming(null)} + onSubmit={(name) => { const cal = renaming; setRenaming(null); if (cal) void handleRename(cal, name.trim()); }} + title={tUx('rename_title')} + placeholder={tMgmt('name_placeholder')} + defaultValue={renaming?.name ?? ''} + confirmText={tUx('rename_confirm')} + validate={(value) => (value.trim() ? null : tUx('rename_empty'))} + /> ); } diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 464e2ac8c..7051e1963 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -39,6 +39,8 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps { onHoverLeave?: () => void; onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void; + /** The "?" / "Reply" marker of an unanswered invitation (pim-9). */ + onReplyEvent?: (anchor: HTMLElement, event: CalendarEvent) => void; onCreateAtTime: (date: Date, endDate?: Date) => void; /** A click on a day in the all-day row: a new all-day event there. */ onCreateAllDay?: (date: Date) => void; @@ -91,6 +93,7 @@ export function CalendarWeekView({ onHoverLeave, onContextMenuEvent, onContextMenuEmpty, + onReplyEvent, onCreateAtTime, onCreateAllDay, firstDayOfWeek = 1, @@ -581,6 +584,7 @@ export function CalendarWeekView({ onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + onReply={onReplyEvent} currentUserEmails={currentUserEmails} tabbable={spanInView(segment.startIndex, segment.span)} /> @@ -724,6 +728,7 @@ export function CalendarWeekView({ onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} onContextMenu={onContextMenuEvent} + onReply={onReplyEvent} currentUserEmails={currentUserEmails} draggable={editable} continuesBefore={clip.clippedStart} diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 7f7bd5d4f..4a31e76e9 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -8,6 +8,8 @@ import { parseDuration } from "@/lib/calendar-event-normalization"; import { format } from "date-fns"; import { ChevronDown, ChevronUp, Users } from "@/components/icons"; import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants"; +import { gridReplyState, type GridReplyState } from "@/lib/calendar-reply-state"; +import { useEventUserEmails } from "@/hooks/use-event-user-emails"; import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { useSettingsStore, type MonthTimedEventStyle } from "@/stores/settings-store"; import { readableTextOn } from "@/lib/color-transform"; @@ -33,8 +35,17 @@ interface EventCardProps { continuesAfter?: boolean; className?: string; style?: CSSProperties; - /** The user's calendar addresses, to tell whether they declined the event. */ + /** + * The user's calendar addresses, to tell whether they declined or have yet + * to answer the event. An event of another signed-in login is read with + * that login's addresses. + */ currentUserEmails?: string[]; + /** + * The "?" / "Reply" marker of an invitation the user hasn't answered was + * clicked: offer Accept, Maybe and Decline next to it (pim-9). + */ + onReply?: (anchor: HTMLElement, event: CalendarEvent) => void; /** False for a day scrolled out of view: Tab skips it (arrows still reach it). */ tabbable?: boolean; /** @@ -97,6 +108,34 @@ export function eventFillStyle( return shadows.length > 0 ? { ...fill, boxShadow: shadows.join(", ") } : fill; } +/** + * An invitation the user hasn't answered: the calendar colour as an outline + * over a light hatch. One they answered "maybe": a dashed outline over a + * light tint (pim-9). + */ +export function replyFillStyle( + color: string, + state: Exclude, + { separated, selected }: { separated?: boolean; selected?: boolean }, +): CSSProperties { + const ring = selected ? "0 0 0 2px var(--color-primary)" : separated ? "0 0 0 1px var(--color-background)" : null; + if (state === "tentative") { + return { + backgroundColor: `color-mix(in srgb, ${color} 14%, var(--color-background))`, + color: "var(--color-foreground)", + border: `1.5px dashed ${color}`, + ...(ring ? { boxShadow: ring } : {}), + }; + } + const tint = `color-mix(in srgb, ${color} 18%, var(--color-background))`; + return { + backgroundColor: "var(--color-background)", + backgroundImage: `repeating-linear-gradient(135deg, ${tint} 0 4px, transparent 4px 8px)`, + color: eventTextColor(color), + boxShadow: [ring, `inset 0 0 0 1.5px ${color}`].filter(Boolean).join(", "), + }; +} + /** The class that draws the light style's bar; declined and cancelled events keep their outline. */ export function eventFillClass(tinted: boolean, inactive: boolean): string | undefined { return tinted && !inactive ? "start-bar" : undefined; @@ -117,8 +156,12 @@ function createEventDragPreview(title: string, timeRange: string, color: string) return el; } -export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true, timedStyle = "bar" }: EventCardProps) { +export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, onReply, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style, currentUserEmails, tabbable = true, timedStyle = "bar" }: EventCardProps) { const t = useTranslations("calendar"); + const tEvent = useTranslations("ux_pim_event"); + const tGrid = useTranslations("ux_pim_grid"); + // The addresses of the login the event is in (pim-12, pim-9). + const userEmails = useEventUserEmails(currentUserEmails ?? [])(event); const [isBeingDragged, setIsBeingDragged] = useState(false); const paint = useCvdColor(); const color = paint(getEventColor(event, calendar)); @@ -146,9 +189,62 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM // deleting it (#572), and a declined invitation stays on the calendar too // (#1110) - render both struck through and dimmed. const isCancelled = event.status === "cancelled"; - const isDeclined = !isCancelled && isDeclinedByUser(event, currentUserEmails); + const isDeclined = !isCancelled && isDeclinedByUser(event, userEmails); const isInactive = isCancelled || isDeclined; - const statusLabel = isCancelled ? t("detail.cancelled") : isDeclined ? t("participants.declined") : null; + // An invitation not answered yet, or answered "maybe" (pim-9). + const replyState = isInactive ? null : gridReplyState(event, userEmails); + const statusLabel = isCancelled + ? t("detail.cancelled") + : isDeclined + ? t("participants.declined") + : replyState === "needs-action" + ? tEvent("status_needs_action") + : replyState === "tentative" ? tEvent("status_tentative") : null; + const fillStyle = (opts: { separated?: boolean }) => replyState + ? replyFillStyle(color, replyState, { ...opts, selected: isSelected }) + : eventFillStyle(color, { inactive: isInactive, selected: isSelected, tinted, ...opts }); + // No light-style bar over the outline of an open answer. + const fillClass = eventFillClass(tinted, isInactive || replyState !== null); + const showMarker = replyState === "needs-action"; + // "?" in a narrow event, "Reply" where there is room. + const markerLabel = ( + <> + + {tGrid("reply")} + + ); + const markerClass = cn( + "absolute z-[1] inline-flex items-center justify-center h-5 min-w-5 max-w-20 px-1 rounded-xs", + "text-[0.625rem] font-semibold leading-none truncate", + // A block's title runs along its top: the marker sits at the bottom. + variant === "block" ? (continuesAfter ? "bottom-0.5 end-4" : "bottom-0.5 end-0.5") : "top-1/2 -translate-y-1/2 end-0.5", + ); + const markerStyle: CSSProperties = { backgroundColor: color, color: readableTextOn(color) }; + const marker = !showMarker ? null : onReply ? ( + + ) : ( + + ); + // Room for the marker at the end of a one-line card. + const markerRoom = showMarker && variant !== "block" ? "pe-6 @min-[11rem]/reply:pe-[5.5rem]" : undefined; + const withMarker = (card: React.ReactElement) => marker ? ( +
+ {card} + {marker} +
+ ) : card; const ariaLabel = `${event.title || t("events.no_title")}, ${spokenTime}${calendarName ? `, ${calendarName}` : ""}${statusLabel ? `, ${statusLabel}` : ""}`; const handleDragStart = useCallback((e: DragEvent) => { @@ -216,7 +312,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM } : {}; if (variant === "chip") { - return ( + return withMarker( ); } - return ( + return withMarker(