fix: show every week of the month on one screen, with +n more for full days

This commit is contained in:
Linus Rath
2026-11-04 10:57:58 +01:00
parent 409c7110c0
commit 0c6782e004
2 changed files with 235 additions and 22 deletions
@@ -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(
<CalendarMonthView
focus={{ date: focus, nonce: 0 }}
windowKey={`month:${focus.toISOString().slice(0, 10)}`}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
selectedDate={focus}
events={events}
calendars={calendars}
onSelectDate={vi.fn()}
onSelectEvent={vi.fn()}
/>,
);
return Array.from(document.querySelectorAll<HTMLElement>('[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');
});
});
+105 -22
View File
@@ -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 (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={`${t("views.month")}, ${formatMonthYear(visibleMonthDate)}`}>
<div className="relative flex flex-col flex-1 overflow-hidden" role="grid" aria-label={`${t("views.month")}, ${formatMonthYear(visibleMonthDate)}`}>
{/* 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. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 z-[60] h-px bg-background" />
<div
ref={scrollRef}
className="flex-1 flex flex-col overflow-y-auto [overflow-anchor:none]"
@@ -437,25 +509,14 @@ export function CalendarMonthView({
</div>
<div ref={topSentinelRef} data-testid="month-top-sentinel" className="h-px flex-shrink-0" />
{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 (
<div key={dayKey(week[0])} data-week={dayKey(week[0])} className="relative flex-shrink-0 border-b border-border" role="row" style={{
minHeight: showChips && !overflowing
minHeight: showChips && !capRows
? Math.max(rowMinHeight, overlayTop + 4 + (contentRows + previewRows) * rowHeight + 8)
: rowMinHeight,
}}>
@@ -577,7 +638,8 @@ export function CalendarMonthView({
dayIndex={dayIndex}
week={week}
segments={segments}
rowLimit={rowLimit}
rowLimit={fit ? fit.limits[dayIndex] : Infinity}
isShown={fit?.isShown}
rowCount={rowCount}
dayEventRows={dayEventRows}
dayTasks={dayTasks}
@@ -603,6 +665,7 @@ export function CalendarMonthView({
onSelectTask={onSelectTask}
onShowMore={() => (onOpenDay ?? onSelectDate)(week[dayIndex])}
moreLabel={t("events.more", { count: hiddenPerDay?.[dayIndex] ?? 0 })}
moreInHeader={moreInHeader}
paint={paint}
/>
)}
@@ -630,14 +693,17 @@ type WeekSegment = ReturnType<typeof buildWeekSegments>[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, onReplyEvent, 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[][];
@@ -665,6 +731,8 @@ function MonthCellEntries({
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];
@@ -683,7 +751,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;
@@ -739,7 +807,22 @@ function MonthCellEntries({
</div>
);
})}
{hidden > 0 && (
{hidden > 0 && moreInHeader && (
<div className="absolute z-10" style={{ top: 4, insetInlineEnd: 2, height: 24 }} {...own}>
<button
type="button"
data-month-more
tabIndex={inView(day) ? undefined : -1}
onClick={onShowMore}
aria-label={moreLabel}
title={moreLabel}
className="h-full px-1.5 rounded-xs text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
+{hidden}
</button>
</div>
)}
{hidden > 0 && !moreInHeader && (
<div className="absolute z-10 px-0.5" style={place(rowLimit)} {...own}>
<button
type="button"