From 7bfab1cb8af2a9b3ba6b8e1ad91189833ecf2c6f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:04:49 +0100 Subject: [PATCH 01/10] fix: fill the week and day view with the visible hours and mark events that run past them --- .../__tests__/calendar-display-range.test.tsx | 77 +++++++++++++++++++ components/calendar/calendar-day-view.tsx | 36 +++++---- components/calendar/calendar-week-view.tsx | 36 +++++---- components/calendar/event-card.tsx | 20 ++++- hooks/use-display-hours.ts | 60 ++++++++++++--- 5 files changed, 185 insertions(+), 44 deletions(-) diff --git a/components/calendar/__tests__/calendar-display-range.test.tsx b/components/calendar/__tests__/calendar-display-range.test.tsx index 2135445a7..0bb49cab1 100644 --- a/components/calendar/__tests__/calendar-display-range.test.tsx +++ b/components/calendar/__tests__/calendar-display-range.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { CalendarWeekView } from '../calendar-week-view'; import { CalendarDayView } from '../calendar-day-view'; import { useSettingsStore } from '@/stores/settings-store'; +import { fillHourHeight } from '@/hooks/use-display-hours'; import type { CalendarEvent, Calendar } from '@/lib/jmap/types'; const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; @@ -154,3 +155,79 @@ describe('calendar display hours and working days (#1164)', () => { expect(document.querySelector('[data-hidden-events="before"]')).toHaveTextContent('1'); }); }); + +describe('time grid height (CAL-27)', () => { + // A tablet-tall pane: the grid's scroll root reports 1100px of room and + // its sticky header 100px, leaving 1000px for 12 hours. + beforeEach(() => { + useSettingsStore.setState(initialSettings, true); + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.getAttribute('role') === 'grid' ? 1100 : 0; + }); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.classList.contains('sticky') && this.classList.contains('top-0') ? 100 : 0; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + cleanup(); + }); + + it('stretches the hour rows to fill the pane below a short range', () => { + renderWeek({ events: [makeEvent('noon', '2026-09-09T12:00:00', 'PT1H')] }); + // floor(1000 / 12) = 83px an hour instead of leaving 280px empty + expect(screen.getAllByRole('gridcell')[0].style.height).toBe('83px'); + expect(eventBlock('noon')?.style.top).toBe(`${4 * 83}px`); + expect(eventBlock('noon')?.style.height).toBe('83px'); + }); + + it('fits the rows to the pane, shrinking them only a little', () => { + expect(fillHourHeight(60, 1000, 12)).toBe(83); + // 59px rows instead of 4px of scrolling + expect(fillHourHeight(60, 716, 12)).toBe(59); + // a whole day on a short pane scrolls at the normal height + expect(fillHourHeight(60, 716, 24)).toBe(60); + expect(fillHourHeight(60, 0, 12)).toBe(60); + }); + + it('keeps the normal row height when the whole day is shown', () => { + useSettingsStore.setState({ calendarLimitHours: false }); + renderWeek(); + expect(screen.getAllByRole('gridcell')[0].style.height).toBe('60px'); + }); + + it('marks events that run past the visible hours instead of cutting them silently', () => { + renderWeek({ + events: [ + makeEvent('late', '2026-09-09T19:00:00', 'PT2H'), + makeEvent('early', '2026-09-09T07:00:00', 'PT2H'), + makeEvent('inside', '2026-09-09T12:00:00', 'PT1H'), + ], + }); + const card = (id: string) => screen.getByText('Event ' + id).closest('button')!; + expect(card('late')).toHaveAttribute('data-continues-after'); + expect(card('late')).not.toHaveAttribute('data-continues-before'); + expect(card('early')).toHaveAttribute('data-continues-before'); + expect(card('inside')).not.toHaveAttribute('data-continues-after'); + expect(card('inside')).not.toHaveAttribute('data-continues-before'); + }); + + it('fills the day view too', () => { + render( + , + ); + expect(screen.getAllByRole('gridcell')[0].style.height).toBe('83px'); + expect(screen.getByText('Event late').closest('button')).toHaveAttribute('data-continues-after'); + }); +}); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 0d85aa1a9..d4db6f55f 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -57,7 +57,7 @@ interface CalendarDayViewProps extends ScrollWindowViewProps { revealAt?: RevealTime | null; } -const HOUR_HEIGHT = 64; +const MIN_HOUR_HEIGHT = 64; const HOURS = Array.from({ length: 24 }, (_, i) => i); const FALLBACK_COL_WIDTH = 600; @@ -100,6 +100,8 @@ export function CalendarDayView({ // the hours scroll down, and the sticky header block and hour gutter stay // put. (A nested vertical scroller would capture the gutter's stickiness.) const rootRef = useRef(null); + // The sticky date header (and all-day row): the hour rows fill what is left. + const headerRef = useRef(null); const startSentinelRef = useRef(null); const endSentinelRef = useRef(null); const gutterWidth = isMobile ? 40 : 64; @@ -176,11 +178,11 @@ export function CalendarDayView({ const tasksByDay = useMemo(() => groupTasksByDueDay(tasks), [tasks]); const { - hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, - } = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt }); + hours, hourHeight, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, + } = useDisplayHours({ scrollRef: rootRef, headerRef, minHourHeight: MIN_HOUR_HEIGHT, revealAt }); const firstHour = hours.startMinutes / 60; const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60); - const gridHeight = visibleHours.length * HOUR_HEIGHT; + const gridHeight = visibleHours.length * hourHeight; // Column layouts are the costly part of a render; with months of columns // they must not be redone on every scroll-driven re-render. @@ -278,7 +280,7 @@ export function CalendarDayView({ quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel, dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop, } = useTimeGridInteractions({ - hourHeight: HOUR_HEIGHT, + hourHeight, gridStartMinutes: hours.startMinutes, gridEndMinutes: hours.endMinutes, calendars, @@ -319,7 +321,7 @@ export function CalendarDayView({
-
+
{canToggleHours && ( {/* Midnight goes unlabelled; a later first hour is labelled inside its row, where the header cannot cover it. */} @@ -472,7 +474,7 @@ export function CalendarDayView({ onDoubleClick={() => handleSlotDoubleClick(day, h)} onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" - style={{ height: HOUR_HEIGHT }} + style={{ height: hourHeight }} /> ))} @@ -494,10 +496,10 @@ export function CalendarDayView({ {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { const durMin = Math.max(15, endMinutes - startMinutes); const clip = clipToDisplayHours(startMinutes, endMinutes, hours); - const baseHeight = Math.max(24, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT); + const baseHeight = Math.max(24, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * hourHeight); // Short events at the bottom edge stay inside the grid. const baseTop = Math.max(0, Math.min( - ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT, + ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight, gridHeight - baseHeight, )); const isResizing = resizeVisual?.eventId === ev.id; @@ -526,6 +528,8 @@ export function CalendarDayView({ onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} draggable={editable} + continuesBefore={clip.clippedStart} + continuesAfter={clip.clippedEnd} /> {/* An edge beyond the visible hours has nothing to grab. */} {editable && !clip.clippedStart && ( @@ -559,7 +563,7 @@ export function CalendarDayView({ {today && nowMinutes >= hours.startMinutes && nowMinutes <= hours.endMinutes && (
@@ -580,8 +584,8 @@ export function CalendarDayView({
@@ -593,7 +597,7 @@ export function CalendarDayView({ {dropTarget?.dayKey === key && (
@@ -621,8 +625,8 @@ export function CalendarDayView({ data-pending-preview className="absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md" style={{ - top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT, - height: Math.max(24, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT), + top: ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight, + height: Math.max(24, ((clip.endMinutes - clip.startMinutes) / 60) * hourHeight), backgroundColor: color, color: readableTextOn(color), }} diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index a985681ed..ce8f4f230 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -66,7 +66,7 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps { revealAt?: RevealTime | null; } -const HOUR_HEIGHT = 60; +const MIN_HOUR_HEIGHT = 60; const HOURS = Array.from({ length: 24 }, (_, i) => i); const MOBILE_COL_WIDTH = 120; const MIN_COL_WIDTH = 80; @@ -114,6 +114,8 @@ export function CalendarWeekView({ // the hours scroll down, and the sticky header rows and hour gutter stay // put. (A nested vertical scroller would capture the gutter's stickiness.) const rootRef = useRef(null); + // The sticky date header (and all-day row): the hour rows fill what is left. + const headerRef = useRef(null); const startSentinelRef = useRef(null); const endSentinelRef = useRef(null); const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6; @@ -211,11 +213,11 @@ export function CalendarWeekView({ }, [events]); const { - hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, - } = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt }); + hours, hourHeight, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes, + } = useDisplayHours({ scrollRef: rootRef, headerRef, minHourHeight: MIN_HOUR_HEIGHT, revealAt }); const firstHour = hours.startMinutes / 60; const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60); - const gridHeight = visibleHours.length * HOUR_HEIGHT; + const gridHeight = visibleHours.length * hourHeight; // Column layouts are the costly part of a render; with months of columns // they must not be redone on every scroll-driven re-render. @@ -374,7 +376,7 @@ export function CalendarWeekView({ quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel, dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop, } = useTimeGridInteractions({ - hourHeight: HOUR_HEIGHT, + hourHeight, gridStartMinutes: hours.startMinutes, gridEndMinutes: hours.endMinutes, calendars, @@ -414,7 +416,7 @@ export function CalendarWeekView({
-
+
{canToggleHours && ( @@ -608,7 +610,7 @@ export function CalendarWeekView({
{/* Midnight goes unlabelled; a later first hour is labelled inside its row, where the header cannot cover it. */} @@ -654,7 +656,7 @@ export function CalendarWeekView({ onDoubleClick={() => handleSlotDoubleClick(day, h)} onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" - style={{ height: HOUR_HEIGHT }} + style={{ height: hourHeight }} /> ))} @@ -676,10 +678,10 @@ export function CalendarWeekView({ {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { const durMin = Math.max(15, endMinutes - startMinutes); const clip = clipToDisplayHours(startMinutes, endMinutes, hours); - const baseHeight = Math.max(20, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT); + const baseHeight = Math.max(20, (Math.max(15, clip.endMinutes - clip.startMinutes) / 60) * hourHeight); // Short events at the bottom edge stay inside the grid. const baseTop = Math.max(0, Math.min( - ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT, + ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight, gridHeight - baseHeight, )); const isResizing = resizeVisual?.eventId === ev.id; @@ -708,6 +710,8 @@ export function CalendarWeekView({ onContextMenu={onContextMenuEvent} currentUserEmails={currentUserEmails} draggable={editable} + continuesBefore={clip.clippedStart} + continuesAfter={clip.clippedEnd} /> {/* An edge beyond the visible hours has nothing to grab. */} {editable && !clip.clippedStart && ( @@ -741,7 +745,7 @@ export function CalendarWeekView({ {todayCol && nowMinutes >= hours.startMinutes && nowMinutes <= hours.endMinutes && (
@@ -762,8 +766,8 @@ export function CalendarWeekView({
@@ -775,7 +779,7 @@ export function CalendarWeekView({ {dropTarget?.dayKey === key && (
@@ -804,8 +808,8 @@ export function CalendarWeekView({ data-pending-preview className="absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md" style={{ - top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT, - height: Math.max(20, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT), + top: ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight, + height: Math.max(20, ((clip.endMinutes - clip.startMinutes) / 60) * hourHeight), backgroundColor: color, color: readableTextOn(color), }} diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index cd851d5ff..0aefaaef1 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import { format } from "date-fns"; -import { Users } from "@/components/icons"; +import { ChevronDown, ChevronUp, Users } from "@/components/icons"; import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants"; import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { useSettingsStore } from "@/stores/settings-store"; @@ -22,7 +22,12 @@ interface EventCardProps { onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void; isSelected?: boolean; draggable?: boolean; + /** + * The event goes on before what is drawn: before the first day of a span, + * above the first visible hour of a block. + */ continuesBefore?: boolean; + /** The event goes on after what is drawn: past the last day or hour. */ continuesAfter?: boolean; className?: string; style?: CSSProperties; @@ -234,14 +239,25 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM aria-label={ariaLabel} {...dragProps} data-calendar-event + data-continues-before={continuesBefore || undefined} + data-continues-after={continuesAfter || undefined} className={cn( - "w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden", + // A block box: an inline one hangs its line's descent below the + // grid's last hour and makes the filled pane scroll. + "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden", "hover:opacity-90 transition-opacity cursor-pointer", + // Cut at the edge of the visible hours: a square edge and an arrow + // say the event runs on past it. + continuesBefore && "rounded-t-none", + continuesAfter && "rounded-b-none", + continuesBefore && "pe-4", isBeingDragged && "opacity-50", className )} style={{ ...eventFillStyle(color, { inactive: isInactive, separated: true, selected: isSelected }), ...style }} > + {continuesBefore &&