Skip to content

feat: dataview timeline component#860

Open
rohanchkrabrty wants to merge 1 commit into
mainfrom
feat-timeline
Open

feat: dataview timeline component#860
rohanchkrabrty wants to merge 1 commit into
mainfrom
feat-timeline

Conversation

@rohanchkrabrty

Copy link
Copy Markdown
Contributor

Summary

  • Adds DataView.Timeline, a renderer that positions the shared DataView row model as cards on a continuous, horizontally scrollable time axis — the card interior is fully consumer-owned via renderCard(row, context), while the Timeline owns the time scale, lane packing, sticky two-tier axis (ticks, month/year bands, today + custom markers, gridlines), and native x/y scrolling with drag-to-pan, momentum glide, and a snapped hover cursor.
  • Supports range-window data loading: explicit range for a stable coordinate space, onVisibleRangeChange (rAF-throttled, deduped by ms window), and time-anchored scrolling so the content never jumps when the domain shifts; imperative navigation ships via actionsRef (scrollTo, getVisibleRange).
  • Performance: memoized card wrapper so per-frame cursor/viewport state never re-renders card interiors, and binary-search horizontal culling behind virtualized.
  • Fixes from review: scroll position is restored when a gated view is re-activated, and point-marker lanes pack with estimatedPointWidth so content-sized cards don't overlap; rowHeight is renamed to estimatedRowHeight to match DataView.List.
  • Includes docs (guide + props tables), playground demos, an order-desk example page, and 52 unit tests covering geometry, axis, panning, culling, and the imperative handle.

@rohanchkrabrty rohanchkrabrty self-assigned this Jul 21, 2026
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
apsara Ready Ready Preview, Comment Jul 21, 2026 7:35am

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces DataView.Timeline with time-scaled cards, point markers, lane packing, virtualization, axis controls, panning, visible-range callbacks, and imperative navigation. Adds public timeline types and utilities with comprehensive tests. Documentation and demos cover interval and point timelines. A new order-management example demonstrates shared timeline/list views, filters, details dialogs, and month-based prefetching.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant DataViewTimeline
  participant TimelineActions
  participant OrderPage
  participant OrdersAPI
  User->>DataViewTimeline: scroll timeline
  DataViewTimeline->>OrderPage: report visible date range
  OrderPage->>OrdersAPI: fetch missing month ranges
  OrdersAPI-->>OrderPage: return orders
  OrderPage-->>DataViewTimeline: merge loaded rows
  User->>TimelineActions: select Today
  TimelineActions->>DataViewTimeline: scrollTo today
Loading

Suggested reviewers: paansinghcoder, ravisuhag, rsbh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding a DataView timeline component.
Description check ✅ Passed The description is directly related to the timeline component, docs, demos, example page, and tests added in this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/www/src/app/examples/timeline/page.tsx`:
- Around line 573-612: Update the fetch handling in loadWindow so rejected
requests always decrement inflightRef and clear isLoading when no runs remain,
while removing each failed month range from requestedRef to allow retries. Add
rejection handling without changing the existing successful order-merging
behavior.

In `@apps/www/src/content/docs/components/dataview/props.ts`:
- Around line 178-197: Make DataViewTimelineProps generic as
DataViewTimelineProps<TData>, and update renderCard to receive the TanStack
Row<TData> wrapper rather than raw row data. Keep the existing
TimelineCardContext parameter and ensure the interface consistently uses the
generic type for the callback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 99b881ad-cfdb-4cc2-867c-91dbff8f72f5

📥 Commits

Reviewing files that changed from the base of the PR and between 1812fa0 and b63fcfe.

📒 Files selected for processing (15)
  • apps/www/src/app/examples/timeline/page.tsx
  • apps/www/src/components/dataview-demo.tsx
  • apps/www/src/components/demo/demo.tsx
  • apps/www/src/content/docs/components/dataview/demo.ts
  • apps/www/src/content/docs/components/dataview/index.mdx
  • apps/www/src/content/docs/components/dataview/props.ts
  • packages/raystack/components/data-view/__tests__/timeline.test.tsx
  • packages/raystack/components/data-view/components/timeline.tsx
  • packages/raystack/components/data-view/data-view.module.css
  • packages/raystack/components/data-view/data-view.tsx
  • packages/raystack/components/data-view/data-view.types.tsx
  • packages/raystack/components/data-view/index.ts
  • packages/raystack/components/data-view/utils/pack-lanes.tsx
  • packages/raystack/components/data-view/utils/time-scale.tsx
  • packages/raystack/index.tsx

Comment on lines +573 to +612
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);
});
}
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Unhandled fetch rejection permanently poisons requestedRef and stalls isLoading.

fetchOrdersApi(...).then(...) has no .catch()/.finally(). If the request rejects (the realistic case once this mock is swapped for a real API), the months were already added to requestedRef at line 581 before the fetch settles, so they're never retried, and inflightRef.current never decrements — leaving isLoading stuck true forever for that window.

🛡️ Proposed fix
     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);
-      });
+      )
+        .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;
+          });
+        })
+        .catch(error => {
+          console.error('Failed to load orders', error);
+          // Un-mark so a future scroll into this window retries the fetch.
+          for (const key of monthKeysBetween(
+            dayjs(`${firstKey}-01`).valueOf(),
+            dayjs(`${lastKey}-01`).endOf('month').valueOf()
+          )) {
+            requestedRef.current.delete(key);
+          }
+        })
+        .finally(() => {
+          inflightRef.current -= 1;
+          if (inflightRef.current === 0) setIsLoading(false);
+        });
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
}
}, []);
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;
});
})
.catch(error => {
console.error('Failed to load orders', error);
// Un-mark so a future scroll into this window retries the fetch.
for (const key of monthKeysBetween(
dayjs(`${firstKey}-01`).valueOf(),
dayjs(`${lastKey}-01`).endOf('month').valueOf()
)) {
requestedRef.current.delete(key);
}
})
.finally(() => {
inflightRef.current -= 1;
if (inflightRef.current === 0) setIsLoading(false);
});
}
}, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/app/examples/timeline/page.tsx` around lines 573 - 612, Update
the fetch handling in loadWindow so rejected requests always decrement
inflightRef and clear isLoading when no runs remain, while removing each failed
month range from requestedRef to allow retries. Add rejection handling without
changing the existing successful order-merging behavior.

Comment on lines +178 to +197
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

DataViewTimelineProps uses T but is declared non-generic; renderCard row type is also inaccurate.

Two issues here:

  1. DataViewTimelineProps (Line 178) declares no generic, but renderCard: (row: T, ...) (Line 197) references T — same unbound-name concern as DataViewListColumn. The canonical type is DataViewTimelineProps<TData>.
  2. Per the tests and the real contract, renderCard's first arg is a TanStack Row<T> (consumers read row.original), not the row data T. Documenting it as row: T will mislead consumers.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/content/docs/components/dataview/props.ts` around lines 178 -
197, Make DataViewTimelineProps generic as DataViewTimelineProps<TData>, and
update renderCard to receive the TanStack Row<TData> wrapper rather than raw row
data. Keep the existing TimelineCardContext parameter and ensure the interface
consistently uses the generic type for the callback.

@rohilsurana rohilsurana left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: DataView.Timeline (in progress)

Reviewing this outside-in over a few passes. Covered so far: the public API/types, the pure utils (time-scale, pack-lanes), and the component itself. Styles, the 52 tests, and the docs/demo are still to come.

Overall this is strong, careful work. The renderer contract mirrors DataView.List and the actionsRef pattern from tour-beta closely, so the API stays consistent with the rest of the library. The scroll/pan/momentum machinery and the scroll-anchoring on domain shift are thoughtfully done, hooks are all called before the early returns, and rAF throttling plus cleanup are handled properly.

Confirmed the data scrub this PR exists for: the only change from the previous PR is the demo mock data in examples/timeline/page.tsx, real org/file names are gone, and no proprietary terms remain in any of the 15 files or in the branch's own commits. Note this only protects main going forward; the earlier data still lives in the previous PR's branch and commits on the remote if that matters.

Inline notes below, tagged by severity. The only one I would treat as a real gate is the unitWidth <= 0 guard in time-scale.tsx. The rest are API/doc/a11y polish. Nothing here blocks the concept.

padUnits = 0,
minWidth = 0
} = params;
const pxPerMs = unitWidth / TIMELINE_UNIT_MS[scale];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[robustness] unitWidth is consumer-controllable (the unitWidth prop). If it is 0 or negative, pxPerMs becomes 0, and the viewport-fill step below computes Math.ceil(deficitPx / unitWidth) = Infinity, which feeds addUnits(end, scale, Infinity) and a while loop that can never exit. A single bad prop value hangs the render. Suggest clamping at the boundary, e.g. const unitWidth = Math.max(1, params.unitWidth). This is the one point I would treat as a real gate, because the failure mode is a hang rather than a wrong pixel.

let cursor = startOfUnit(dayjs(timeScale.t0), scale);
if (cursor.valueOf() < timeScale.t0) cursor = addUnits(cursor, scale, 1);
let index = 0;
while (cursor.valueOf() <= timeScale.t1) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[scalability, non-blocking] buildAxis materializes one tick object per unit across the whole domain. The virtualized path culls what renders but not what gets built here, so a day scale over a multi-year range allocates tens of thousands of ticks on every domain rebuild. Fine for the shipped use cases (weeks/months). Worth a code comment so nobody points it at a decade of days and wonders why it stalls.

laneCount: number;
}

const DEFAULT_LANE_GAP_PX = 8;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Two things: (1) the horizontal in-lane gap defaults to 8px and the component never passes a value, so it is not configurable, while the vertical gap (laneGap) is a public prop defaulting to 16. Worth confirming that asymmetry is intentional. (2) DEFAULT_LANE_GAP_PX (8, horizontal) reads almost identically to DEFAULT_LANE_GAP (16, vertical) in timeline.tsx but they are different axes; a rename like DEFAULT_CARD_GAP_PX would remove the trap for the next reader. For the record, the first-fit greedy packing itself is correct and optimal in lane count.

*/
lanePacking?: 'auto' | 'one-per-row';
/** Card height in px (cards render at exactly this height; named to match `DataView.List`). Default 66. */
estimatedRowHeight?: number;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[api / naming] rowHeight was renamed to estimatedRowHeight to match DataView.List, but the semantics differ. In List, estimatedRowHeight is a true estimate: rows auto-measure after paint and it is only used until the first measurement. In Timeline, cards render at exactly this height (applied as a hard height style; nothing measures), so content taller than this value clips or overflows. The shared name promises auto-measurement that does not happen here. Options: keep rowHeight (honest), or keep the name but make the doc explicit that cards do not auto-measure unlike List. Since the rename came from earlier review feedback, flagging the mismatch rather than deciding it.

* visual entirely — chrome, states, truncation, and the collapsed variant.
* Compose `<DataView.DisplayAccess>` inside for Display Properties support.
*/
renderCard: (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[perf / docs] The Timeline memoizes its card wrapper (TimelineCardView) so per-frame cursor/viewport state does not re-render card interiors. That optimization only holds if renderCard is referentially stable. If a consumer passes an inline renderCard={(row, ctx) => ...}, its identity changes every render, the memo never hits, and every visible card re-renders on every scroll frame. Nothing here tells consumers to wrap it in useCallback. Suggest adding that guidance to this doc (the same applies to onRowClick).

/** Pixel width of the time span (0 when `endField` is omitted). */
width: number;
/** True when the span is narrower than `minCardWidth`. */
collapsed: boolean;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor doc] collapsed is documented as true when the span is narrower than minCardWidth, but point cards (no endField) are never collapsed regardless of width (see collapsed={item.endTime !== null && ...} in timeline.tsx). One clause here would close the gap.

);
}

const TimelineCardView = memo(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[perf] The value of this memo depends on row, renderCard, and onRowClick being referentially stable across renders (the comment above says as much). row is stable from TanStack, but renderCard/onRowClick come straight from consumer props with no memoization enforced. If a consumer passes them inline, the shallow compare fails every frame and this memo becomes pure overhead. Pairs with the renderCard doc note in data-view.types.tsx. No change needed in this file; calling it out so the consumer-facing docs cover it.

);

return (
<div

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a11y] The scroll container is the primary way to navigate the timeline, but it is not keyboard operable: no tabIndex, so keyboard-only users cannot focus it to scroll horizontally with arrow keys, and drag-to-pan is mouse-only by design. The role="list" canvas also has no accessible name. Focusable content inside renderCard lets tab reach individual cards, but panning the axis itself is unreachable without a pointer. Consider tabIndex={0} + an aria-label on the scroll region, or documenting the keyboard story. Medium priority.

// Panning, not selecting — suppress native text-selection drag.
event.preventDefault();
},
[]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] handleDragPointerDown uses stopMomentum but its dependency array is []. It works because stopMomentum is a stable useCallback([]), but it is inconsistent with the rest of this file, which is otherwise careful with exhaustive deps (and would trip react-hooks/exhaustive-deps if that rule is on). Either add stopMomentum to the array or leave a note.

@rohilsurana rohilsurana left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step 4: styles (data-view.module.css)

Checked the Timeline styles against house conventions. Tokens all resolve to real definitions in styles/colors.css and are the same ones the existing data-view CSS uses, so light/dark theming comes for free. The 0.5px borders match the codebase convention (used in ~31 component stylesheets). Layering, sticky axis/footer, and overscroll-behavior: contain are all sensible. Two low-priority notes inline.

position: sticky;
top: 0;
z-index: 3;
height: 52px;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor / maintainability] The 52px axis height is a magic literal duplicated here and in .timelineCanvas (min-height: calc(100% - 52px)), plus the explaining comment. Change the axis height and you must remember all three. A custom property scoped to .timelineRoot (e.g. --timeline-axis-height: 52px) referenced in both rules would keep them in sync. Nice that it is not also duplicated in the JS.

pointer-events: none;
}

.timelineMarkerLine {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[design question, low] Marker lines, gridlines, and the cursor line render before the cards in DOM order and none carry a z-index, so cards paint on top of them. A today line reading as a subtle background is fine, but a custom danger marker (a deadline) would be hidden wherever a card overlaps its x. Is occluding marker lines behind cards intended? If milestone lines should stay visible, they would need to sit above cards (a higher z-index, still pointer-events: none).

@rohilsurana rohilsurana left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step 5: tests (52 cases, __tests__/timeline.test.tsx)

This is a genuinely strong suite, well above what most feature PRs ship. It does not just assert "renders" — it checks exact geometry (px positions, widths, lane indices) and it exercises the hard, stateful machinery I was most worried about:

  • momentum glide after a fling, plus wheel-cancels-glide, plus hold-still-then-release does not glide
  • drag-to-pan on both axes, and correctly not panning from a card or a touch pointer
  • binary-search horizontal culling under virtualized (overscan window)
  • scroll anchoring: the left-edge time stays put when the domain is extended leftward
  • onVisibleRangeChange dedup on identity churn, and re-fire on a real scroll
  • scroll-position restore when a gated view is switched away and back
  • the memoized card wrapper: a hover-cursor root re-render does not re-render card interiors
  • the full actionsRef surface, including no-op-with-warning while hidden

Two gaps worth closing, both inline:

  1. quarter scale is untested — and it is the only scale with hand-rolled (non-dayjs) logic, so it is the highest-value missing case.
  2. rAF is flushed with real timers, which makes the momentum/timing tests the most likely to flake on CI and slows the suite.

One more, tied to my step-2 note: the unitWidth <= 0 path (the potential hang) has no test because the guard does not exist yet. If the guard is added, a one-line test should come with it.

Net: coverage is not the concern here. The two items above are quality/robustness polish.

});
});

describe('buildAxis', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test coverage] Every scale-level and renderer-level test uses day or month. The quarter scale has zero coverage, and it is the riskiest path because its logic is hand-rolled: startOfUnit (date.startOf('month').subtract(date.month() % 3, ...)), addUnits (add(3 * n, 'month')), and the Q${...} branches in tickLabel and cursorLabel all exist precisely because dayjs has no quarter support without a plugin. A single quarter-scale buildAxis case (assert Q1 2025/Q2 ticks and year bands) would cover the one part of the math that is not just delegating to dayjs. A week case would be nice too, though it mostly delegates.

Comment thread packages/raystack/components/data-view/__tests__/timeline.test.tsx

@rohilsurana rohilsurana left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step 6: docs and demo (final pass)

The guide (index.mdx) is genuinely excellent: accurate to the real API (scale set, unitWidth defaults, minCardWidth, estimatedPointWidth, actionsRef, the range-window loading walkthrough), well-structured, and its code examples correctly use row.original. The TimelineCardContext/TimelineMarker/TimelineActions tables are wired up via auto-type-table, and the demo exports (timelinePreview, timelinePointPreview) resolve.

I also verified the two existing CodeRabbit findings rather than take them at face value:

  • props.ts "unbound T" (Major): false positive. T is a deliberate = any placeholder (line 5). The only real sliver is that renderCard's arg is a Row wrapper; minor, and the guide already teaches row.original. Details inline.
  • page.tsx unhandled rejection (Critical): the code observation is right, but the mock never rejects, so it cannot happen here. Worth fixing only because the guide sells this as the recommended pattern and examples get copied. Downgraded to example-quality. Details inline.

Also worth noting for the author: the estimatedRowHeight doc here (line 263) already has to add "cards render at exactly this height" to undo the misleading name, which reinforces the naming point from my earlier types comment.


Overall (steps 1-6 complete)

This is strong, careful work: a well-designed API consistent with DataView.List and tour-beta, solid scroll/pan/momentum and scroll-anchoring, correct and optimal lane packing, a comprehensive 52-test suite, on-pattern theming, and excellent docs. The data scrub this PR exists for is complete and clean.

Nothing I found blocks the concept. In priority order, before merge I would want:

  1. the unitWidth <= 0 guard in time-scale.tsx (only issue that can hang the render);
  2. a quarter-scale test (the one untested hand-rolled path).

Everything else (the estimatedRowHeight name, renderCard/memo stability docs, keyboard a11y on the scroll region, the CSS 52px de-dup, marker-line z-order, real-timer test flake, and the example's fetch error handling) is polish the author can take or leave.

fetchOrdersApi(
dayjs(`${firstKey}-01`).valueOf(),
dayjs(`${lastKey}-01`).endOf('month').valueOf()
).then(rows => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[example quality] Confirming CodeRabbit's finding here, with a calibrated severity. The observation is correct: this .then() has no .catch()/.finally(), and because the months are added to requestedRef at line 581 before the fetch settles, a rejection would strand isLoading at true and never retry that window.

But I would not call it Critical: fetchOrdersApi (line 161) only ever resolves via setTimeout, so it cannot reject in this demo. The real reason to fix it is that the guide presents this block as "the recommended pattern" for range-window loading, and example code gets copied against a real API that can reject. So: worth fixing as a teaching example (add .catch that deletes the affected keys from requestedRef so a later scroll retries, plus .finally for the inflight decrement), but it is not a bug in the shipped library and does not block the feature.

Comment thread apps/www/src/content/docs/components/dataview/props.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants