From ea6d0ee6784fe18ad7e7dfca7416621e8f3b788a Mon Sep 17 00:00:00 2001 From: Sebastian Sanitz Date: Mon, 28 Sep 2026 23:42:30 +0200 Subject: [PATCH] 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 --- .../calendar-week-view-all-day-cap.test.tsx | 188 ++++++++++++++++++ components/calendar/calendar-week-view.tsx | 154 +++++++++----- 2 files changed, 293 insertions(+), 49 deletions(-) create mode 100644 components/calendar/__tests__/calendar-week-view-all-day-cap.test.tsx diff --git a/components/calendar/__tests__/calendar-week-view-all-day-cap.test.tsx b/components/calendar/__tests__/calendar-week-view-all-day-cap.test.tsx new file mode 100644 index 000000000..1fc4014fd --- /dev/null +++ b/components/calendar/__tests__/calendar-week-view-all-day-cap.test.tsx @@ -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 { + 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; + +function renderView(overrides: Partial = {}) { + 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() }; +} + +function chipSlot(taskId: string): HTMLElement { + const chip = document.querySelector(`[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('[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('[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('[role="grid"]')!; + const columns = document.querySelector('[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(); + }); +}); diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 733bb2a5d..e41b0a197 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -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(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 && (
- {t("events.all_day")} + {t("events.all_day")} + {isExpandable && ( + + )}
{days.map((day) => (
- {/* Task chips in all-day area */} - {taskRowCount > 0 && ( -
- {days.map((day, dayIndex) => { - const key = format(day, "yyyy-MM-dd"); - const dayTasks = tasksByDay.get(key) || []; - return dayTasks.map((task, taskIndex) => ( -
- task.calendarIds[c.id])} - onToggleComplete={onToggleTaskComplete} - onSelect={onSelectTask} - /> -
- )); - })} -
- )} + {/* Task chips in all-day area, stacked under events of their day (#1107, #1270) */} +
+ {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) => ( +
+ task.calendarIds[c.id])} + onToggleComplete={onToggleTaskComplete} + onSelect={onSelectTask} + /> +
+ )); + })} +
)}