diff --git a/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx
new file mode 100644
index 000000000..fce16b53c
--- /dev/null
+++ b/components/calendar/__tests__/event-block-title-ux-p2-pim.test.tsx
@@ -0,0 +1,37 @@
+import { render, renderHook, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { EventCard } from '../event-card';
+import { useDisplayHours } from '@/hooks/use-display-hours';
+import type { CalendarEvent } from '@/lib/jmap/types';
+
+// pim-p2-19: the part of "Night train to Vienna" after midnight starts at the
+// top of the day, scrolled out of view, so the week showed a block with no
+// title. The title line now sticks under the grid's header while the block is
+// in sight. A block that hid its overflow was a scroller of its own, and a
+// sticky line inside one sticks to the block, not to the grid.
+
+const train = {
+ id: 'n1', '@type': 'Event', uid: 'n1', title: 'Night train to Vienna', start: '2026-11-07T00:00:00',
+ duration: 'PT7H40M', showWithoutTime: false, status: 'confirmed', calendarIds: { cal: true }, participants: null,
+} as unknown as CalendarEvent;
+
+describe('the title of a long block stays in sight', () => {
+ it('sticks the title line under the header and clips the block without making it a scroller', () => {
+ render();
+ const card = screen.getByRole('button', { name: /Night train to Vienna/ });
+ expect(card.className).toContain('overflow-clip');
+ expect(card.className).not.toContain('overflow-hidden');
+ const line = card.querySelector('[data-event-title-line]') as HTMLElement;
+ expect(line.className).toContain('sticky');
+ expect(line.className).toContain('top-[var(--grid-header-height,0px)]');
+ expect(line.textContent).toContain('Night train to Vienna');
+ });
+
+ it('tells the grid how tall its header is, for the sticky title', () => {
+ const pane = document.createElement('div');
+ const header = document.createElement('div');
+ Object.defineProperty(header, 'offsetHeight', { value: 96 });
+ renderHook(() => useDisplayHours({ scrollRef: { current: pane }, headerRef: { current: header }, minHourHeight: 48 }));
+ expect(pane.style.getPropertyValue('--grid-header-height')).toBe('96px');
+ });
+});
diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx
index 4a31e76e9..8f9732155 100644
--- a/components/calendar/event-card.tsx
+++ b/components/calendar/event-card.tsx
@@ -450,8 +450,9 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
data-continues-after={continuesAfter || undefined}
className={cn(
// 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",
+ // grid's last hour and makes the filled pane scroll. Clipped, not
+ // hidden: hidden makes the block a scroller and its title can't stick.
+ "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-clip",
"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.
@@ -470,6 +471,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{/* Shorter blocks have no room for a second line at the 60px hour, so
their start time follows the title on the same line, when the
block is wide enough to keep the title readable. */}
+ {/* The title stays in sight under the grid's header while the block
+ is: the morning part of a night train started at midnight, scrolled
+ out of view, and showed as a block with no name (pim-p2-19). */}
+
{event.title || t("events.no_title")}
@@ -487,6 +492,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
)}
+
{visibleCalendarName && (
{visibleCalendarName}
)}
diff --git a/hooks/use-display-hours.ts b/hooks/use-display-hours.ts
index 68a64e34c..39821c87f 100644
--- a/hooks/use-display-hours.ts
+++ b/hooks/use-display-hours.ts
@@ -58,7 +58,13 @@ export function useDisplayHours({ scrollRef, minHourHeight, headerRef, revealAt
useLayoutEffect(() => {
const root = scrollRef.current;
if (!root) return;
- const measure = () => setAvailable(Math.max(0, root.clientHeight - (headerRef?.current?.offsetHeight ?? 0)));
+ const measure = () => {
+ const headerHeight = headerRef?.current?.offsetHeight ?? 0;
+ setAvailable(Math.max(0, root.clientHeight - headerHeight));
+ // Where an event's title sticks while its block runs above the visible
+ // hours: an overnight event's morning part (pim-p2-19).
+ root.style?.setProperty("--grid-header-height", `${headerHeight}px`);
+ };
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);