mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
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:
co-authored by
Claude Opus 5.5
parent
0b3e47e3d2
commit
ea6d0ee678
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user