Merge pull request #1122 from sanitz/fix/week-allday-visible-week

fix(calendar): size the week view all-day area from the visible week
This commit is contained in:
Linus Rath
2026-09-29 23:24:42 +02:00
committed by GitHub
29 changed files with 347 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") : t("events.show_more")}
>
{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>
)}
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "لا توجد أحداث",
"all_day": "طوال اليوم",
"more": "+{count} أخرى",
"show_more": "إظهار المزيد",
"show_less": "إظهار أقل",
"no_title": "(بلا عنوان)",
"resize": "تغيير حجم الحدث",
"duplicate": "تكرار",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Cap esdeveniment",
"all_day": "Tot el dia",
"more": "+{count} més",
"show_more": "Mostra'n més",
"show_less": "Mostra'n menys",
"no_title": "(Sense títol)",
"resize": "Redimensiona l'esdeveniment",
"duplicate": "Duplica",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Žádné události",
"all_day": "Celý den",
"more": "+{count} dalších",
"show_more": "Zobrazit více",
"show_less": "Zobrazit méně",
"no_title": "(Bez názvu)",
"resize": "Změnit velikost události",
"duplicate": "Duplikovat",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Ingen begivenheder",
"all_day": "Hele dagen",
"more": "+{count} mere",
"show_more": "Vis mere",
"show_less": "Vis mindre",
"no_title": "(Ingen titel)",
"resize": "Tilpas størrelse på begivenhed",
"duplicate": "Duplikér",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Keine Termine",
"all_day": "Ganztägig",
"more": "+{count} weitere",
"show_more": "Mehr anzeigen",
"show_less": "Weniger anzeigen",
"no_title": "(Kein Titel)",
"resize": "Termingröße ändern",
"duplicate": "Duplizieren",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "No events",
"all_day": "All day",
"more": "+{count} more",
"show_more": "Show more",
"show_less": "Show less",
"no_title": "(No title)",
"resize": "Resize event",
"duplicate": "Duplicate",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Sin eventos",
"all_day": "Todo el día",
"more": "+{count} más",
"show_more": "Mostrar más",
"show_less": "Mostrar menos",
"no_title": "(Sin título)",
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "رویدادی نیست",
"all_day": "تمام روز",
"more": "+{count} بیشتر",
"show_more": "نمایش بیشتر",
"show_less": "نمایش کمتر",
"no_title": "(بدون عنوان)",
"resize": "تغییر اندازه رویداد",
"duplicate": "کپی",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Aucun événement",
"all_day": "Toute la journée",
"more": "+{count} de plus",
"show_more": "Afficher plus",
"show_less": "Afficher moins",
"no_title": "(Sans titre)",
"resize": "Redimensionner l'événement",
"duplicate": "Dupliquer",
+2
View File
@@ -2776,6 +2776,8 @@
"no_events": "אין אירועים",
"all_day": "כל היום",
"more": "+{count}נוספים",
"show_more": "הצג עוד",
"show_less": "הצג פחות",
"no_title": "(ללא כותרת)",
"resize": "שנה את גודל האירוע",
"duplicate": "שכפול",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Nincs esemény",
"all_day": "Egész napos",
"more": "+{count} további",
"show_more": "Több mutatása",
"show_less": "Kevesebb mutatása",
"no_title": "(Nincs cím)",
"resize": "Esemény átméretezése",
"duplicate": "Duplikálás",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Nessun evento",
"all_day": "Tutto il giorno",
"more": "+{count} altri",
"show_more": "Mostra di più",
"show_less": "Mostra meno",
"no_title": "(Senza titolo)",
"resize": "Ridimensiona evento",
"duplicate": "Duplica",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "予定なし",
"all_day": "終日",
"more": "他{count}件",
"show_more": "展開する",
"show_less": "折りたたむ",
"no_title": "(タイトルなし)",
"resize": "イベントのサイズ変更",
"duplicate": "複製",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "일정이 없어요",
"all_day": "하루 종일",
"more": "+{count}개 더보기",
"show_more": "더 보기",
"show_less": "간략히 보기",
"no_title": "(제목 없음)",
"resize": "일정 크기 조절",
"duplicate": "복제",
+2
View File
@@ -2851,6 +2851,8 @@
"no_events": "Nav pasākumu",
"all_day": "Visa diena",
"more": "vēl {count}",
"show_more": "Rādīt vairāk",
"show_less": "Rādīt mazāk",
"no_title": "(Bez virsraksta)",
"resize": "Mainīt pasākuma laiku",
"duplicate": "Dublēt",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Үйл явдал байхгүй",
"all_day": "Бүтэн өдөр",
"more": "+{count} дэлгэрэнгүй",
"show_more": "Илүү харуулах",
"show_less": "Бага харуулах",
"no_title": "(Гарчиггүй)",
"resize": "Үйл явдлын хэмжээг өөрчлөх",
"duplicate": "Давхардсан",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Ingen hendelser",
"all_day": "Hele dagen",
"more": "+{count} til",
"show_more": "Vis flere",
"show_less": "Vis færre",
"no_title": "(Ingen tittel)",
"resize": "Endre størrelsen på hendelsen",
"duplicate": "Dupliser",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Geen evenementen",
"all_day": "Hele dag",
"more": "+{count} meer",
"show_more": "Meer tonen",
"show_less": "Minder tonen",
"no_title": "(Geen titel)",
"resize": "Evenement formaat wijzigen",
"duplicate": "Dupliceren",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Brak wydarzeń",
"all_day": "Cały dzień",
"more": "+{count} więcej",
"show_more": "Pokaż więcej",
"show_less": "Pokaż mniej",
"no_title": "(Bez tytułu)",
"resize": "Zmień rozmiar wydarzenia",
"duplicate": "Duplikuj",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Nenhum evento",
"all_day": "Dia inteiro",
"more": "+{count} mais",
"show_more": "Mostrar mais",
"show_less": "Mostrar menos",
"no_title": "(Sem título)",
"resize": "Redimensionar evento",
"duplicate": "Duplicar",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Fără evenimente",
"all_day": "Toată ziua",
"more": "+{count} mai multe",
"show_more": "Afișați mai mult",
"show_less": "Afișați mai puțin",
"no_title": "(Fără titlu)",
"resize": "Eveniment de redimensionare",
"duplicate": "Duplicare",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Нет событий",
"all_day": "Весь день",
"more": "ещё {count}",
"show_more": "Развернуть",
"show_less": "Свернуть",
"no_title": "(Без названия)",
"resize": "Изменить размер события",
"duplicate": "Дублировать",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Žiadne udalosti",
"all_day": "Celý deň",
"more": "+{count} ďalších",
"show_more": "Zobraziť viac",
"show_less": "Zobraziť menej",
"no_title": "(Bez názvu)",
"resize": "Zmeniť veľkosť udalosti",
"duplicate": "Duplikovať",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Etkinlik yok",
"all_day": "Tüm gün",
"more": "+{count} daha",
"show_more": "Daha fazla göster",
"show_less": "Daha az göster",
"no_title": "(Başlık yok)",
"resize": "Etkinliği yeniden boyutlandır",
"duplicate": "Çoğalt",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "Подій немає",
"all_day": "Весь день",
"more": "+ ще {count}",
"show_more": "Показувати більше",
"show_less": "Показувати менше",
"no_title": "(Без назви)",
"resize": "Змінити розмір події",
"duplicate": "дублікат",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "沒有活動",
"all_day": "全天",
"more": "另有 {count} 個",
"show_more": "顯示更多",
"show_less": "顯示較少",
"no_title": "(無標題)",
"resize": "調整活動大小",
"duplicate": "建立副本",
+2
View File
@@ -2852,6 +2852,8 @@
"no_events": "没有活动",
"all_day": "全天",
"more": "+{count} 更多",
"show_more": "展开",
"show_less": "收起",
"no_title": "(无标题)",
"resize": "调整事件大小",
"duplicate": "复制",