mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
fix: show every week of the month on one screen, with +n more for full days
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user