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/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index ada11d958..6173f2dff 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -81,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, @@ -228,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 @@ -415,7 +484,10 @@ export function CalendarMonthView({ }, [t, describeError, userEmailsFor, onMoveEvent]); return ( -
+
+ {/* At 150 % the browser rounds the sticky header a device pixel below + the pane's edge, and a sliver of the week scrolled under it showed. */} +