From b63fcfe6f49dc76698ee9d2e24bf8eda3cb86c11 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 21 Jul 2026 13:01:57 +0530 Subject: [PATCH] feat: dataview timeline component --- apps/www/src/app/examples/timeline/page.tsx | 788 ++++++++++++ apps/www/src/components/dataview-demo.tsx | 312 ++++- apps/www/src/components/demo/demo.tsx | 4 + .../content/docs/components/dataview/demo.ts | 76 ++ .../docs/components/dataview/index.mdx | 137 ++- .../content/docs/components/dataview/props.ts | 175 ++- .../data-view/__tests__/timeline.test.tsx | 976 +++++++++++++++ .../data-view/components/timeline.tsx | 1061 +++++++++++++++++ .../components/data-view/data-view.module.css | 156 +++ .../components/data-view/data-view.tsx | 2 + .../components/data-view/data-view.types.tsx | 155 +++ .../raystack/components/data-view/index.ts | 7 + .../components/data-view/utils/pack-lanes.tsx | 43 + .../components/data-view/utils/time-scale.tsx | 220 ++++ packages/raystack/index.tsx | 6 + 15 files changed, 4112 insertions(+), 6 deletions(-) create mode 100644 apps/www/src/app/examples/timeline/page.tsx create mode 100644 packages/raystack/components/data-view/__tests__/timeline.test.tsx create mode 100644 packages/raystack/components/data-view/components/timeline.tsx create mode 100644 packages/raystack/components/data-view/utils/pack-lanes.tsx create mode 100644 packages/raystack/components/data-view/utils/time-scale.tsx diff --git a/apps/www/src/app/examples/timeline/page.tsx b/apps/www/src/app/examples/timeline/page.tsx new file mode 100644 index 000000000..6f8a74839 --- /dev/null +++ b/apps/www/src/app/examples/timeline/page.tsx @@ -0,0 +1,788 @@ +/** biome-ignore-all lint/suspicious/noShadowRestrictedNames: public component name intentionally matches the package export */ +'use client'; + +import { + Badge, + Button, + DataView, + type DataViewField, + type DataViewListColumn, + Dialog, + EmptyState, + Flex, + IconButton, + Navbar, + Sidebar, + Text, + type TimelineActions, + type TimelineCardContext +} from '@raystack/apsara'; +import { BellIcon, FilterIcon, SidebarIcon } from '@raystack/apsara/icons'; +import dayjs from 'dayjs'; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState +} from 'react'; + +type OrderStatus = 'scheduled' | 'in-progress' | 'partial' | 'completed'; + +type Order = { + id: string; + name: string; + org: string; + priority: number; + status: OrderStatus; + startDate: string; + dueDate: string; +}; + +/* Dates are generated relative to today so the today-line and the + "Due in N days" danger states are always live in the example. Pinned to + midnight so server- and client-rendered output match (no hydration drift). */ +const DAY_MS = 86_400_000; +const startOfToday = () => { + const now = new Date(); + now.setHours(0, 0, 0, 0); + return now.getTime(); +}; +const daysFromNow = (days: number) => + new Date(startOfToday() + days * DAY_MS).toISOString(); + +/* ── Programmatic scroll domain (min/max of the timeline) ───────────────── + The explorable window is fixed to ±6 months around today. An explicit + `range` keeps the coordinate space stable while data streams in — the + scrollbar never jumps as new rows load. */ +const RANGE: [string, string] = [daysFromNow(-183), daysFromNow(183)]; + +/* How far beyond the visible window to prefetch on each scroll event. */ +const PREFETCH_MS = 14 * DAY_MS; +/* Minimum spacing between range fetches while scrolling. */ +const FETCH_THROTTLE_MS = 200; +/* Simulated network latency for the fake orders API. */ +const API_LATENCY_MS = 500; + +const ORG_LIST = [ + 'Company A', + 'Company B', + 'Company C', + 'Company D', + 'Company E' +]; + +const NAME_POOL = [ + 'Order_1', + 'Order_2', + 'Order_3', + 'Order_4', + 'Order_5', + 'Order_6', + 'Order_7', + 'Order_8', + 'Order_9' +]; + +const STATUS_LABEL: Record = { + scheduled: 'Scheduled', + 'in-progress': 'In progress', + partial: 'Partially delivered', + completed: 'Completed' +}; + +/* ── Simulated orders backend ────────────────────────────────────────────── + The full dataset is generated once up front (the "database table"), then + `fetchOrdersApi` answers range queries against it with the interval-overlap + predicate — the same shape a real RQL backend would run: + + start_time <= to AND end_time >= from + + i.e. a card is returned iff it has at least one visible pixel in the + window. A containment filter (`start >= from AND end <= to`) would drop + cards that straddle either window edge. */ +function mulberry32(seed: number) { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function ordersForMonth(monthKey: string): Order[] { + const monthStart = dayjs(`${monthKey}-01`); + const rng = mulberry32(monthStart.year() * 100 + monthStart.month()); + const today = startOfToday(); + const count = 5 + Math.floor(rng() * 4); // 5–8 orders per month + return Array.from({ length: count }, (_, i) => { + const start = monthStart.add(Math.floor(rng() * 27), 'day'); + const due = start.add(2 + Math.floor(rng() * 16), 'day'); + let status: OrderStatus; + if (due.valueOf() < today) { + status = rng() < 0.75 ? 'completed' : 'partial'; + } else if (start.valueOf() > today) { + status = 'scheduled'; + } else { + status = rng() < 0.5 ? 'in-progress' : 'partial'; + } + return { + id: `${monthKey}-${i}`, + name: NAME_POOL[Math.floor(rng() * NAME_POOL.length)], + org: ORG_LIST[Math.floor(rng() * ORG_LIST.length)], + priority: 1 + Math.floor(rng() * 10), + status, + startDate: start.toISOString(), + dueDate: due.toISOString() + }; + }); +} + +function monthKeysBetween(fromMs: number, toMs: number): string[] { + const keys: string[] = []; + let cursor = dayjs(fromMs).startOf('month'); + const end = dayjs(toMs); + while (cursor.isBefore(end)) { + keys.push(cursor.format('YYYY-MM')); + cursor = cursor.add(1, 'month'); + } + return keys; +} + +/* The whole "table", generated once at module load. Seeded per month, so the + data is stable across renders and reloads within a day. */ +const ALL_ORDERS: Order[] = monthKeysBetween( + Date.parse(RANGE[0]), + Date.parse(RANGE[1]) +).flatMap(ordersForMonth); + +function fetchOrdersApi(fromMs: number, toMs: number): Promise { + // Interval-overlap predicate: starts before the window closes AND ends + // after it opens — catches fully-inside cards and edge-spanners alike. + const rows = ALL_ORDERS.filter( + order => + Date.parse(order.startDate) <= toMs && Date.parse(order.dueDate) >= fromMs + ).sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate)); + return new Promise(resolve => + setTimeout(() => resolve(rows), API_LATENCY_MS) + ); +} + +// Renderer-agnostic metadata — drives search, filters, and the Display +// Properties toggles that the card composes via . +const fields: DataViewField[] = [ + { + accessorKey: 'name', + label: 'Order', + filterable: true, + filterType: 'string', + sortable: true, + hideable: false + }, + { + accessorKey: 'org', + label: 'Organization', + filterable: true, + filterType: 'select', + hideable: true, + filterOptions: ORG_LIST.map(org => ({ value: org, label: org })) + }, + { + accessorKey: 'status', + label: 'Status', + filterable: true, + filterType: 'select', + hideable: true, + filterOptions: (Object.keys(STATUS_LABEL) as OrderStatus[]).map(status => ({ + value: status, + label: STATUS_LABEL[status] + })) + }, + { accessorKey: 'priority', label: 'Priority', hideable: true }, + { + accessorKey: 'startDate', + label: 'Start date', + filterable: true, + filterType: 'date', + sortable: true, + hideable: true + }, + { + accessorKey: 'dueDate', + label: 'Due date', + filterable: true, + filterType: 'date', + sortable: true, + hideable: true + } +]; + +/* Status icons matching the Figma card anatomy: outlined circle (scheduled), + pie (in progress), half-filled circle (partially delivered), check circle + (completed). */ +function StatusIcon({ status }: { status: OrderStatus }) { + const accent = 'var(--rs-color-foreground-accent-primary)'; + const success = 'var(--rs-color-foreground-success-primary)'; + const shared = { + width: 16, + height: 16, + viewBox: '0 0 16 16', + 'aria-label': STATUS_LABEL[status], + role: 'img', + style: { flexShrink: 0 } + } as const; + switch (status) { + case 'completed': + return ( + + + + + ); + case 'partial': + return ( + + + + + ); + case 'in-progress': + return ( + + + + + ); + case 'scheduled': + return ( + + + + ); + } +} + +/* ── Shared order-details dialog ────────────────────────────────────────── + One detached Dialog instance serves every card. Cards don't render their + own ; clicking a card opens this instance imperatively via + the handle, passing the clicked order as the payload. */ +const orderDialog = Dialog.createHandle(); + +const ellipsis: React.CSSProperties = { + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' +}; + +function formatDue(date: Date) { + return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }); +} + +const STATUS_BADGE_VARIANT: Record< + OrderStatus, + 'accent' | 'success' | 'warning' | 'neutral' +> = { + scheduled: 'neutral', + 'in-progress': 'accent', + partial: 'warning', + completed: 'success' +}; + +/* Due-date urgency shared by the timeline card, the list view, and the + details dialog. */ +function dueState(order: Order) { + const due = new Date(order.dueDate); + const daysLeft = Math.ceil((due.getTime() - Date.now()) / DAY_MS); + return { + due, + daysLeft, + urgent: daysLeft >= 0 && daysLeft <= 5 && order.status !== 'completed' + }; +} + +function DueBadge({ order }: { order: Order }) { + const { due, daysLeft, urgent } = dueState(order); + return ( + + {urgent + ? `Due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}` + : `Due on ${formatDue(due)}`} + + ); +} + +/* ── List view (table variant of DataView.List) ─────────────────────────── + Same row model, columnar presentation — switched with the view tabs in + the Display settings popover. Columns reference the same accessorKeys as + `fields`, so visibility toggles, filters, and sort apply to both views. */ +type OrderCell = { row: { original: Order } }; + +const orderColumns: DataViewListColumn[] = [ + { + accessorKey: 'name', + width: 'minmax(220px, 1.5fr)', + cell: ({ row }: OrderCell) => ( + + + + {row.original.name} + + + ) + }, + { + accessorKey: 'status', + width: '150px', + cell: ({ row }: OrderCell) => ( + + {STATUS_LABEL[row.original.status]} + + ) + }, + { + accessorKey: 'priority', + width: '90px', + cell: ({ row }: OrderCell) => ( + + P{row.original.priority} + + ) + }, + { + accessorKey: 'startDate', + width: '120px', + cell: ({ row }: OrderCell) => ( + {formatDue(new Date(row.original.startDate))} + ) + }, + { + accessorKey: 'dueDate', + width: '150px', + cell: ({ row }: OrderCell) => + }, + { + accessorKey: 'org', + width: 'minmax(180px, 1fr)', + cell: ({ row }: OrderCell) => ( + + {row.original.org} + + ) + } +]; + +/* The card is entirely consumer-owned — the Timeline only positions it. + Chrome, danger state, truncation, and the collapsed stub all live here. */ +function OrderCard({ + order, + context +}: { + order: Order; + context: TimelineCardContext; +}) { + const { urgent } = dueState(order); + + const chrome: React.CSSProperties = { + height: '100%', + boxSizing: 'border-box', + borderRadius: 'var(--rs-radius-3)', + border: `1px solid ${ + urgent + ? 'var(--rs-color-border-danger-emphasis)' + : 'var(--rs-color-border-base-primary)' + }`, + background: urgent + ? 'var(--rs-color-background-danger-primary)' + : 'var(--rs-color-background-base-primary)', + overflow: 'hidden' + }; + + // Narrow spans (context.collapsed) render as a compact priority stub, like + // the tiny "P10" card in the design. + if (context.collapsed) { + return ( + + + P{order.priority} + + + ); + } + + return ( + + + + + {order.name} + + + + P{order.priority} + + + + + + + + + + {order.org} + + + + + ); +} + +function DetailRow({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +/* The dialog body renders from the handle's payload — the order of whichever + card was clicked last. */ +function OrderDetailsDialog() { + return ( + + {({ payload: order }) => + order ? ( + + {/* Dialog.Header is a row flex by default — stack title over + description, and keep clear of the top-right close button. */} + + + + {order.name} + + + {STATUS_LABEL[order.status]} · {order.org} + + + + + + + {STATUS_LABEL[order.status]} + + } + /> + + + + + + + + ) : null + } + + ); +} + +const Page = () => { + /* ── Range-window infinite loading ────────────────────────────────────── + The timeline reports its visible [from, to] via onVisibleRangeChange. + We widen that window by a prefetch pad and query the API with it. Month + buckets exist only as client-side bookkeeping of what's already been + requested — contiguous missing buckets coalesce into one query. Because + the API returns everything *overlapping* the window, a card straddling a + window edge comes back from both adjacent fetches, so merging dedupes by + row id. */ + const [orders, setOrders] = useState([]); + /* Imperative navigation handle — drives the "Today" button in the toolbar. + Null while the timeline view is inactive (methods no-op with a warning). */ + const timelineActions = useRef(null); + // Starts true: the first window is fetched on mount, and `isLoading` keeps + // the timeline shell (axis + skeletons) rendered while it's empty. + const [isLoading, setIsLoading] = useState(true); + const requestedRef = useRef>(new Set()); + const inflightRef = useRef(0); + const lastFetchRef = useRef(0); + const trailingRef = useRef | null>(null); + + const loadWindow = useCallback((fromMs: number, toMs: number) => { + const clampedFrom = Math.max(fromMs, Date.parse(RANGE[0])); + const clampedTo = Math.min(toMs, Date.parse(RANGE[1])); + if (clampedFrom >= clampedTo) return; + const missing = monthKeysBetween(clampedFrom, clampedTo).filter( + key => !requestedRef.current.has(key) + ); + if (missing.length === 0) return; + for (const key of missing) requestedRef.current.add(key); + // Coalesce consecutive missing months into contiguous [first, last] runs + // (jump-scrolling can leave already-fetched holes between them). + const runs: [string, string][] = []; + for (const key of missing) { + const last = runs[runs.length - 1]; + if ( + last && + dayjs(`${last[1]}-01`).add(1, 'month').format('YYYY-MM') === key + ) { + last[1] = key; + } else { + runs.push([key, key]); + } + } + inflightRef.current += runs.length; + setIsLoading(true); + for (const [firstKey, lastKey] of runs) { + fetchOrdersApi( + dayjs(`${firstKey}-01`).valueOf(), + dayjs(`${lastKey}-01`).endOf('month').valueOf() + ).then(rows => { + setOrders(prev => { + const seen = new Set(prev.map(order => order.id)); + const fresh = rows.filter(order => !seen.has(order.id)); + return fresh.length > 0 ? [...prev, ...fresh] : prev; + }); + inflightRef.current -= 1; + if (inflightRef.current === 0) setIsLoading(false); + }); + } + }, []); + + // Throttled (leading + trailing), NOT debounced: a debounce would wait for + // scrolling to stop, leaving a gap of missing cards during a long drag. + // Throttling fetches while the scroll is still in flight, and the trailing + // call covers the final resting position. Dedup in loadWindow keeps the + // extra invocations free. + const handleVisibleRangeChange = useCallback( + ([from, to]: [Date, Date]) => { + const fetchWindow = () => { + lastFetchRef.current = Date.now(); + loadWindow(from.getTime() - PREFETCH_MS, to.getTime() + PREFETCH_MS); + }; + if (trailingRef.current) clearTimeout(trailingRef.current); + const elapsed = Date.now() - lastFetchRef.current; + if (elapsed >= FETCH_THROTTLE_MS) { + fetchWindow(); + } else { + trailingRef.current = setTimeout( + fetchWindow, + FETCH_THROTTLE_MS - elapsed + ); + } + }, + [loadWindow] + ); + + // Initial fetch around today — the timeline starts centered on it, and + // subsequent windows load through onVisibleRangeChange as the user scrolls. + useEffect(() => { + const today = startOfToday(); + loadWindow(today - 2 * PREFETCH_MS, today + 2 * PREFETCH_MS); + }, [loadWindow]); + + useEffect( + () => () => { + if (trailingRef.current) clearTimeout(trailingRef.current); + }, + [] + ); + + return ( + + + + + + + + + Apsara + + + + + } + active + > + Timeline + + + + Help & Support + Preferences + + + + + + data={orders} + fields={fields} + mode='client' + isLoading={isLoading} + loadingRowCount={3} + defaultSort={{ name: 'dueDate', order: 'asc' }} + getRowId={(row: Order) => row.id} + onRowClick={order => orderDialog.openWithPayload(order)} + views={[ + { value: 'timeline', label: 'Timeline' }, + { value: 'list', label: 'List' } + ]} + defaultView='timeline' + > + + + + Order management · Timeline + + + + + + + Orders load as you scroll · {orders.length} loaded + + + + + + + + + {/* Empty/zero states live inside the same flex-1 pane as the + timeline: when the timeline renders null they center in the + pane instead of being pushed below an empty spacer. */} + + + name='timeline' + startField='startDate' + endField='dueDate' + scale='day' + unitWidth={20} + tickInterval={2} + gridlineInterval={2} + range={RANGE} + virtualized + actionsRef={timelineActions} + onVisibleRangeChange={handleVisibleRangeChange} + renderCard={(row, context) => ( + + )} + /> + + + } + heading='No matching orders' + variant='empty1' + subHeading='Try adjusting your filters or search.' + /> + + + } + heading='No orders yet' + variant='empty1' + subHeading='Orders will show up here once they are scheduled.' + /> + + + + + + + + + ); +}; + +export default Page; diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index d91ff698c..5feb4716e 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -10,9 +10,11 @@ import { DataViewField, DataViewListColumn, Flex, - Text + Text, + type TimelineActions, + type TimelineCardContext } from '@raystack/apsara'; -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; type Person = { id: string; @@ -604,3 +606,309 @@ export function DataViewPerViewFieldsDemo() { ); } + +/* ── Timeline demo ─────────────────────────────────────────────────────── */ + +type Task = { + id: string; + title: string; + team: 'Eng' | 'Design' | 'Ops'; + status: 'todo' | 'active' | 'done'; + start: string; + end: string; +}; + +const TASK_DAY_MS = 86_400_000; + +/* Dates relative to today, pinned to midnight so the today line is always + live and SSR/client renders match (no hydration drift). */ +const taskDate = (days: number) => { + const now = new Date(); + now.setHours(0, 0, 0, 0); + return new Date(now.getTime() + days * TASK_DAY_MS).toISOString(); +}; + +const taskSpec: Array< + [string, string, Task['team'], Task['status'], number, number] +> = [ + ['t1', 'Design audit', 'Design', 'done', -16, -9], + ['t2', 'API contracts', 'Eng', 'done', -13, -6], + ['t3', 'Billing revamp', 'Eng', 'active', -7, 2], + ['t4', 'Docs sprint', 'Design', 'active', -4, 4], + ['t5', 'Bug bash', 'Ops', 'todo', 1, 2], + ['t6', 'Load testing', 'Ops', 'todo', 3, 9], + ['t7', 'Beta rollout', 'Eng', 'todo', 6, 14], + ['t8', 'Launch comms', 'Design', 'todo', 10, 16] +]; + +const tasks: Task[] = taskSpec.map(([id, title, team, status, from, to]) => ({ + id, + title, + team, + status, + start: taskDate(from), + end: taskDate(to) +})); + +const taskFields: DataViewField[] = [ + { + accessorKey: 'title', + label: 'Task', + filterable: true, + filterType: 'string', + hideable: false + }, + { + accessorKey: 'team', + label: 'Team', + filterable: true, + filterType: 'select', + hideable: true, + filterOptions: [ + { label: 'Eng', value: 'Eng' }, + { label: 'Design', value: 'Design' }, + { label: 'Ops', value: 'Ops' } + ] + }, + { + accessorKey: 'status', + label: 'Status', + filterable: true, + filterType: 'select', + hideable: true, + filterOptions: [ + { label: 'To do', value: 'todo' }, + { label: 'Active', value: 'active' }, + { label: 'Done', value: 'done' } + ] + }, + { + accessorKey: 'start', + label: 'Start', + filterable: true, + filterType: 'date', + sortable: true, + hideable: true + }, + { + accessorKey: 'end', + label: 'End', + filterable: true, + filterType: 'date', + sortable: true, + hideable: true + } +]; + +const TASK_STATUS_BADGE: Record< + Task['status'], + 'neutral' | 'accent' | 'success' +> = { + todo: 'neutral', + active: 'accent', + done: 'success' +}; + +/* The card interior is entirely consumer-owned — the Timeline only positions + the wrapper. `context.collapsed` flags spans narrower than `minCardWidth`. */ +function TaskCard({ + task, + context +}: { + task: Task; + context: TimelineCardContext; +}) { + const chrome: React.CSSProperties = { + height: '100%', + boxSizing: 'border-box', + borderRadius: 'var(--rs-radius-3)', + border: '1px solid var(--rs-color-border-base-primary)', + background: 'var(--rs-color-background-base-primary)', + overflow: 'hidden' + }; + if (context.collapsed) { + return ( + + + {task.title.charAt(0)} + + + ); + } + return ( + + + {task.title} + + + + {task.status} + + + + {task.team} + + + + + ); +} + +/* ── Timeline point-marker demo (no endField) ──────────────────────────── */ + +type Release = { + id: string; + version: string; + channel: 'stable' | 'beta'; + date: string; +}; + +const releases: Release[] = [ + { id: 'r1', version: 'v1.8', channel: 'stable', date: taskDate(-14) }, + { id: 'r2', version: 'v1.9', channel: 'stable', date: taskDate(-9) }, + { id: 'r3', version: 'v2.0-b1', channel: 'beta', date: taskDate(-4) }, + { id: 'r4', version: 'v2.0-b2', channel: 'beta', date: taskDate(1) }, + { id: 'r5', version: 'v2.0', channel: 'stable', date: taskDate(6) }, + { id: 'r6', version: 'v2.1-b1', channel: 'beta', date: taskDate(11) } +]; + +const releaseFields: DataViewField[] = [ + { + accessorKey: 'version', + label: 'Version', + filterable: true, + filterType: 'string', + hideable: false + }, + { + accessorKey: 'channel', + label: 'Channel', + filterable: true, + filterType: 'select', + hideable: true, + filterOptions: [ + { label: 'Stable', value: 'stable' }, + { label: 'Beta', value: 'beta' } + ] + }, + { + accessorKey: 'date', + label: 'Date', + filterable: true, + filterType: 'date', + sortable: true, + hideable: true + } +]; + +export function DataViewTimelinePointDemo() { + return ( + + + data={releases} + fields={releaseFields} + defaultSort={{ name: 'date', order: 'asc' }} + getRowId={release => release.id} + > + + + + + + {/* No endField → point markers: the wrapper sizes to its content. */} + + startField='date' + estimatedRowHeight={28} + renderCard={row => ( + + {row.original.version} + + )} + /> + + No releases match your filters. + + + + + ); +} + +export function DataViewTimelineDemo() { + const timelineActions = useRef(null); + return ( + + + data={tasks} + fields={taskFields} + defaultSort={{ name: 'start', order: 'asc' }} + getRowId={task => task.id} + > + + + + + {/* Sorting/grouping don't affect time-positioned cards — hide them. */} + + + + {/* Empty state lives inside the flex-1 pane so it centers when the + timeline renders null. */} + + + startField='start' + endField='end' + actionsRef={timelineActions} + markers={[ + { date: taskDate(12), label: 'Release', variant: 'accent' } + ]} + renderCard={(row, context) => ( + + )} + /> + + No tasks match your filters. + + + + + ); +} diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index f8ebad332..0dd7caa88 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -52,6 +52,8 @@ import { DataViewPerViewFieldsDemo, DataViewSearchDemo, DataViewTableDemo, + DataViewTimelineDemo, + DataViewTimelinePointDemo, DataViewVirtualizedDemo, DataViewVirtualizedGroupingDemo } from '../dataview-demo'; @@ -87,6 +89,8 @@ export default function Demo(props: DemoProps) { DataViewLoadingDemo, DataViewPerViewFieldsDemo, DataViewSearchDemo, + DataViewTimelineDemo, + DataViewTimelinePointDemo, ChipInputDemo, DataTableSelectionDemo, LinearMenuDemo, diff --git a/apps/www/src/content/docs/components/dataview/demo.ts b/apps/www/src/content/docs/components/dataview/demo.ts index 5d4e4e16c..ba7bec499 100644 --- a/apps/www/src/content/docs/components/dataview/demo.ts +++ b/apps/www/src/content/docs/components/dataview/demo.ts @@ -331,3 +331,79 @@ export const searchPreview = { } ] }; + +export const timelinePreview = { + type: 'code', + style: { padding: 0 }, + previewCode: false, + code: ``, + codePreview: [ + { + label: 'index.tsx', + code: ` + /* The Timeline owns positioning: date → x, span → width, lane packing, + the sticky two-tier axis, and native x/y scrolling. The card interior + is entirely yours via renderCard. */ + const timelineActions = useRef(null); + + t.id}> + + + + + {/* Sorting/grouping don't affect time-positioned cards — hide them. */} + + + + + + context.collapsed + ? + : + } + /> + + No tasks match your filters. + + ` + } + ] +}; + +export const timelinePointPreview = { + type: 'code', + style: { padding: 0 }, + previewCode: false, + code: ``, + codePreview: [ + { + label: 'index.tsx', + code: ` + /* Omit endField → point markers. The wrapper sizes to its content + instead of a time span, and context.end is null. */ + r.id}> + + + + + ( + + {row.original.version} + + )} + /> + ` + } + ] +}; diff --git a/apps/www/src/content/docs/components/dataview/index.mdx b/apps/www/src/content/docs/components/dataview/index.mdx index 21d35d158..f5cde2a7d 100644 --- a/apps/www/src/content/docs/components/dataview/index.mdx +++ b/apps/www/src/content/docs/components/dataview/index.mdx @@ -16,6 +16,8 @@ import { virtualizedGroupingPreview, loadingPreview, perViewFieldsPreview, + timelinePreview, + timelinePointPreview, } from "./demo.ts"; @@ -24,7 +26,7 @@ import { `DataView` owns the data layer — query state (filters, sort, group, search), client/server mode, row model derivation — and lets you pick the renderer that draws it. The same `` can host a table, a list, or a free-form view, switchable at runtime without losing query state. -In scope today: `DataView.List` (table + list presentations) and `DataView.Custom` (escape hatch for cards, kanban, gallery, etc.). +In scope today: `DataView.List` (table + list presentations), `DataView.Timeline` (cards positioned on a continuous time axis — full guide in [Timeline](#timeline)), and `DataView.Custom` (escape hatch for cards, kanban, gallery, etc.). ## Anatomy @@ -153,6 +155,26 @@ Visibility is a **single global map** on context. `DataView.List` honours it for +### DataView.Timeline + + + +#### Card context + +Passed as the second argument to `renderCard`. + + + +#### Marker + + + +#### Actions + +The imperative handle received through `actionsRef`. + + + ### DataView.Custom @@ -281,3 +303,116 @@ The wire format keeps `group_by: string[]`. To group by something that isn't a r ``` In server mode, `onTableQueryChange` fires whenever the query changes. The `DataView.List` shows skeleton loader rows when `isLoading` is true. Filter predicates and sort run on the server — the local TanStack table is manual. + +## Timeline + +`DataView.Timeline` positions the same row model as cards on a continuous, horizontally scrollable time axis — orders on a delivery schedule, tasks on a Gantt-style plan, releases on a strip. It shares the root's entire data layer: search, filters, and Display Properties apply to cards exactly as they do to list rows, and it participates in multi-view switching via `name`. + +In the demo below: drag the background to pan (with a momentum glide), hover for the snapped date cursor, and use the Today button — wired through `actionsRef` — to jump back. The narrow "B" stub is a span shorter than `minCardWidth`, rendered via `context.collapsed`. + + + +### Cards + +The Timeline owns **positioning** — the time scale (date → x, span → width, using real timestamps so variable-length months don't distort placement), lane packing (non-overlapping cards share a lane; `lanePacking="one-per-row"` opts out), the sticky two-tier axis, and scrolling. You own the **card**: `renderCard(row, context)` draws everything visual, the same split as `DataView.List`'s `columns[].cell`. + +```tsx + { + // context: { width, collapsed, laneIndex, start, end } + if (context.collapsed) return ; + return ; + }} +/> +``` + +`context.collapsed` flips when the span is narrower than `minCardWidth` (default 60px) — render a compact stub instead of letting the full card clip. Wrap card fields in `DataView.DisplayAccess` so the toolbar's Display Properties toggles reach them. + +### Point markers + +Omit `endField` and rows render as point markers at their date — releases, incidents, audit events. The wrapper sizes to the card's content instead of a time span, and `context.end` is `null`. Because the packer can't measure content, it assumes each point card is `estimatedPointWidth` wide (default 120px) when assigning lanes — set it to roughly your widest point card so nearby markers don't overlap in a lane. + + + +### Scale and axis + +Four props control the axis, and they compose rather than overlap: + +- `scale` — the tick unit: `day` (default), `week`, `month`, or `quarter`. +- `unitWidth` — pixels per unit (defaults: day 20, week 56, month 96, quarter 140). This is the zoom knob. +- `tickInterval` — label every Nth unit. Labels never render closer than a collision floor, so a too-dense value degrades gracefully instead of overlapping. +- `gridlineInterval` — draw a gridline every Nth unit. Purely visual: cards, the today line, and cursor snapping still land on every unit. + +The domain defaults to the data extent (plus today and markers) with padding. Pass an explicit `range` to fix the coordinate space — essential for range-window loading below, since the scrollbar then never jumps as data streams in. Rows entirely outside an explicit `range` are culled. + +### Today and markers + +`today` (default `true`) draws a vertical line with a date badge pinned to the axis, at day precision so server and client renders agree. Pin it to a fixed date with `today={date}` or hide it with `today={false}`. `markers` adds more full-height lines for milestones and deadlines: + +```tsx + +``` + +### Navigation + +`defaultScrollTo` (default `'today'`) sets the initial scroll position: a date, `'today'`, `'start'`, or `'end'`. After mount, navigate imperatively through `actionsRef`: + +```tsx +const timelineActions = useRef(null); + + + + +``` + +`scrollTo(target, { align = "center", behavior = "smooth" })` accepts the same target vocabulary as `defaultScrollTo`. Out-of-domain dates clamp to the nearest domain edge; while the timeline is hidden (inactive view, no data) calls no-op with a dev warning. `getVisibleRange()` returns the visible `[Date, Date]` window (or `null` while hidden) — useful for showing the Today button only when today is off-screen. + +### Loading data by visible range + +Offset pagination doesn't fit a 2-D time canvas — the natural unit of fetching is the **visible window**. The recommended pattern is client mode + `onVisibleRangeChange`: + +1. Fix the coordinate space with an explicit `range` so the scrollbar stays stable while rows stream in. +2. On `onVisibleRangeChange`, widen the window by a prefetch pad and fetch rows **overlapping** it (`start <= to AND end >= from` — a containment filter drops cards straddling the window edges). +3. Track requested buckets (e.g. months) in a `Set` so re-entering a window is free, and dedupe merged rows by id — edge-straddling rows come back from both adjacent fetches. +4. Throttle, don't debounce: a debounce waits for scrolling to stop, leaving a gap of missing cards mid-drag. + +```tsx +const [orders, setOrders] = useState([]); +const [isLoading, setIsLoading] = useState(true); // initial fetch in flight + + o.id} +> + throttledLoadWindow(from, to)} + renderCard={renderOrderCard} + /> + +``` + +Start with `isLoading={true}` and fire an initial fetch on mount: with no data and no loading flag the timeline renders `null` (the zero state takes over), so `onVisibleRangeChange` would never fire to bootstrap the first window. Keep the row model **cumulative** — the domain is stable, so previously fetched cards stay mounted and scrolling back is instant. Scroll position is anchored by *time*, not pixels: if the domain shifts (a `range` extension, rows prepended by a fetch), the date under the viewport's left edge stays put instead of the content jumping. + +### Notes + +- **Grouping and sorting are bypassed** — cards are positioned by time, so group buckets and sort order have no geometric meaning. Hide both controls while a timeline view is active (``), or pass the timeline a per-view `fields` override without `sortable`/`groupable`. +- **`virtualized`** enables horizontal culling: only cards and gridlines near the viewport render. Recommended whenever the domain is long or rows are numerous. +- **Interaction** — cards receive row clicks via the root's `onRowClick`; the background supports mouse drag-to-pan with a momentum glide; scrolling past the domain edge won't trigger browser back-swipe. diff --git a/apps/www/src/content/docs/components/dataview/props.ts b/apps/www/src/content/docs/components/dataview/props.ts index ed92f4d1b..1a384ed14 100644 --- a/apps/www/src/content/docs/components/dataview/props.ts +++ b/apps/www/src/content/docs/components/dataview/props.ts @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, RefObject } from 'react'; // Documentation-only placeholders so `auto-type-table` can render the public // API without surfacing real generics (TData is shown as `T` for readability). @@ -159,10 +159,10 @@ export interface DataViewListColumn { accessorKey: string; /** TanStack-style cell renderer. */ - cell?: (ctx) => ReactNode; + cell?: (ctx: T) => ReactNode; /** TanStack-style header renderer. Overrides the field `label`. */ - header?: (ctx) => ReactNode; + header?: (ctx: T) => ReactNode; /** * CSS grid track width. @@ -172,6 +172,175 @@ export interface DataViewListColumn { width?: string | number; } +/** Date inputs accepted by Timeline props and row fields. */ +type TimelineDateInput = Date | number | string; + +export interface DataViewTimelineProps { + /** Multi-view name. When set, the renderer gates itself on the active view. */ + name?: string; + + /** Optional view-scoped field override (full replacement). */ + fields?: DataViewField[]; + + /** Accessor key on the row yielding the start date. Rows with a missing/invalid value are skipped. (Required) */ + startField: string; + + /** Accessor key for the end date. Omitted → point markers; present → variable-width span cards. */ + endField?: string; + + /** + * Renders the card interior. The Timeline owns positioning (x from start, + * width from span, lane from packing, scroll); the consumer owns the card + * visual entirely. Compose `DataView.DisplayAccess` inside for Display + * Properties support. (Required) + */ + renderCard: (row: T, context: TimelineCardContext) => ReactNode; + + /** + * Tick granularity of the time axis. + * @defaultValue "day" + */ + scale?: 'day' | 'week' | 'month' | 'quarter'; + + /** Pixel width of one `scale` unit — density/zoom override. Defaults per scale (day 20, week 56, month 96, quarter 140). */ + unitWidth?: number; + + /** + * Explicit time domain. Defaults to the data extent (plus today/markers) with padding. + * A domain narrower than the container is extended at the end so the axis and gridlines fill the visible width. + */ + range?: [TimelineDateInput, TimelineDateInput]; + + /** + * Vertical "today" line + axis badge. `true` uses the current date; a date pins it; `false` hides it. + * @defaultValue true + */ + today?: boolean | TimelineDateInput; + + /** Additional full-height marker lines with axis badges (milestones, deadlines). */ + markers?: TimelineMarker[]; + + /** + * Vertical gridlines at axis ticks. + * @defaultValue true + */ + showGridlines?: boolean; + + /** Label every Nth `scale` unit on the axis. Labels never render closer than the collision floor. Default: densest interval that fits. */ + tickInterval?: number; + + /** + * Draw a gridline every Nth `scale` unit. Purely visual — positioning stays at full unit granularity. + * @defaultValue 1 + */ + gridlineInterval?: number; + + /** + * Hover crosshair snapped to the unit under the pointer, with a date badge on the axis. + * @defaultValue true + */ + showCursorLine?: boolean; + + /** + * Initial horizontal scroll target. + * @defaultValue "today" + */ + defaultScrollTo?: TimelineDateInput | 'today' | 'start' | 'end'; + + /** Fires (rAF-throttled) with the visible time range as the user scrolls or resizes. */ + onVisibleRangeChange?: (range: [Date, Date]) => void; + + /** Receives the imperative navigation handle (`scrollTo`, `getVisibleRange`). */ + actionsRef?: RefObject; + + /** + * `auto` packs non-overlapping cards into shared lanes; `one-per-row` gives every row its own lane. + * @defaultValue "auto" + */ + lanePacking?: 'auto' | 'one-per-row'; + + /** + * Card height in px (cards render at exactly this height; named to match `DataView.List`). + * @defaultValue 66 + */ + estimatedRowHeight?: number; + + /** + * Vertical gap between lanes in px. + * @defaultValue 16 + */ + laneGap?: number; + + /** + * Spans narrower than this (px) flip `context.collapsed` for `renderCard`. + * @defaultValue 60 + */ + minCardWidth?: number; + + /** + * Assumed width (px) of point-marker cards (rows without `endField`) for lane packing. + * Set to roughly the widest point card to prevent horizontal overlap within a lane. + * @defaultValue 120 + */ + estimatedPointWidth?: number; + + /** When true, only cards/gridlines near the visible viewport are rendered (horizontal culling). */ + virtualized?: boolean; + + /** Class overrides per part: root, axis, band, tick, marker, gridline, cursor, canvas, card. */ + classNames?: Record; +} + +export interface TimelineCardContext { + /** Pixel width of the time span (0 when `endField` is omitted). */ + width: number; + + /** True when the span is narrower than `minCardWidth`. */ + collapsed: boolean; + + /** Lane (row) index assigned by packing. */ + laneIndex: number; + + /** Resolved start date. */ + start: Date; + + /** Resolved end date. Null when `endField` is omitted (point marker). */ + end: Date | null; +} + +export interface TimelineMarker { + /** Marker position. (Required) */ + date: TimelineDateInput; + + /** Badge content. Defaults to the marker date formatted as "17 Jan". */ + label?: ReactNode; + + /** + * Badge/line color. + * @defaultValue "default" + */ + variant?: 'default' | 'accent' | 'danger'; +} + +export interface TimelineActions { + /** + * Scroll the viewport so the target lands at `align`. Out-of-domain dates + * clamp to the nearest edge; no-ops (dev warning) while the renderer is hidden. + */ + scrollTo: ( + target: TimelineDateInput | 'today' | 'start' | 'end', + options?: { + /** @defaultValue "center" */ + align?: 'start' | 'center' | 'end'; + /** @defaultValue "smooth" */ + behavior?: 'auto' | 'smooth'; + } + ) => void; + + /** The visible time window, or null while the renderer is hidden. */ + getVisibleRange: () => [Date, Date] | null; +} + export interface DataViewCustomProps { /** Multi-view name. */ name?: string; diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx new file mode 100644 index 000000000..5ef0861f6 --- /dev/null +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -0,0 +1,976 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import dayjs from 'dayjs'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +// SVG icons are inlined via @svgr/rollup at build time. In Vitest they resolve +// to undefined, so stub the `~/icons` module with no-op components. +vi.mock('~/icons', () => ({ + FilterIcon: () => null, + __esModule: true +})); + +// biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name +import { DataView } from '../data-view'; +import type { + DataViewField, + DataViewTimelineProps, + TimelineActions +} from '../data-view.types'; +import { packLanes } from '../utils/pack-lanes'; +import { buildAxis, createTimeScale, toTimestamp } from '../utils/time-scale'; + +beforeAll(() => { + // jsdom doesn't implement ResizeObserver — the timeline observes its scroll + // container when viewport tracking is enabled. + // biome-ignore lint/suspicious/noExplicitAny: jsdom lacks ResizeObserver + (global as any).ResizeObserver = + // biome-ignore lint/suspicious/noExplicitAny: jsdom lacks ResizeObserver + (global as any).ResizeObserver || + vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn() + })); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/* ─────────────────────────── utils: packLanes ────────────────────────── */ + +describe('packLanes', () => { + it('returns no lanes for empty input', () => { + expect(packLanes([])).toEqual({ lanes: [], laneCount: 0 }); + }); + + it('packs non-overlapping items into the same lane', () => { + const { lanes, laneCount } = packLanes([ + { x: 0, width: 100 }, + { x: 120, width: 50 } + ]); + expect(lanes).toEqual([0, 0]); + expect(laneCount).toBe(1); + }); + + it('opens a new lane for overlapping items', () => { + const { lanes, laneCount } = packLanes([ + { x: 0, width: 100 }, + { x: 50, width: 100 } + ]); + expect(lanes).toEqual([0, 1]); + expect(laneCount).toBe(2); + }); + + it('respects the gap: items closer than gapPx do not share a lane', () => { + // First ends at 100; second starts at 104 < 100 + 8 → new lane. + const tight = packLanes([ + { x: 0, width: 100 }, + { x: 104, width: 50 } + ]); + expect(tight.lanes).toEqual([0, 1]); + // Exactly at the gap boundary → same lane. + const exact = packLanes([ + { x: 0, width: 100 }, + { x: 108, width: 50 } + ]); + expect(exact.lanes).toEqual([0, 0]); + }); + + it('assigns lanes by ascending x regardless of input order', () => { + const { lanes, laneCount } = packLanes([ + { x: 220, width: 60 }, // fits after the first item + { x: 0, width: 100 }, + { x: 50, width: 100 } // overlaps the first → lane 1 + ]); + expect(lanes).toEqual([0, 0, 1]); + expect(laneCount).toBe(2); + }); +}); + +/* ─────────────────────────── utils: time scale ───────────────────────── */ + +describe('toTimestamp', () => { + it('accepts Date, epoch ms, and parseable strings', () => { + const date = new Date('2025-01-05T00:00:00'); + expect(toTimestamp(date)).toBe(date.getTime()); + expect(toTimestamp(1736035200000)).toBe(1736035200000); + expect(toTimestamp('2025-01-05')).toBe(dayjs('2025-01-05').valueOf()); + }); + + it('returns null for missing or invalid values', () => { + expect(toTimestamp(null)).toBeNull(); + expect(toTimestamp(undefined)).toBeNull(); + expect(toTimestamp('not-a-date')).toBeNull(); + expect(toTimestamp(new Date('invalid'))).toBeNull(); + expect(toTimestamp(Number.NaN)).toBeNull(); + expect(toTimestamp({})).toBeNull(); + }); +}); + +describe('createTimeScale', () => { + it('snaps the domain to unit boundaries with padding', () => { + const ts = createTimeScale({ + minTime: dayjs('2025-01-05T10:30:00').valueOf(), + maxTime: dayjs('2025-01-20T18:00:00').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 2 + }); + expect(ts.t0).toBe(dayjs('2025-01-03').startOf('day').valueOf()); + // startOf(max) + (pad + 1) days. + expect(ts.t1).toBe(dayjs('2025-01-23').startOf('day').valueOf()); + }); + + it('extends the domain end to reach minWidth, in whole units', () => { + // Jan 1 → Feb 1 = 31 days × 20px = 620px; filling to 1000px needs 50 days. + const ts = createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 0, + minWidth: 1000 + }); + expect(ts.t0).toBe(dayjs('2025-01-01').valueOf()); + expect(ts.t1).toBe(dayjs('2025-02-20').valueOf()); + expect(ts.totalWidth).toBe(1000); + }); + + it('never shrinks a domain already wider than minWidth', () => { + const ts = createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 0, + minWidth: 100 + }); + expect(ts.t1).toBe(dayjs('2025-02-01').valueOf()); + expect(ts.totalWidth).toBe(620); + }); + + it('reaches minWidth on calendar scales with uneven unit lengths', () => { + // Months render at (actual ms × pxPerMs), not exactly unitWidth — the + // fill must land at or past minWidth despite short months. + const ts = createTimeScale({ + minTime: dayjs('2025-01-15').valueOf(), + maxTime: dayjs('2025-02-15').valueOf(), + scale: 'month', + unitWidth: 96, + padUnits: 0, + minWidth: 500 + }); + expect(ts.totalWidth).toBeGreaterThanOrEqual(500); + // Still snapped to a month boundary. + expect(dayjs(ts.t1).date()).toBe(1); + }); + + it('maps time to px linearly and inverts with timeAt', () => { + const ts = createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 0 + }); + expect(ts.x(ts.t0)).toBe(0); + expect(ts.x(dayjs('2025-01-05').valueOf())).toBe(80); + const time = dayjs('2025-01-11').valueOf(); + expect(ts.timeAt(ts.x(time))).toBe(time); + // Domain = Jan 1 → Feb 1 = 31 days. + expect(ts.totalWidth).toBe(31 * 20); + }); +}); + +describe('buildAxis', () => { + const januaryScale = createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 0 + }); + + it('emits one tick per unit across the domain', () => { + const { ticks } = buildAxis(januaryScale, 'day', 20); + // Jan 1 … Feb 1 inclusive. + expect(ticks).toHaveLength(32); + expect(ticks[0].label).toBe('1'); + expect(ticks[0].x).toBe(0); + expect(ticks[4].x).toBe(80); + }); + + it('thins tick labels below the minimum label spacing', () => { + // 20px per tick < 28px minimum → every 2nd label. + const thinned = buildAxis(januaryScale, 'day', 20).ticks; + expect(thinned[0].showLabel).toBe(true); + expect(thinned[1].showLabel).toBe(false); + expect(thinned[2].showLabel).toBe(true); + // 40px per tick → all labels show. + const roomy = buildAxis( + createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 40, + padUnits: 0 + }), + 'day', + 40 + ).ticks; + expect(roomy.every(t => t.showLabel)).toBe(true); + }); + + it('labels every Nth unit when labelEvery is passed', () => { + const roomyScale = createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 40, + padUnits: 0 + }); + // 40px fits every label; the override thins to every 2nd anyway. + const { ticks } = buildAxis(roomyScale, 'day', 40, 2); + expect(ticks.map(t => t.showLabel).slice(0, 4)).toEqual([ + true, + false, + true, + false + ]); + expect(ticks[0].index).toBe(0); + expect(ticks[3].index).toBe(3); + }); + + it('collision floor wins over a too-dense labelEvery', () => { + // 10px per tick → auto floor is every 3rd; asking for every 2nd degrades. + const dense = createTimeScale({ + minTime: dayjs('2025-01-01').valueOf(), + maxTime: dayjs('2025-01-31').valueOf(), + scale: 'day', + unitWidth: 10, + padUnits: 0 + }); + const { ticks } = buildAxis(dense, 'day', 10, 2); + expect(ticks[0].showLabel).toBe(true); + expect(ticks[1].showLabel).toBe(false); + expect(ticks[2].showLabel).toBe(false); + expect(ticks[3].showLabel).toBe(true); + }); + + it('emits month bands over day ticks, with the year on the first band', () => { + const wide = createTimeScale({ + minTime: dayjs('2025-01-10').valueOf(), + maxTime: dayjs('2025-02-20').valueOf(), + scale: 'day', + unitWidth: 20, + padUnits: 0 + }); + const { bands } = buildAxis(wide, 'day', 20); + expect(bands.map(b => b.label)).toEqual(['Jan 2025', 'Feb']); + }); + + it('emits year bands over month ticks', () => { + const yearly = createTimeScale({ + minTime: dayjs('2024-11-01').valueOf(), + maxTime: dayjs('2025-03-01').valueOf(), + scale: 'month', + unitWidth: 96, + padUnits: 0 + }); + const { bands, ticks } = buildAxis(yearly, 'month', 96); + expect(bands.map(b => b.label)).toEqual(['2024', '2025']); + expect(ticks[0].label).toBe('Nov'); + }); +}); + +/* ─────────────────────────── renderer ────────────────────────── */ + +type Order = { + id: string; + title: string; + start: string | null; + end: string | null; +}; + +const fields: DataViewField[] = [ + { accessorKey: 'title', label: 'Title', sortable: true } +]; + +// x at unitWidth 20 over a Jan 2025 domain: Jan 5 → 80px, Jan 12 → 220px, … +const orders: Order[] = [ + { id: 'o1', title: 'Alpha', start: '2025-01-05', end: '2025-01-10' }, + { id: 'o2', title: 'Beta', start: '2025-01-12', end: '2025-01-15' }, + { id: 'o3', title: 'Gamma', start: '2025-01-06', end: '2025-01-09' } +]; + +function renderTimeline( + props: Partial> = {}, + data: Order[] = orders +) { + return render( + + data={data} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + getRowId={(row: Order) => row.id} + > + + startField='start' + endField='end' + range={['2025-01-01', '2025-01-31']} + scale='day' + unitWidth={20} + today={false} + defaultScrollTo='start' + renderCard={(row, context) => ( +
+ {row.original.title} +
+ )} + {...props} + /> +
+ ); +} + +describe('DataView.Timeline', () => { + it('positions cards from the time scale (left = start, width = span)', () => { + renderTimeline(); + const wrapper = screen.getByTestId('card-o1').parentElement!; + // Jan 5 = 4 days after Jan 1 → 4 × 20px; Jan 5 → Jan 10 = 5 days → 100px. + expect(wrapper.style.left).toBe('80px'); + expect(wrapper.style.width).toBe('100px'); + expect(wrapper).toHaveAttribute('role', 'listitem'); + }); + + it('packs overlapping cards into separate lanes and reuses free lanes', () => { + renderTimeline(); + // o1 [80..180] and o3 [100..180] overlap → different lanes. + expect(screen.getByTestId('card-o1').dataset.lane).toBe('0'); + expect(screen.getByTestId('card-o3').dataset.lane).toBe('1'); + // o2 starts at 220 ≥ o1's end + gap → back onto lane 0. + expect(screen.getByTestId('card-o2').dataset.lane).toBe('0'); + }); + + it('gives every row its own lane with lanePacking="one-per-row"', () => { + renderTimeline({ lanePacking: 'one-per-row' }); + const lanes = ['o1', 'o2', 'o3'].map( + id => screen.getByTestId(`card-${id}`).dataset.lane + ); + expect(new Set(lanes).size).toBe(3); + }); + + it('flags spans narrower than minCardWidth as collapsed', () => { + renderTimeline(undefined, [ + { id: 'wide', title: 'Wide', start: '2025-01-05', end: '2025-01-10' }, + { id: 'tiny', title: 'Tiny', start: '2025-01-12', end: '2025-01-14' } + ]); + // 5 days × 20px = 100px ≥ 60 → not collapsed. + expect(screen.getByTestId('card-wide').dataset.collapsed).toBe('false'); + // 2 days × 20px = 40px < 60 → collapsed. + expect(screen.getByTestId('card-tiny').dataset.collapsed).toBe('true'); + }); + + it('renders point markers with intrinsic width when endField is omitted', () => { + renderTimeline({ endField: undefined }, [ + { id: 'p1', title: 'Point', start: '2025-01-05', end: null } + ]); + const card = screen.getByTestId('card-p1'); + expect(card.dataset.point).toBe('true'); + expect(card.parentElement!.style.width).toBe(''); + expect(card.parentElement!.style.left).toBe('80px'); + }); + + it('culls rows entirely outside an explicit range, keeps overlapping ones', () => { + renderTimeline(undefined, [ + { id: 'in', title: 'Inside', start: '2025-01-05', end: '2025-01-10' }, + // Crosses the range end (Jan 31) → kept, clipped visually by the canvas. + { id: 'edge', title: 'Edge', start: '2025-01-30', end: '2025-02-05' }, + // Entirely past the range end → dropped (no lane, no card). + { id: 'after', title: 'After', start: '2025-02-10', end: '2025-02-15' }, + // Entirely before the range start → dropped. + { id: 'before', title: 'Before', start: '2024-12-01', end: '2024-12-20' } + ]); + expect(screen.getByTestId('card-in')).toBeInTheDocument(); + expect(screen.getByTestId('card-edge')).toBeInTheDocument(); + expect(screen.queryByTestId('card-after')).not.toBeInTheDocument(); + expect(screen.queryByTestId('card-before')).not.toBeInTheDocument(); + }); + + it('skips rows with a missing start value and warns in dev', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + renderTimeline(undefined, [ + { id: 'ok', title: 'Ok', start: '2025-01-05', end: '2025-01-10' }, + { id: 'bad', title: 'Bad', start: null, end: '2025-01-10' } + ]); + expect(screen.getByTestId('card-ok')).toBeInTheDocument(); + expect(screen.queryByTestId('card-bad')).toBeNull(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Skipped 1 row(s)') + ); + }); + + it('renders the today line badge and custom markers on the axis', () => { + renderTimeline({ + today: '2025-01-17', + markers: [ + { date: '2025-01-25' }, + { date: '2025-01-28', label: 'Launch', variant: 'danger' } + ] + }); + expect(screen.getByText('17 Jan')).toBeInTheDocument(); + expect(screen.getByText('25 Jan')).toBeInTheDocument(); + expect(screen.getByText('Launch')).toBeInTheDocument(); + }); + + it('renders axis bands and tick labels', () => { + renderTimeline(); + expect(screen.getByText('Jan 2025')).toBeInTheDocument(); + // unitWidth 20 thins labels to every other day (odd days from Jan 1). + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('fires onRowClick with the row data when a card is clicked', async () => { + const onRowClick = vi.fn(); + const user = userEvent.setup(); + render( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + getRowId={(row: Order) => row.id} + onRowClick={onRowClick} + > + + startField='start' + endField='end' + range={['2025-01-01', '2025-01-31']} + today={false} + defaultScrollTo='start' + renderCard={row => ( +
+ {row.original.title} +
+ )} + /> + + ); + await user.click(screen.getByTestId('card-o1')); + expect(onRowClick).toHaveBeenCalledWith(orders[0]); + }); + + it('gates itself on the active view when `name` is set', () => { + const views = [ + { value: 'gantt', label: 'Timeline' }, + { value: 'other', label: 'Other' } + ]; + const { unmount } = render( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + views={views} + defaultView='other' + > + + name='gantt' + startField='start' + endField='end' + today={false} + renderCard={row =>
} + /> + + ); + expect(screen.queryByTestId('card-o1')).toBeNull(); + unmount(); + + render( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + views={views} + defaultView='gantt' + > + + name='gantt' + startField='start' + endField='end' + today={false} + renderCard={row =>
} + /> + + ); + expect(screen.getByTestId('card-o1')).toBeInTheDocument(); + }); + + it('thins gridlines with gridlineInterval while the cursor snaps to every unit', async () => { + const { container } = renderTimeline({ + gridlineInterval: 2, + classNames: { gridline: 'test-gridline' } + }); + // 32 ticks (Jan 1 … Feb 1); every 2nd from the domain start → 16 lines. + expect(container.getElementsByClassName('test-gridline')).toHaveLength(16); + + // Jan 6 (index 5) has no gridline, but the hover cursor still snaps to it. + const root = container.firstElementChild as HTMLElement; + await act(async () => { + fireEvent.mouseMove(root, { clientX: 100 }); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + expect(screen.getByText('6 Jan')).toBeInTheDocument(); + }); + + it('extends the domain so the grid fills a container wider than the data span', () => { + // jsdom's clientWidth is 0 by default — pretend the scroll container is + // 1000px wide. The explicit range renders 620px, so the domain end + // extends Feb 1 → Feb 20 and ticks run Jan 1 … Feb 20 = 51 gridlines. + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(1000); + const { container } = renderTimeline({ + classNames: { gridline: 'test-gridline' } + }); + expect(container.getElementsByClassName('test-gridline')).toHaveLength(51); + const canvas = container.querySelector('[role="list"]') as HTMLElement; + expect(canvas.style.width).toBe('1000px'); + }); + + it('renders nothing when there is no data and not loading', () => { + const { container } = renderTimeline(undefined, []); + expect(container.querySelector('[role="list"]')).toBeNull(); + }); + + it('shows a cursor line snapped to the hovered sub-interval with a date badge', async () => { + const { container } = renderTimeline(); + const root = container.firstElementChild as HTMLElement; + // jsdom rects are all-zero and scrollLeft is 0, so canvasX = clientX. + // x=100 at 20px/day from Jan 1 → snapped to Jan 6. + await act(async () => { + fireEvent.mouseMove(root, { clientX: 100 }); + await new Promise(resolve => setTimeout(resolve, 30)); // flush rAF + }); + expect(screen.getByText('6 Jan')).toBeInTheDocument(); + + await act(async () => { + fireEvent.mouseLeave(root); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + expect(screen.queryByText('6 Jan')).toBeNull(); + }); + + it('does not track the cursor when showCursorLine is false', async () => { + const { container } = renderTimeline({ showCursorLine: false }); + const root = container.firstElementChild as HTMLElement; + await act(async () => { + fireEvent.mouseMove(root, { clientX: 100 }); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + expect(screen.queryByText('6 Jan')).toBeNull(); + }); + + it('pans both axes when the background is dragged', async () => { + const { container } = renderTimeline(); + const root = container.firstElementChild as HTMLElement; + fireEvent.pointerDown(root, { + pointerType: 'mouse', + button: 0, + pointerId: 1, + clientX: 300, + clientY: 100 + }); + // Pointer moves 50px left / 20px up → content scrolls 50px right / 20px down. + fireEvent.pointerMove(root, { pointerId: 1, clientX: 250, clientY: 80 }); + expect(root.scrollLeft).toBe(50); + expect(root.scrollTop).toBe(20); + expect(root.dataset.dragging).toBe('true'); + + // Hold still past the stale window, then release — no glide. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + }); + fireEvent.pointerUp(root, { pointerId: 1 }); + expect(root.dataset.dragging).toBeUndefined(); + // Released — further movement no longer pans. + fireEvent.pointerMove(root, { pointerId: 1, clientX: 100, clientY: 0 }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + expect(root.scrollLeft).toBe(50); + }); + + it('glides with momentum after a fling release', async () => { + const { container } = renderTimeline(); + const root = container.firstElementChild as HTMLElement; + fireEvent.pointerDown(root, { + pointerType: 'mouse', + button: 0, + pointerId: 1, + clientX: 300, + clientY: 100 + }); + fireEvent.pointerMove(root, { pointerId: 1, clientX: 250, clientY: 100 }); + // Release mid-motion — the pan keeps scrolling and decays. + fireEvent.pointerUp(root, { pointerId: 1 }); + expect(root.scrollLeft).toBe(50); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + }); + const glided = root.scrollLeft; + expect(glided).toBeGreaterThan(50); + + // Wheel input cancels the glide. + fireEvent.wheel(root); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + }); + expect(root.scrollLeft).toBe(glided); + }); + + it('does not start a pan from a card or from touch pointers', () => { + const { container } = renderTimeline(); + const root = container.firstElementChild as HTMLElement; + // Press on a card (row-click territory) — no pan. + fireEvent.pointerDown(screen.getByTestId('card-o1'), { + pointerType: 'mouse', + button: 0, + pointerId: 1, + clientX: 300, + clientY: 100 + }); + fireEvent.pointerMove(root, { pointerId: 1, clientX: 200, clientY: 100 }); + expect(root.scrollLeft).toBe(0); + + // Touch pans natively — the drag handler must not hijack it. + fireEvent.pointerDown(root, { + pointerType: 'touch', + button: 0, + pointerId: 2, + clientX: 300, + clientY: 100 + }); + fireEvent.pointerMove(root, { pointerId: 2, clientX: 200, clientY: 100 }); + expect(root.scrollLeft).toBe(0); + }); + + it('keeps the left-edge time anchored when the domain is extended', async () => { + const { container, rerender } = render( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + getRowId={(row: Order) => row.id} + > + + startField='start' + endField='end' + range={['2025-01-01', '2025-01-31']} + scale='day' + unitWidth={20} + today={false} + defaultScrollTo='start' + renderCard={row =>
{row.original.title}
} + /> + + ); + const root = container.firstElementChild as HTMLElement; + // Viewport's left edge sits at Jan 11 (200px / 20px-per-day from Jan 1). + root.scrollLeft = 200; + + // Extend the range a month to the left — t0 moves to Dec 1. + rerender( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + getRowId={(row: Order) => row.id} + > + + startField='start' + endField='end' + range={['2024-12-01', '2025-01-31']} + scale='day' + unitWidth={20} + today={false} + defaultScrollTo='start' + renderCard={row =>
{row.original.title}
} + /> + + ); + // Dec 1 → Jan 11 = 41 days × 20px: the same time stays at the left edge. + expect(root.scrollLeft).toBe(820); + }); + + it('fires onVisibleRangeChange with the visible time window', async () => { + const onVisibleRangeChange = vi.fn(); + renderTimeline({ onVisibleRangeChange }); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(onVisibleRangeChange).toHaveBeenCalled(); + const calls = onVisibleRangeChange.mock.calls; + const [from, to] = calls[calls.length - 1][0]; + expect(from).toBeInstanceOf(Date); + expect(to).toBeInstanceOf(Date); + // jsdom viewport is 0-wide at scrollLeft 0 → both edges sit at t0 (Jan 1). + expect(from.getTime()).toBe(dayjs('2025-01-01').valueOf()); + }); + + it('does not re-fire onVisibleRangeChange when the window is unchanged', async () => { + const onVisibleRangeChange = vi.fn(); + // Fresh `markers` array each render → domain/timeScale identity churn + // without any change to the actual window. + const makeUI = () => ( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + getRowId={(row: Order) => row.id} + > + + startField='start' + endField='end' + range={['2025-01-01', '2025-01-31']} + scale='day' + unitWidth={20} + today={false} + defaultScrollTo='start' + markers={[{ date: '2025-01-20' }]} + onVisibleRangeChange={onVisibleRangeChange} + renderCard={row =>
{row.original.title}
} + /> + + ); + const { container, rerender } = render(makeUI()); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + const callsAfterMount = onVisibleRangeChange.mock.calls.length; + expect(callsAfterMount).toBeGreaterThan(0); + + rerender(makeUI()); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(onVisibleRangeChange.mock.calls.length).toBe(callsAfterMount); + + // An actual scroll still fires with the new window. + const root = container.firstElementChild as HTMLElement; + root.scrollLeft = 100; + await act(async () => { + fireEvent.scroll(root); + await new Promise(resolve => setTimeout(resolve, 30)); // flush rAF + }); + expect(onVisibleRangeChange.mock.calls.length).toBe(callsAfterMount + 1); + const calls = onVisibleRangeChange.mock.calls; + const [from] = calls[calls.length - 1][0]; + expect(from.getTime()).toBe(dayjs('2025-01-06').valueOf()); + }); + + it('restores the scroll position when a gated view is re-activated', () => { + const views = [ + { value: 'gantt', label: 'Timeline' }, + { value: 'other', label: 'Other' } + ]; + const makeUI = (view: string) => ( + + data={orders} + fields={fields} + mode='client' + defaultSort={{ name: 'title', order: 'asc' }} + getRowId={(row: Order) => row.id} + views={views} + view={view} + > + + name='gantt' + startField='start' + endField='end' + range={['2025-01-01', '2025-01-31']} + scale='day' + unitWidth={20} + today={false} + defaultScrollTo='start' + renderCard={row =>
} + /> + + ); + const { container, rerender } = render(makeUI('gantt')); + const root = container.firstElementChild as HTMLElement; + // User scrolls to Jan 11 (200px), then switches away and back. + root.scrollLeft = 200; + fireEvent.scroll(root); + rerender(makeUI('other')); + expect(screen.queryByTestId('card-o1')).toBeNull(); + + rerender(makeUI('gantt')); + // The DOM is recreated at scroll 0 — the stashed position must come back + // instead of stranding the user at the domain start. + const newRoot = container.firstElementChild as HTMLElement; + expect(screen.getByTestId('card-o1')).toBeInTheDocument(); + expect(newRoot.scrollLeft).toBe(200); + }); + + it('packs point markers using estimatedPointWidth', () => { + const points: Order[] = [ + { id: 'p1', title: 'P1', start: '2025-01-05', end: null }, // x = 80 + { id: 'p2', title: 'P2', start: '2025-01-08', end: null } // x = 140 + ]; + // Default estimate (120px): p1 occupies [80, 200] → p2 at 140 overlaps + // → separate lanes, even though the old 24px floor would have packed them. + renderTimeline({ endField: undefined }, points); + expect(screen.getByTestId('card-p1').dataset.lane).not.toBe( + screen.getByTestId('card-p2').dataset.lane + ); + }); + + it('shares a lane when estimatedPointWidth says the points fit', () => { + const points: Order[] = [ + { id: 'p1', title: 'P1', start: '2025-01-05', end: null }, + { id: 'p2', title: 'P2', start: '2025-01-08', end: null } + ]; + // 40px estimate + 8px lane gap ≤ 60px separation → same lane. + renderTimeline({ endField: undefined, estimatedPointWidth: 40 }, points); + expect(screen.getByTestId('card-p1').dataset.lane).toBe( + screen.getByTestId('card-p2').dataset.lane + ); + }); + + it('culls cards outside the overscanned viewport when virtualized', () => { + // jsdom viewport: left 0, width 0 → overscan 400 → cull window [-400, 400]. + renderTimeline({ virtualized: true }, [ + { id: 'near', title: 'Near', start: '2025-01-05', end: '2025-01-10' }, // x = 80 + { id: 'far', title: 'Far', start: '2025-01-27', end: '2025-01-30' } // x = 520 + ]); + expect(screen.getByTestId('card-near')).toBeInTheDocument(); + expect(screen.queryByTestId('card-far')).toBeNull(); + }); + + it('does not re-render cards on hover-cursor updates (memoized wrapper)', async () => { + const renderSpy = vi.fn(); + const { container } = renderTimeline({ + renderCard: row => { + renderSpy(row.original.id); + return ( +
+ {row.original.title} +
+ ); + } + }); + const callsAfterMount = renderSpy.mock.calls.length; + const root = container.firstElementChild as HTMLElement; + await act(async () => { + fireEvent.mouseMove(root, { clientX: 100 }); + await new Promise(resolve => setTimeout(resolve, 30)); + }); + // The cursor badge proves a root re-render happened… + expect(screen.getByText('6 Jan')).toBeInTheDocument(); + // …but no card interior re-rendered. + expect(renderSpy.mock.calls.length).toBe(callsAfterMount); + }); +}); + +/* ─────────────────────── actionsRef (imperative handle) ───────────────── + Domain: explicit range Jan 1 → Jan 31 2025, day scale, 20px/unit. + t0 = Jan 1, t1 = Feb 1, totalWidth = 620px. jsdom clientWidth is 0, so + 'center'/'end' alignment offsets collapse to the target's own x. */ + +describe('DataView.Timeline actionsRef', () => { + const makeActionsRef = () => ({ current: null as TimelineActions | null }); + + it('scrolls to a date, aligned to the viewport edge requested', () => { + const actionsRef = makeActionsRef(); + const { container } = renderTimeline({ actionsRef }); + const root = container.firstElementChild as HTMLElement; + expect(actionsRef.current).not.toBeNull(); + // Jan 11 = 10 days after Jan 1 → 10 × 20px. + actionsRef.current!.scrollTo('2025-01-11', { + align: 'start', + behavior: 'auto' + }); + expect(root.scrollLeft).toBe(200); + }); + + it("resolves 'start' and 'end' to the domain edges", () => { + const actionsRef = makeActionsRef(); + const { container } = renderTimeline({ actionsRef }); + const root = container.firstElementChild as HTMLElement; + actionsRef.current!.scrollTo('end', { behavior: 'auto' }); + expect(root.scrollLeft).toBe(620); + actionsRef.current!.scrollTo('start', { behavior: 'auto' }); + expect(root.scrollLeft).toBe(0); + }); + + it('clamps out-of-domain dates to the nearest domain edge', () => { + const actionsRef = makeActionsRef(); + const { container } = renderTimeline({ actionsRef }); + const root = container.firstElementChild as HTMLElement; + actionsRef.current!.scrollTo('2030-06-01', { + align: 'start', + behavior: 'auto' + }); + expect(root.scrollLeft).toBe(620); + actionsRef.current!.scrollTo('1999-01-01', { + align: 'start', + behavior: 'auto' + }); + expect(root.scrollLeft).toBe(0); + }); + + it('defaults to smooth behavior via Element.scrollTo when available', () => { + const actionsRef = makeActionsRef(); + const { container } = renderTimeline({ actionsRef }); + const root = container.firstElementChild as HTMLElement; + const scrollToSpy = vi.fn(); + // biome-ignore lint/suspicious/noExplicitAny: jsdom lacks Element.scrollTo + (root as any).scrollTo = scrollToSpy; + actionsRef.current!.scrollTo('2025-01-11', { align: 'start' }); + expect(scrollToSpy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' }); + }); + + it('warns and no-ops on an invalid date', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const actionsRef = makeActionsRef(); + const { container } = renderTimeline({ actionsRef }); + const root = container.firstElementChild as HTMLElement; + actionsRef.current!.scrollTo('not-a-date', { behavior: 'auto' }); + expect(root.scrollLeft).toBe(0); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('invalid date')); + }); + + it('reports the visible time window from the live scroll position', () => { + const actionsRef = makeActionsRef(); + const { container } = renderTimeline({ actionsRef }); + const root = container.firstElementChild as HTMLElement; + root.scrollLeft = 200; + const range = actionsRef.current!.getVisibleRange(); + expect(range).not.toBeNull(); + const [from, to] = range!; + expect(from.getTime()).toBe(dayjs('2025-01-11').valueOf()); + // 0-wide jsdom viewport → both edges coincide. + expect(to.getTime()).toBe(dayjs('2025-01-11').valueOf()); + }); + + it('no-ops with a dev warning while hidden, and getVisibleRange is null', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const actionsRef = makeActionsRef(); + renderTimeline({ actionsRef }, []); // no data + not loading → renders null + expect(actionsRef.current).not.toBeNull(); + actionsRef.current!.scrollTo('today'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('not rendered')); + expect(actionsRef.current!.getVisibleRange()).toBeNull(); + }); +}); diff --git a/packages/raystack/components/data-view/components/timeline.tsx b/packages/raystack/components/data-view/components/timeline.tsx new file mode 100644 index 000000000..66f7101d6 --- /dev/null +++ b/packages/raystack/components/data-view/components/timeline.tsx @@ -0,0 +1,1061 @@ +'use client'; + +import type { Row } from '@tanstack/react-table'; +import { cx } from 'class-variance-authority'; +import dayjs from 'dayjs'; +import { + CSSProperties, + memo, + ReactNode, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react'; + +import { Badge } from '../../badge'; +import styles from '../data-view.module.css'; +import { + DataViewTimelineProps, + TimelineActions, + TimelineCardContext, + TimelineScale +} from '../data-view.types'; +import { useDataView } from '../hooks/useDataView'; +import { packLanes } from '../utils/pack-lanes'; +import { + buildAxis, + createTimeScale, + startOfUnit, + TIMELINE_DEFAULT_UNIT_WIDTH, + TIMELINE_UNIT_MS, + toTimestamp +} from '../utils/time-scale'; +import { FilterSummary } from './clear-filters'; + +const DEFAULT_ROW_HEIGHT = 66; +const DEFAULT_LANE_GAP = 16; +const DEFAULT_MIN_CARD_WIDTH = 60; +/** Assumed width of content-sized point cards for lane packing and culling. */ +const DEFAULT_POINT_WIDTH = 120; +/** Floor for the rendered wrapper so near-zero spans stay visible and clickable. */ +const MIN_RENDER_WIDTH = 24; +/** Units of padding around the data extent when no explicit `range` is given. */ +const DOMAIN_PAD_UNITS = 2; +/** Half-window (in units) of the fallback domain used while loading with no data. */ +const FALLBACK_DOMAIN_UNITS = 15; +/** Below this speed (px/ms) a release doesn't glide, and a glide stops. */ +const MOMENTUM_MIN_SPEED = 0.05; +/** Speed cap (px/ms) so event-timing spikes can't launch a huge fling. */ +const MOMENTUM_MAX_SPEED = 4; +/** Exponential decay constant (ms) of the post-release glide. */ +const MOMENTUM_DECAY_TAU = 325; +/** Releasing this long (ms) after the last move means "held still" — no glide. */ +const MOMENTUM_STALE_MS = 80; + +const clampSpeed = (v: number) => + Math.max(-MOMENTUM_MAX_SPEED, Math.min(v, MOMENTUM_MAX_SPEED)); + +/** First index in `list` (ascending by `x`) with `x >= value`. */ +function lowerBoundByX(list: readonly { x: number }[], value: number): number { + let lo = 0; + let hi = list.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (list[mid].x < value) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** First index in `list` (ascending by `x`) with `x > value`. */ +function upperBoundByX(list: readonly { x: number }[], value: number): number { + let lo = 0; + let hi = list.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (list[mid].x <= value) lo = mid + 1; + else hi = mid; + } + return lo; +} + +interface TimelineCardViewProps { + row: Row; + x: number; + top: number; + height: number; + /** Null for point cards — the wrapper sizes to its content. */ + renderWidth: number | null; + spanWidth: number; + collapsed: boolean; + laneIndex: number; + startTime: number; + /** Null when `endField` is omitted (point marker). */ + endTime: number | null; + renderCard: DataViewTimelineProps['renderCard']; + onRowClick?: (row: TData) => void; + className?: string; +} + +/** + * Memoized positioning wrapper for one card. Isolates `renderCard` from the + * root's per-frame re-renders (hover cursor, viewport tracking) — a card only + * re-renders when its own row or geometry changes. All props except `row`, + * `renderCard`, and `onRowClick` are primitives, so the default shallow + * compare holds as long as those identities are stable across renders. + */ +function TimelineCardViewInner({ + row, + x, + top, + height, + renderWidth, + spanWidth, + collapsed, + laneIndex, + startTime, + endTime, + renderCard, + onRowClick, + className +}: TimelineCardViewProps) { + const context: TimelineCardContext = { + width: spanWidth, + collapsed, + laneIndex, + start: new Date(startTime), + end: endTime !== null ? new Date(endTime) : null + }; + const style: CSSProperties = { + left: x, + top, + height, + ...(renderWidth !== null ? { width: renderWidth } : null) + }; + return ( +
onRowClick(row.original) : undefined} + > + {renderCard(row, context)} +
+ ); +} + +const TimelineCardView = memo( + TimelineCardViewInner +) as unknown as typeof TimelineCardViewInner & { displayName?: string }; +TimelineCardView.displayName = 'DataView.TimelineCard'; + +interface ResolvedMarker { + key: string; + time: number; + x: number; + label: ReactNode; + variant: 'default' | 'accent' | 'danger'; +} + +const MARKER_BADGE_VARIANT: Record< + ResolvedMarker['variant'], + 'accent' | 'danger' | 'neutral' +> = { + accent: 'accent', + danger: 'danger', + default: 'neutral' +}; + +/** Axis-badge label for the hover cursor, formatted per scale granularity. */ +function cursorLabel(time: number, scale: TimelineScale): string { + const date = dayjs(time); + switch (scale) { + case 'day': + case 'week': + return date.format('D MMM'); + case 'month': + return date.format('MMM YYYY'); + case 'quarter': + return `Q${Math.floor(date.month() / 3) + 1} ${date.format('YYYY')}`; + } +} + +/** + * Time-positioned card renderer. The Timeline owns the time scale (date → x, + * span → width), lane packing, the sticky two-tier axis (ticks, month/year + * bands, today + custom markers, gridlines), and native x/y scrolling. The + * card interior is entirely consumer-owned via `renderCard` — analogous to how + * `columns[].cell` owns cell interiors in `DataView.List`. + */ +export function DataViewTimeline({ + name, + fields: fieldsOverride, + startField, + endField, + renderCard, + scale = 'day', + unitWidth, + range, + today = true, + markers, + showGridlines = true, + tickInterval, + gridlineInterval = 1, + showCursorLine = true, + defaultScrollTo = 'today', + onVisibleRangeChange, + actionsRef, + lanePacking = 'auto', + estimatedRowHeight = DEFAULT_ROW_HEIGHT, + laneGap = DEFAULT_LANE_GAP, + minCardWidth = DEFAULT_MIN_CARD_WIDTH, + estimatedPointWidth = DEFAULT_POINT_WIDTH, + virtualized = false, + classNames = {} +}: DataViewTimelineProps) { + const { table, onRowClick, activeView, registerFieldsForView, hasData } = + useDataView(); + + // Register per-view field override so the toolbar's effectiveFields reflects + // this renderer's metadata while it's the active view. + useEffect(() => { + if (!name || !fieldsOverride) return; + return registerFieldsForView(name, fieldsOverride); + }, [name, fieldsOverride, registerFieldsForView]); + + // Multi-view gate. When `name` is set, render only when this is the active + // view. When unset (single-renderer mode), always render. + const isActive = !name || activeView === undefined || activeView === name; + + const effectiveUnitWidth = unitWidth ?? TIMELINE_DEFAULT_UNIT_WIDTH[scale]; + + const scrollRef = useRef(null); + + // Viewport fill — when the domain renders narrower than the scroll + // container, the domain end extends so the axis/gridlines span the full + // visible width (see `minWidth` in createTimeScale). Extension only ever + // widens, so an explicit `range` wider than the container is untouched. + const [containerWidth, setContainerWidth] = useState(0); + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + const measure = () => setContainerWidth(el.clientWidth); + measure(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + // Re-attach when the renderer mounts its DOM (ref is null while the view + // is gated or there's no data yet). + }, [isActive, hasData]); + + // Quantized to whole units so sub-unit resizes (e.g. dragging a panel + // divider 1px at a time) don't rebuild the scale and re-run layout. + const fillWidth = + Math.ceil(containerWidth / effectiveUnitWidth) * effectiveUnitWidth; + + const rowModel = table?.getRowModel(); + const { rows = [] } = rowModel || {}; + + // Timeline bypasses group headers (RFC §Grouping) — position leaf rows only. + const leafRows = useMemo( + () => rows.filter(row => !(row.subRows && row.subRows.length > 0)), + [rows] + ); + + // Resolve each row's start/end timestamps. Rows without a valid start are + // skipped (dev warning); inverted ranges clamp to zero-length spans. + const timedItems = useMemo(() => { + const items: { + row: Row; + startTime: number; + endTime: number | null; + }[] = []; + let dropped = 0; + for (const row of leafRows) { + const original = row.original as Record; + const startTime = toTimestamp(original?.[startField]); + if (startTime === null) { + dropped++; + continue; + } + let endTime: number | null = null; + if (endField) { + endTime = toTimestamp(original?.[endField]); + if (endTime !== null && endTime < startTime) endTime = startTime; + } + items.push({ row, startTime, endTime }); + } + if (process.env.NODE_ENV !== 'production' && dropped > 0) { + console.warn( + `[DataView.Timeline] Skipped ${dropped} row(s) with a missing or invalid "${startField}" value.` + ); + } + return items; + }, [leafRows, startField, endField]); + + const todayTime = useMemo(() => { + if (today === false) return null; + if (today === true) { + // Day precision: aligns the line with its day tick and keeps SSR and + // client renders consistent (no per-ms Date.now() drift → no hydration + // mismatch). + const now = new Date(); + now.setHours(0, 0, 0, 0); + return now.getTime(); + } + return toTimestamp(today); + }, [today]); + + const markerTimes = useMemo( + () => + (markers ?? []) + .map(marker => toTimestamp(marker.date)) + .filter((time): time is number => time !== null), + [markers] + ); + + // Domain: explicit `range` wins; otherwise the extent of data + today + + // markers. With nothing to show (e.g. initial server load), fall back to a + // window centered on today so the axis and skeletons still render. + const domain = useMemo(() => { + if (range) { + const a = toTimestamp(range[0]); + const b = toTimestamp(range[1]); + if (a !== null && b !== null) { + return { min: Math.min(a, b), max: Math.max(a, b), explicit: true }; + } + } + let min = Infinity; + let max = -Infinity; + for (const item of timedItems) { + min = Math.min(min, item.startTime); + max = Math.max(max, item.endTime ?? item.startTime); + } + for (const time of [todayTime ?? Infinity, ...markerTimes]) { + if (!Number.isFinite(time)) continue; + min = Math.min(min, time); + max = Math.max(max, time); + } + if (!Number.isFinite(min)) { + const fallback = new Date(); + fallback.setHours(0, 0, 0, 0); + const anchor = todayTime ?? fallback.getTime(); + const pad = FALLBACK_DOMAIN_UNITS * TIMELINE_UNIT_MS[scale]; + return { min: anchor - pad, max: anchor + pad, explicit: false }; + } + return { min, max, explicit: false }; + }, [range, timedItems, todayTime, markerTimes, scale]); + + const timeScale = useMemo( + () => + createTimeScale({ + minTime: domain.min, + maxTime: domain.max, + scale, + unitWidth: effectiveUnitWidth, + padUnits: domain.explicit ? 0 : DOMAIN_PAD_UNITS, + minWidth: fillWidth + }), + [domain, scale, effectiveUnitWidth, fillWidth] + ); + + const { ticks, bands } = useMemo( + () => buildAxis(timeScale, scale, effectiveUnitWidth, tickInterval), + [timeScale, scale, effectiveUnitWidth, tickInterval] + ); + + // Gridline thinning is visual only — positioning (cards, today, cursor + // snapping) stays at full `scale`-unit granularity. + const gridlineEvery = Math.max(1, Math.floor(gridlineInterval)); + + // Card geometry. `renderWidth` is null for point markers (no endField) — + // the wrapper sizes to its content instead of the time span. Rows entirely + // outside the domain are dropped: with an explicit `range`, fetched data + // routinely extends past the window (e.g. whole-month API buckets), and + // those rows must not render beyond the axis or occupy lanes. + const positioned = useMemo(() => { + const items: (typeof timedItems)[number][] = []; + for (const item of timedItems) { + const effectiveEnd = item.endTime ?? item.startTime; + if (item.startTime > timeScale.t1 || effectiveEnd < timeScale.t0) { + continue; + } + items.push(item); + } + return items.map(item => { + const x = timeScale.x(item.startTime); + const spanWidth = + item.endTime !== null + ? (item.endTime - item.startTime) * timeScale.pxPerMs + : 0; + const renderWidth = + item.endTime !== null ? Math.max(spanWidth, MIN_RENDER_WIDTH) : null; + // Point cards (no endField) size to their content, so the packer can't + // know their width — `estimatedPointWidth` stands in for lane packing + // and culling so wide chips don't overlap within a lane. + const packWidth = renderWidth ?? estimatedPointWidth; + return { ...item, x, spanWidth, renderWidth, packWidth }; + }); + }, [timedItems, timeScale, estimatedPointWidth]); + + const { lanes, laneCount } = useMemo(() => { + if (lanePacking === 'one-per-row') { + return { + lanes: positioned.map((_, i) => i), + laneCount: positioned.length + }; + } + return packLanes( + positioned.map(item => ({ x: item.x, width: item.packWidth })) + ); + }, [positioned, lanePacking]); + + // Lane assignments zipped in and sorted ascending by x so per-frame culling + // can binary-search the visible slice instead of scanning every item. Lane + // semantics (packing order, one-per-row row order) are unaffected — lanes + // are assigned before the sort. DOM order becomes chronological. + const cards = useMemo(() => { + const list = positioned.map((item, index) => ({ + ...item, + lane: lanes[index] + })); + list.sort((a, b) => a.x - b.x); + return list; + }, [positioned, lanes]); + + // Widens the culling window's left bound: a card is visible when + // `x + width >= min`, and width isn't sorted — only x is. + const maxPackWidth = useMemo( + () => positioned.reduce((max, item) => Math.max(max, item.packWidth), 0), + [positioned] + ); + + const resolvedMarkers = useMemo(() => { + const list: ResolvedMarker[] = []; + if ( + todayTime !== null && + todayTime >= timeScale.t0 && + todayTime <= timeScale.t1 + ) { + list.push({ + key: '__today', + time: todayTime, + x: timeScale.x(todayTime), + label: dayjs(todayTime).format('D MMM'), + variant: 'accent' + }); + } + markers?.forEach((marker, index) => { + const time = toTimestamp(marker.date); + if (time === null || time < timeScale.t0 || time > timeScale.t1) return; + list.push({ + key: `__marker-${index}`, + time, + x: timeScale.x(time), + label: marker.label ?? dayjs(time).format('D MMM'), + variant: marker.variant ?? 'default' + }); + }); + return list; + }, [todayTime, markers, timeScale]); + + // Viewport tracking — drives horizontal culling (`virtualized`) and + // `onVisibleRangeChange`. rAF-throttled so the state update runs at most + // once per frame regardless of scroll event rate. + const needsViewport = virtualized || Boolean(onVisibleRangeChange); + const [viewport, setViewport] = useState<{ + left: number; + width: number; + } | null>(null); + const rafIdRef = useRef(null); + + const readViewport = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + setViewport(prev => { + const next = { left: el.scrollLeft, width: el.clientWidth }; + if (prev && prev.left === next.left && prev.width === next.width) { + return prev; + } + return next; + }); + }, []); + + // Hover cursor — a crosshair line snapped to the sub-interval (tick unit) + // under the pointer, with a date badge pinned to the axis. Updates are + // rAF-throttled and recomputed on scroll too (the content moves under a + // stationary pointer). + const [cursorTime, setCursorTime] = useState(null); + const pointerXRef = useRef(null); + const cursorRafRef = useRef(null); + + const updateCursorFromPointer = useCallback(() => { + if (!showCursorLine) return; + const el = scrollRef.current; + const pointerX = pointerXRef.current; + if (!el || pointerX === null) return; + const rect = el.getBoundingClientRect(); + const canvasX = pointerX - rect.left + el.scrollLeft; + const time = Math.max( + timeScale.t0, + Math.min(timeScale.timeAt(canvasX), timeScale.t1) + ); + const snapped = startOfUnit(dayjs(time), scale).valueOf(); + setCursorTime(prev => (prev === snapped ? prev : snapped)); + }, [showCursorLine, timeScale, scale]); + + const handlePointerMove = useCallback( + (event: React.MouseEvent) => { + if (!showCursorLine) return; + pointerXRef.current = event.clientX; + if (cursorRafRef.current !== null) return; + cursorRafRef.current = requestAnimationFrame(() => { + cursorRafRef.current = null; + updateCursorFromPointer(); + }); + }, + [showCursorLine, updateCursorFromPointer] + ); + + const handlePointerLeave = useCallback(() => { + pointerXRef.current = null; + if (cursorRafRef.current !== null) { + cancelAnimationFrame(cursorRafRef.current); + cursorRafRef.current = null; + } + setCursorTime(null); + }, []); + + // Drag-to-pan — press the background and drag to scroll both axes. Mouse + // only: touch already pans via native scrolling, and starting only on the + // background keeps cards (row click) and footer controls interactive. + const dragRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + scrollLeft: number; + scrollTop: number; + lastX: number; + lastY: number; + lastTime: number; + vx: number; + vy: number; + } | null>(null); + const [isDragging, setIsDragging] = useState(false); + + // Post-release glide — the pan carries its release velocity and decays + // exponentially instead of stopping dead. Any new interaction cancels it. + const momentumRafRef = useRef(null); + + const stopMomentum = useCallback(() => { + if (momentumRafRef.current !== null) { + cancelAnimationFrame(momentumRafRef.current); + momentumRafRef.current = null; + } + }, []); + + const startMomentum = useCallback( + (initialVx: number, initialVy: number) => { + stopMomentum(); + let vx = initialVx; + let vy = initialVy; + let prevTime = performance.now(); + const step = (now: number) => { + momentumRafRef.current = null; + const el = scrollRef.current; + if (!el) return; + const dt = Math.max(0, now - prevTime); + prevTime = now; + if (dt > 0) { + const beforeLeft = el.scrollLeft; + const beforeTop = el.scrollTop; + el.scrollLeft = beforeLeft + vx * dt; + el.scrollTop = beforeTop + vy * dt; + // Hitting a scroll bound kills that axis so the glide ends there + // instead of burning frames against the edge. + if (el.scrollLeft === beforeLeft) vx = 0; + if (el.scrollTop === beforeTop) vy = 0; + const decay = Math.exp(-dt / MOMENTUM_DECAY_TAU); + vx *= decay; + vy *= decay; + } + if (Math.hypot(vx, vy) >= MOMENTUM_MIN_SPEED) { + momentumRafRef.current = requestAnimationFrame(step); + } + }; + momentumRafRef.current = requestAnimationFrame(step); + }, + [stopMomentum] + ); + + const handleDragPointerDown = useCallback( + (event: React.PointerEvent) => { + // Any press grabs the content — even ones that don't start a pan. + stopMomentum(); + if (event.pointerType !== 'mouse' || event.button !== 0) return; + const el = scrollRef.current; + if (!el) return; + const target = event.target as Element; + if ( + target.closest( + '[role="listitem"], a, button, input, textarea, select, [contenteditable]' + ) + ) { + return; + } + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + scrollLeft: el.scrollLeft, + scrollTop: el.scrollTop, + lastX: event.clientX, + lastY: event.clientY, + lastTime: performance.now(), + vx: 0, + vy: 0 + }; + // Capture so the pan keeps tracking when the pointer leaves the pane. + // Throws for pointers the browser isn't tracking (synthetic events). + try { + el.setPointerCapture?.(event.pointerId); + } catch { + /* noop */ + } + // Panning, not selecting — suppress native text-selection drag. + event.preventDefault(); + }, + [] + ); + + const handleDragPointerMove = useCallback( + (event: React.PointerEvent) => { + const drag = dragRef.current; + const el = scrollRef.current; + if (!drag || !el || event.pointerId !== drag.pointerId) return; + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + el.scrollLeft = drag.scrollLeft - dx; + el.scrollTop = drag.scrollTop - dy; + // Release velocity, EMA-smoothed over a ~50ms window so one noisy + // inter-event gap doesn't set the fling speed. + const now = performance.now(); + const dt = now - drag.lastTime; + if (dt > 0) { + const mix = Math.min(1, dt / 50); + drag.vx += ((event.clientX - drag.lastX) / dt - drag.vx) * mix; + drag.vy += ((event.clientY - drag.lastY) / dt - drag.vy) * mix; + drag.lastX = event.clientX; + drag.lastY = event.clientY; + drag.lastTime = now; + } + // Small threshold so a plain background click never flashes the + // grabbing cursor. + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) setIsDragging(true); + }, + [] + ); + + const handleDragEnd = useCallback( + (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return; + dragRef.current = null; + setIsDragging(false); + // Fling: glide only when released mid-motion, not after holding still. + if (performance.now() - drag.lastTime > MOMENTUM_STALE_MS) return; + const vx = clampSpeed(drag.vx); + const vy = clampSpeed(drag.vy); + if (Math.hypot(vx, vy) < MOMENTUM_MIN_SPEED) return; + // Content scrolls opposite to the pointer's travel. + startMomentum(-vx, -vy); + }, + [startMomentum] + ); + + // Last known scroll offsets — stashed on every scroll (and on programmatic + // scrolls) so the position survives the DOM being unmounted while hidden + // (inactive view / no data) and restored on re-activation, instead of the + // recreated DOM stranding the user at scroll 0 (the domain start). + const savedScrollRef = useRef<{ left: number; top: number } | null>(null); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (el) { + savedScrollRef.current = { left: el.scrollLeft, top: el.scrollTop }; + } + if (!needsViewport && !showCursorLine) return; + if (rafIdRef.current !== null) return; + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null; + if (needsViewport) readViewport(); + updateCursorFromPointer(); + }); + }, [needsViewport, showCursorLine, readViewport, updateCursorFromPointer]); + + useEffect( + () => () => { + for (const ref of [rafIdRef, cursorRafRef, momentumRafRef]) { + if (ref.current !== null) { + cancelAnimationFrame(ref.current); + ref.current = null; + } + } + }, + [] + ); + + useEffect(() => { + if (!needsViewport || !isActive) return; + readViewport(); + const el = scrollRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(readViewport); + observer.observe(el); + return () => observer.disconnect(); + }, [needsViewport, isActive, readViewport]); + + // Deduped by ms values — `timeScale`/`viewport` identity churn (a resize, a + // domain rebuild from streamed-in rows) must not re-fire consumers with an + // unchanged window; each fire typically triggers a fetch check. + const lastNotifiedRangeRef = useRef<{ from: number; to: number } | null>( + null + ); + useEffect(() => { + if (!onVisibleRangeChange || !viewport) return; + const from = timeScale.timeAt(viewport.left); + const to = timeScale.timeAt(viewport.left + viewport.width); + const prev = lastNotifiedRangeRef.current; + if (prev && prev.from === from && prev.to === to) return; + lastNotifiedRangeRef.current = { from, to }; + onVisibleRangeChange([new Date(from), new Date(to)]); + }, [onVisibleRangeChange, viewport, timeScale]); + + // Time-target resolution shared by `defaultScrollTo` and the imperative + // handle. 'today' resolves to the today-line when shown, else the actual + // current date — both get clamped into the domain by `scrollToTime`. + const resolveScrollTarget = useCallback( + (target: NonNullable['defaultScrollTo']>) => { + if (target === 'start') return timeScale.t0; + if (target === 'end') return timeScale.t1; + if (target === 'today') { + if (todayTime !== null) return todayTime; + const now = new Date(); + now.setHours(0, 0, 0, 0); + return now.getTime(); + } + return toTimestamp(target); + }, + [timeScale, todayTime] + ); + + // Programmatic scroll — clamps the target into the domain, aligns it in the + // viewport, and cancels any in-flight momentum glide. Direct scrollLeft + // assignment for 'auto' keeps jsdom (no Element.scrollTo) working. + const scrollToTime = useCallback( + ( + time: number, + align: 'start' | 'center' | 'end', + behavior: 'auto' | 'smooth' + ) => { + const el = scrollRef.current; + if (!el) return; + stopMomentum(); + const clamped = Math.max(timeScale.t0, Math.min(time, timeScale.t1)); + let targetX = timeScale.x(clamped); + if (align === 'center') targetX -= el.clientWidth / 2; + else if (align === 'end') targetX -= el.clientWidth; + const left = Math.max( + 0, + Math.min(targetX, timeScale.totalWidth - el.clientWidth) + ); + // Stash eagerly — programmatic scrolls must survive a view switch even + // when no scroll event follows (e.g. the target equals the current + // position, or jsdom). + savedScrollRef.current = { left, top: el.scrollTop }; + if (behavior === 'smooth' && typeof el.scrollTo === 'function') { + el.scrollTo({ left, behavior: 'smooth' }); + } else { + el.scrollLeft = left; + } + }, + [timeScale, stopMomentum] + ); + + useImperativeHandle( + actionsRef, + (): TimelineActions => ({ + scrollTo: (target, options) => { + if (!scrollRef.current) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + '[DataView.Timeline] scrollTo() ignored — the timeline is not rendered (inactive view or no data).' + ); + } + return; + } + const time = resolveScrollTarget(target); + if (time === null) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + `[DataView.Timeline] scrollTo() received an invalid date: ${String(target)}` + ); + } + return; + } + scrollToTime( + time, + options?.align ?? 'center', + options?.behavior ?? 'smooth' + ); + }, + getVisibleRange: () => { + const el = scrollRef.current; + if (!el) return null; + return [ + new Date(timeScale.timeAt(el.scrollLeft)), + new Date(timeScale.timeAt(el.scrollLeft + el.clientWidth)) + ]; + } + }), + [resolveScrollTarget, scrollToTime, timeScale] + ); + + // Initial scroll position — runs when the renderer (re)mounts its DOM. The + // first activation applies `defaultScrollTo`; re-activations restore the + // stashed position, because the recreated scroll container starts back at + // (0, 0) — without the restore a returning user would land at the domain + // start instead of where they left off. + const didInitScrollRef = useRef(false); + // biome-ignore lint/correctness/useExhaustiveDependencies: `isActive`/`hasData` re-run the attempt when the renderer (re)mounts its DOM (the ref is null while hidden). + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) { + // Hidden (inactive view or no data): the DOM is gone — arm the next + // activation to restore or re-init. + didInitScrollRef.current = false; + return; + } + if (didInitScrollRef.current) return; + didInitScrollRef.current = true; + const saved = savedScrollRef.current; + if (saved) { + el.scrollLeft = saved.left; + el.scrollTop = saved.top; + return; + } + const time = resolveScrollTarget(defaultScrollTo) ?? timeScale.t0; + const align = + defaultScrollTo === 'start' + ? 'start' + : defaultScrollTo === 'end' + ? 'end' + : 'center'; + scrollToTime(time, align, 'auto'); + }, [ + defaultScrollTo, + resolveScrollTarget, + scrollToTime, + timeScale, + isActive, + hasData + ]); + + // Scroll anchoring — when the time domain shifts (rows prepended by + // range-window fetching, `range` extended, zoom change), keep the time under + // the viewport's left edge stable instead of letting content jump. + const scrollAnchorRef = useRef<{ t0: number; pxPerMs: number } | null>(null); + useLayoutEffect(() => { + const el = scrollRef.current; + const prev = scrollAnchorRef.current; + if ( + el && + prev && + (prev.t0 !== timeScale.t0 || prev.pxPerMs !== timeScale.pxPerMs) + ) { + const leftEdgeTime = prev.t0 + el.scrollLeft / prev.pxPerMs; + el.scrollLeft = Math.max(0, timeScale.x(leftEdgeTime)); + } + scrollAnchorRef.current = { + t0: timeScale.t0, + pxPerMs: timeScale.pxPerMs + }; + }, [timeScale]); + + if (!isActive) return null; + // Render nothing when there's truly no data and no loading — sibling + // `` / `` handle messaging. + if (!hasData) return null; + + const lanePitch = estimatedRowHeight + laneGap; + const canvasHeight = Math.max(laneCount, 1) * lanePitch + laneGap; + + // Horizontal culling window — one extra viewport on each side as overscan. + const overscan = viewport ? Math.max(viewport.width, 400) : 0; + const cullRange = + virtualized && viewport + ? { + min: viewport.left - overscan, + max: viewport.left + viewport.width + overscan + } + : null; + // Visible slices via binary search — both lists are sorted ascending by x, + // so per-frame culling costs O(log n + visible) instead of scanning every + // item. The card slice widens its left bound by the widest card (x is + // sorted, width isn't), so each sliced card still gets a right-edge check. + const cardSlice = cullRange + ? cards.slice( + lowerBoundByX(cards, cullRange.min - maxPackWidth), + upperBoundByX(cards, cullRange.max) + ) + : cards; + const gridTicks = cullRange + ? ticks.slice( + lowerBoundByX(ticks, cullRange.min), + upperBoundByX(ticks, cullRange.max) + ) + : ticks; + const cardClassName = cx( + styles.timelineCard, + onRowClick && styles.clickable, + classNames.card + ); + + return ( +
+
+ {bands.map(band => ( +
+ {/* Sticky-left so the label stays visible while its band spans the viewport. */} + {band.label} +
+ ))} + {ticks.map(tick => + tick.showLabel ? ( +
+ {tick.label} +
+ ) : null + )} + {resolvedMarkers.map(marker => ( +
+ + {marker.label} + +
+ ))} + {cursorTime !== null ? ( + + ) : null} +
+ +
+ {showGridlines + ? gridTicks.map(tick => + tick.index % gridlineEvery === 0 ? ( +