fix(calendar): size the week view all-day area from the visible week

The all-day/tasks area in the week view took its height from
(allDayRowCount + taskRowCount) across every loaded day. Since free
scrolling loads 37 to 180 days, one crowded day anywhere in that range
blew the header up to 250+ px for every week and pushed the time grid down;
tasks were also stacked below the global all-day row count, leaving a gap
on days without all-day events.

- Stack each day's tasks directly below that day's all-day events.
- Size the area from the days in view only: track the first visible day by
  date (so columns prepended while loading do not shift it) plus the number
  of visible columns (7 on desktop, by width on mobile).
- Cap the area at 3 rows with an expand/collapse toggle when a visible day
  has more entries.

Tests cover per-day task stacking, the 3-row cap and toggle, an empty week
next to a crowded one, and the area growing when scrolling into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sebastian Sanitz
2026-09-28 23:42:30 +02:00
co-authored by Claude Opus 5.5
parent 0b3e47e3d2
commit ea6d0ee678
2 changed files with 293 additions and 49 deletions
@@ -0,0 +1,188 @@
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { CalendarWeekView } from '../calendar-week-view';
import type { CalendarEvent, Calendar, CalendarTask } from '@/lib/jmap/types';
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
function makeTask(id: string, due: string, overrides: Partial<CalendarTask> = {}): CalendarTask {
return {
id,
'@type': 'Task',
uid: id,
title: 'Task ' + id,
description: '',
due,
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-09-01T00:00:00Z',
recurrenceRules: null,
alerts: null,
relatedTo: null,
calendarIds: { 'cal-1': true },
...overrides,
};
}
function makeAllDayEvent(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;
}
type Props = React.ComponentProps<typeof CalendarWeekView>;
function renderView(overrides: Partial<Props> = {}) {
const props: Props = {
focus: { date: new Date(2026, 8, 9), nonce: 0 },
windowKey: 'week:2026-09-07',
// Monday 7 Sep to Sunday 13 Sep 2026
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 13),
selectedDate: new Date(2026, 8, 9),
events: [],
calendars,
onSelectDate: vi.fn(),
onSelectEvent: vi.fn(),
onCreateAtTime: vi.fn(),
...overrides,
};
return { props, ...render(<CalendarWeekView {...props} />) };
}
function chipSlot(taskId: string): HTMLElement {
const chip = document.querySelector<HTMLElement>(`[data-calendar-task="${taskId}"]`);
if (!chip?.parentElement) throw new Error(`no chip for ${taskId}`);
return chip.parentElement;
}
describe('CalendarWeekView all-day and tasks height cap', () => {
afterEach(() => {
cleanup();
});
it('stacks tasks directly below events of their own day instead of global allDayRowCount', () => {
// Monday 2026-09-07 has 2 all-day events
// Tuesday 2026-09-08 has NO events, but 1 task
renderView({
events: [
makeAllDayEvent('e1', '2026-09-07'),
makeAllDayEvent('e2', '2026-09-07'),
],
tasks: [
makeTask('t-tue', '2026-09-08'),
],
});
// Tuesday's task must start at row 0 (top: 2px), NOT pushed down to row 2 (top: 50px)
const tueSlot = chipSlot('t-tue');
expect(tueSlot.style.top).toBe('2px');
});
it('caps all-day height to default max rows (3 rows = 76px) and provides an expand toggle when crowded', () => {
// Monday 2026-09-07 has 5 tasks (rows 0..4)
renderView({
tasks: [
makeTask('t1', '2026-09-07'),
makeTask('t2', '2026-09-07'),
makeTask('t3', '2026-09-07'),
makeTask('t4', '2026-09-07'),
makeTask('t5', '2026-09-07'),
],
});
const toggle = screen.getByRole('button', { name: /all-day-expand/i });
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute('aria-expanded', 'false');
// Default height is capped to 3 rows (3 * 24 + 4 = 76px)
const allDayGrid = document.querySelector<HTMLElement>('[data-testid="all-day-grid"]');
expect(allDayGrid).toBeInTheDocument();
expect(allDayGrid?.style.height).toBe('76px');
// Click toggle to expand
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(allDayGrid?.style.height).toBe('124px'); // 5 * 24 + 4
});
it('sizes the all-day area from the visible week, not from other loaded weeks (#1293)', () => {
// Three weeks loaded (free scrolling), the focused first week is empty;
// a crowded day two weeks later must not add height or a toggle here.
renderView({
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 27),
windowKey: 'week:2026-09-07:3w',
events: [
makeAllDayEvent('e1', '2026-09-23'),
makeAllDayEvent('e2', '2026-09-23'),
makeAllDayEvent('e3', '2026-09-23'),
makeAllDayEvent('e4', '2026-09-23'),
],
});
expect(screen.queryByRole('button', { name: /all-day-expand/i })).toBeNull();
expect(document.querySelector('[data-testid="all-day-grid"]')).toBeNull();
});
it('still sizes the all-day area from entries in the visible week (#1293)', () => {
renderView({
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 27),
windowKey: 'week:2026-09-07:3w',
events: [
makeAllDayEvent('e1', '2026-09-08'),
makeAllDayEvent('e2', '2026-09-23'),
makeAllDayEvent('e3', '2026-09-23'),
makeAllDayEvent('e4', '2026-09-23'),
],
});
const allDayGrid = document.querySelector<HTMLElement>('[data-testid="all-day-grid"]');
expect(allDayGrid?.style.height).toBe('28px');
expect(screen.queryByRole('button', { name: /all-day-expand/i })).toBeNull();
});
it('resizes the all-day area when scrolling to a crowded week (#1293)', async () => {
renderView({
rangeStart: new Date(2026, 8, 7),
rangeEnd: new Date(2026, 8, 27),
windowKey: 'week:2026-09-07:3w',
events: [
makeAllDayEvent('e1', '2026-09-23'),
makeAllDayEvent('e2', '2026-09-23'),
makeAllDayEvent('e3', '2026-09-23'),
makeAllDayEvent('e4', '2026-09-23'),
],
});
expect(document.querySelector('[data-testid="all-day-grid"]')).toBeNull();
// Scroll the strip to Monday 21 Sep: 14 columns of the measured width
const strip = document.querySelector<HTMLElement>('[role="grid"]')!;
const columns = document.querySelector<HTMLElement>('[style*="grid-template-columns"]')!;
const colWidth = parseInt(/(\d+)px/.exec(columns.style.gridTemplateColumns)![1], 10);
strip.scrollLeft = 14 * colWidth;
fireEvent.scroll(strip);
await new Promise((r) => requestAnimationFrame(() => r(null)));
const allDayGrid = await screen.findByTestId('all-day-grid');
expect(allDayGrid.style.height).toBe('76px');
expect(screen.getByRole('button', { name: /all-day-expand/i })).toBeInTheDocument();
});
});
+105 -49
View File
@@ -100,15 +100,22 @@ export function CalendarWeekView({
return Math.max(MIN_COL_WIDTH, Math.floor((root.clientWidth - gutterWidth) / 7));
}, [isMobile, gutterWidth]);
const [colWidth, setColWidth] = useState(MOBILE_COL_WIDTH);
const [viewportWidth, setViewportWidth] = useState(0);
// First day in view, reported by the scroll handler below (#1293)
const [visibleDayKey, setVisibleDayKey] = useState<string | null>(null);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
setColWidth(measureColWidth(root));
const measure = () => {
setColWidth(measureColWidth(root));
setViewportWidth(Math.max(0, root.clientWidth - gutterWidth));
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setColWidth(measureColWidth(root)));
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [measureColWidth]);
}, [measureColWidth, gutterWidth]);
// Every scroll offset is computed with the column width that is rendered.
// When that width changes (first measurement, resize) keep the same day at
@@ -177,26 +184,55 @@ export function CalendarWeekView({
return packWeekSegments([...explicitAllDay, ...timedFullDay]);
}, [events, days]);
const allDayRowCount = useMemo(() => {
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
}, [allDaySegments]);
const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
// Max tasks on any single loaded day
const taskRowCount = useMemo(() => {
// Tasks stack under the events of their own day rather than globally (#1107, #1270)
const dayEventRows = useMemo(() => {
return days.map((_, dayIndex) =>
allDaySegments.reduce(
(rows, segment) =>
dayIndex >= segment.startIndex && dayIndex < segment.startIndex + segment.span
? Math.max(rows, segment.row + 1)
: rows,
0,
)
);
}, [days, allDaySegments]);
// The strip holds weeks to months of days (#759), but the all-day area is
// sized from the columns in view: a crowded day in another week must not
// add empty rows or an expand toggle here (#1293). The first visible day is
// tracked by date, so columns prepended while loading do not shift it.
const visibleStartIndex = useMemo(() => {
const tracked = visibleDayKey ? days.findIndex((d) => dayKey(d) === visibleDayKey) : -1;
if (tracked >= 0) return tracked;
const target = isMobile ? focus.date : startOfWeek(focus.date, { weekStartsOn: weekStart });
return Math.max(0, Math.min(days.length - 1, differenceInCalendarDays(target, rangeStart)));
}, [visibleDayKey, days, isMobile, focus.date, weekStart, rangeStart]);
const visibleColumns = isMobile ? Math.max(1, Math.ceil(viewportWidth / colWidth)) : 7;
// Max combined rows (events + tasks) across the visible days
const maxContentRows = useMemo(() => {
let max = 0;
for (const day of days) {
const key = format(day, "yyyy-MM-dd");
const count = tasksByDay.get(key)?.length ?? 0;
if (count > max) max = count;
const end = Math.min(days.length, visibleStartIndex + visibleColumns);
for (let i = visibleStartIndex; i < end; i++) {
const key = format(days[i], "yyyy-MM-dd");
const taskCount = tasksByDay.get(key)?.length ?? 0;
const total = dayEventRows[i] + taskCount;
if (total > max) max = total;
}
return max;
}, [tasksByDay, days]);
}, [days, dayEventRows, tasksByDay, visibleStartIndex, visibleColumns]);
const hasAllDay = useMemo(() => {
return allDaySegments.length > 0 || taskRowCount > 0;
}, [allDaySegments, taskRowCount]);
return maxContentRows > 0;
}, [maxContentRows]);
const DEFAULT_ALL_DAY_MAX_ROWS = 3;
const [isAllDayExpanded, setIsAllDayExpanded] = useState(false);
const isExpandable = maxContentRows > DEFAULT_ALL_DAY_MAX_ROWS;
const visibleRows = isAllDayExpanded ? maxContentRows : Math.min(DEFAULT_ALL_DAY_MAX_ROWS, maxContentRows);
const allDayHeight = Math.max(28, visibleRows * 24 + 4);
useEffect(() => {
if (rootRef.current) {
@@ -258,6 +294,7 @@ export function CalendarWeekView({
const key = dayKey(day);
if (key === visibleKeyRef.current) return;
visibleKeyRef.current = key;
setVisibleDayKey(key);
onVisibleDateChange?.(day);
});
}, [colWidth, colCount, days, onVisibleDateChange]);
@@ -323,14 +360,34 @@ export function CalendarWeekView({
{hasAllDay && (
<div className="flex border-b border-border">
<div
className={cn(gutterClass, "text-[10px] text-muted-foreground p-1 text-end")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
className={cn(gutterClass, "text-[10px] text-muted-foreground p-1 text-end flex flex-col justify-between")}
style={{ height: allDayHeight }}
>
{t("events.all_day")}
<span>{t("events.all_day")}</span>
{isExpandable && (
<button
type="button"
aria-label="all-day-expand"
aria-expanded={isAllDayExpanded}
onClick={() => setIsAllDayExpanded((prev) => !prev)}
className="self-end mt-auto text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-0.5 rounded px-1 py-0.5 hover:bg-muted transition-colors cursor-pointer"
title={isAllDayExpanded ? t("events.show_less", { defaultMessage: "Weniger anzeigen" }) : t("events.show_more", { defaultMessage: "Mehr anzeigen" })}
>
{isAllDayExpanded ? (
<span>▲</span>
) : (
<>
<span>+{maxContentRows - DEFAULT_ALL_DAY_MAX_ROWS}</span>
<span>▼</span>
</>
)}
</button>
)}
</div>
<div
className="relative grid border-s border-border"
style={{ ...columnsStyle, minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
data-testid="all-day-grid"
className="relative grid border-s border-border overflow-hidden"
style={{ ...columnsStyle, height: allDayHeight }}
>
{days.map((day) => (
<div
@@ -371,34 +428,33 @@ export function CalendarWeekView({
})}
</div>
{/* Task chips in all-day area */}
{taskRowCount > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: allDayRowCount * 24 + 2 }}>
{days.map((day, dayIndex) => {
const key = format(day, "yyyy-MM-dd");
const dayTasks = tasksByDay.get(key) || [];
return dayTasks.map((task, taskIndex) => (
<div
key={`task-${task.id}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
width: `calc(${(1 / colCount) * 100}% - 2px)`,
top: taskIndex * 24,
height: 20,
}}
>
<CalendarTaskChip
task={task}
calendar={calendars.find(c => task.calendarIds[c.id])}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
/>
</div>
));
})}
</div>
)}
{/* Task chips in all-day area, stacked under events of their day (#1107, #1270) */}
<div className="absolute inset-0 pointer-events-none">
{days.map((day, dayIndex) => {
const key = format(day, "yyyy-MM-dd");
const dayTasks = tasksByDay.get(key) || [];
const baseRow = dayEventRows[dayIndex];
return dayTasks.map((task, taskIndex) => (
<div
key={`task-${task.id}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
width: `calc(${(1 / colCount) * 100}% - 2px)`,
top: (baseRow + taskIndex) * 24 + 2,
height: 20,
}}
>
<CalendarTaskChip
task={task}
calendar={calendars.find(c => task.calendarIds[c.id])}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
/>
</div>
));
})}
</div>
</div>
</div>
)}