finding(plugin-timeline): an object-bound timeline with variant: "gantt" throws — calculateDateRange reads a row shape ObjectTimeline never composes #6655
Description
Filed unassigned, from work on #6355 (retiring the timeScale alias). Not caused by that change — see "Provenance" below.
What happens
Rendering ObjectTimeline with variant: 'gantt' and object-bound data throws:
RangeError: Invalid time value
at calculateDateRange packages/plugin-timeline/src/renderer.tsx:151
at TimelineRenderer packages/plugin-timeline/src/renderer.tsx:410
(line numbers at merge-base f241a4df4)
Why
The two timeline item shapes are not interchangeable, and this path crosses them.
ObjectTimeline maps each record to a feed item — flat, one item per record:
{ title, time, startDate, endDate, description, variant, color, group, meta, _data }calculateDateRange, on the gantt branch, reads the gantt row shape instead — a row that owns a nested items array:
const allDates = items.flatMap((row) => (row.items || []).flatMap((item) => [item.startDate, item.endDate]));For ObjectTimeline's output every row.items is undefined, so allDates is empty. Math.min() over an empty list is Infinity, and new Date(Infinity).toISOString() throws. There is no guard and no diagnostic — the component simply throws during render.
Why it matters
ObjectTimeline deliberately resolves an axis scale (timelineConfig?.scale ?? schema.scale) and composes it into the schema it hands the renderer. That axis is a gantt-only concern — the vertical and horizontal variants are sequential feeds with no time axis. So the object-bound path carries configuration for exactly the variant it cannot render: an author who sets variant: gantt on an object-bound timeline gets a crash rather than a chart, and the scale option that path plumbs through is unreachable in practice.
Provenance — this predates #6355
Observed while writing #6355's pins. Both halves of the crash are byte-identical to the merge base, verified with git diff:
calculateDateRangeinrenderer.tsx— untouched (0 changed lines matching the function or its body);ObjectTimeline's record-to-item mapping — untouched.
#6355 changes only which KEY the resolved axis is composed under (timeScale to scale). #6355's own pin therefore renders variant: 'vertical' deliberately, so it does not couple to this defect.
Related, but not the same card
#6356 records that TimelineSchema.items has no declared element type and that there are two of them. That is the declaration half of the same two-shapes fact. This is the runtime half: declaring the element type would not stop ObjectTimeline from composing the wrong one of the two for gantt, so this is not fixed by closing #6356.
For triage — two directions, both plausible
- Make it work:
ObjectTimelinecomposes gantt ROWS forvariant: gantt, grouping records into rows (groupByFieldis already resolved on that path and is the obvious grouping key). - Make it refuse loudly: the object-bound path rejects
variant: ganttwith a real diagnostic until (1) exists — no crash, and no silently-inertscaleoption either.
Which one is right depends on whether object-bound gantt is a capability the product wants now; that is a maintainer call, not a mechanical fix, which is why this is filed rather than fixed.
Activity
os-sales commentedon Aug 29, 2026
Claim:
domain:uiexecution seat, sessionsession_01CRJge11jso9TpXRWFt1Z49.Branch:
claude/issue-6655-object-timeline-gantt-refuse-loudly.Dispatched as ruled work, executing option 2 (refuse loudly) per the 2026-08-29 director ruling on this card — verbatim 「同意」. Option 1 (compose real gantt rows from records) is explicitly NOT in scope and stays unruled.
Declared file face for the cross-lane in-flight check:
packages/plugin-timeline/src/ObjectTimeline.tsx(the object-bound path that composes the schema — where the refusal lands),packages/plugin-timeline/src/renderer.tsx(read-only:calculateDateRangeis the crash site, and this card does not change it), plus new/updated pins underpackages/plugin-timeline/src/.The ruling's three pins are the acceptance criterion, both directions included: object-bound +
gantt⇒ the diagnostic, noRangeError; object-bound +vertical/horizontal⇒ unchanged; literal-rows +gantt⇒ unchanged.Generated by Claude Code
os-sales commentedon Aug 29, 2026
os-dev-report
{ "issue": 6655, "status": "done", "branch": "claude/issue-6655-object-timeline-gantt-refuse-loudly", "pr": "https://github.com/objectstack-ai/objectui/pull/6749", "premise_still_valid": true, "summary": "Implemented option 2 (refuse loudly) exactly as ruled; option 1 is untouched and stays unruled. ObjectTimeline now rejects variant 'gantt' on the COMPOSED path with an author-facing diagnostic naming the limitation, instead of falling into calculateDateRange and throwing RangeError. The condition keys on whether items were AUTHORED (the same schema.items test effectiveItems makes), not on the variant alone -- this component also answers the bare 'timeline' key and the in-repo catalog fixture plugin-timeline/gantt-style-timeline.json is a literal gantt flowing through it, so a variant-only refusal would have broken it. Convention matched: plugin-dashboard/src/PivotTable.tsx (objectui#2941), the nearest neighbour -- an out-of-vocabulary AUTHORED ENUM VALUE refused on a renderer, after the hooks, via a role=alert box naming the refused value and what the surface does render; its own comment even says 'refuse loudly'. Rejected DatasetReportRenderer's muted dashed note (it degrades to a table that still carries the numbers -- nothing survives here) and DashboardGridLayout's text-node lowering. Markup also matches this file's own timeline-error state. RULING CLAUSE 2 resolved by the second route the card allowed (the refusal is what the author now sees), NOT by dropping the composition on the feed variants -- that route is closed by evidence: objectui#6355's landed pin ObjectTimeline.scaleComposition.test.tsx renders variant 'vertical' and asserts schema.scale === 'year' and resolveTimelineScale(schema) === 'year', so gating the composition would turn it red, which is the ruling's own pin 2 failing. Measured with pin 1b: an object-bound gantt carrying timeline.scale 'year' shows the diagnostic and the 2099/2100 year axis is absent from the DOM. The diagnostic goes through the package's i18n channel rather than an English literal -- an inline literal trips check:i18n-keys, whose message names packages/i18n/src/locales/en.ts as the source of truth and rejects the inline-default workaround; the key is in all ten packs, mirrored byte-identically into TIMELINE_DEFAULT_TRANSLATIONS. The German value uses the pack's mandated typographic pair, which moves de-quote-pairing-3876.test.ts's census 54 to 55 with a line naming this card (rdq stays 0). If I disagreed with the ruling I would say so here: I do not -- refusing is the right call while no product demand exists, and option 1's grouping key (groupByField) is already overloaded for date bucketing, so composing rows from it would have needed its own decision.", "tests": "All readings name their commit. Base faac0d935; final commit 371dd804a; union re-run on 371dd804a after the last commit. NOTE: package-dir vitest is refused by this repo's guard (objectui#3378), so every run is from the repo root by path -- the one such refusal I hit is reported as NOT MEASURED, not as a red. RED FIRST on base faac0d935, pin written before any fix: 'AssertionError: expected [Function] to not throw an error but RangeError: Invalid time value was thrown', stack 'calculateDateRange packages/plugin-timeline/src/renderer.tsx:174:37' from 'TimelineRenderer packages/plugin-timeline/src/renderer.tsx:424:25' -- card's :151/:410 were merge-base f241a4df4 numbers and are stale, re-derived as stated. Verdict line: 'Tests 4 failed | 6 passed (10)'; the 4 red are pins 1 and 1b, the 6 green are pins 2 and 3, already passing on base, which is what makes them a real unchanged-baseline rather than filler. ABLATION against the COMMITTED implementation at 371dd804a: no rebuild needed on either leg and I say why -- the pin imports the mutated module by RELATIVE specifier (import { ObjectTimeline } from '../ObjectTimeline'), so vitest compiles that source directly and no dist can stale it. Mutation confirmed ON DISK before any run, not by the editor's exit code: the timeline-unsupported-variant marker went 1 to 0 and an injected marker went 0 to 1 (both grep -c), and git diff --stat showed '1 file changed, 1 insertion(+), 9 deletions(-)'. Restore trapped on EXIT INT TERM with absolute paths resolved from git rev-parse --show-toplevel, and written as 'git checkout HEAD -- ABSOLUTE_PATH', never the bare form. Ablated leg: 'Tests 4 failed | 6 passed (10)' with RangeError: Invalid time value -- the same 4, and pins 2 and 3 stayed GREEN under ablation, so the pin discriminates rather than going uniformly red. RESTORE PROVEN, not assumed: git diff HEAD empty AND on-disk blob 1b67bd7e1dd2c104263e298df48ee69f3bdefc18 equal to that path's HEAD blob hash (empty hash would have been read as failure; the script exits nonzero on empty or mismatch). Restored-green leg: 'Test Files 71 passed (71) / Tests 1016 passed (1016)'. SUITES at 371dd804a (@object-ui/i18n + @object-ui/plugin-timeline): 'Test Files 71 passed (71) / Tests 1016 passed (1016)'. TYPE-CHECK both packages: pnpm echoed the script name (it is spelled type-check with a hyphen in this repo) and both tsc invocations, 'tsc --noEmit && tsc -p tsconfig.test.json' then 'Done', with 'Scope: 2 of 47 workspace projects' proving the filter matched 2 projects rather than silently matching zero. The new pin is verifiably INSIDE that program, measured not assumed: tsc -p tsconfig.test.json --listFiles lists it (1 hit). GATES at 371dd804a, each quoted from its OWN verdict line, every exit code captured by redirect-then-capture (never after a pipe): check:control-bytes 'OK (scanned 5606 tracked text file(s); skipped 85 binary)'; check:i18n-keys '2587/2587 literal keys resolve' -- it was 2586/2587 with the key missing, which is how the en-pack requirement surfaced and it is a REAL red I fixed, not a waived one; check:i18n-drift 'No en value changed in this range' with '1 key(s) added, 0 removed'; check:vi-mock-specifiers 'OK (3938 tracked source file(s) ... 751 relative specifier(s) resolved)'; check:self-import 'No package names itself inside its own src/.'; check:phantom-deps 'Every in-scope import is declared by the package that publishes it.'; check:esm-specifiers exit 0 with no finding; check:i18n-dead-keys is a report, not a gate, by its own footer. Also caught and fixed a real red mid-run: de-quote-pairing-3876.test.ts rejected U+0022 in a de value, then its two census assertions needed the honest 54-to-55 bump with a named line. LINT, NARROWED AND DECLARED with all three pieces of evidence: (1) population read from the repo's own config, not my guess -- root lint is 'turbo run lint' and each package's lint is 'eslint .', so CI's job is that per-package run across all 47 workspace projects; (2) file counts from eslint's own --format json, not from my reading of the text output -- plugin-timeline 20 files / 0 errors / 71 warnings, i18n 84 files / 0 errors / 34 warnings, and pnpm's own 'Scope: 2 of 47 workspace projects'; (3) config invariance for untouched files -- eslint.config.js declares no projectService, no parserOptions and no project key (grep exit 1), so type-aware linting is OFF and this diff cannot move the verdict on any file it does not contain. plugin-timeline went 72 to 71 warnings mid-review: the one new warning was react-refresh/only-export-components on a constant I had exported, so it is module-local now and adds no public surface. NOT MEASURED -- neither green nor red, with the reason, per the gates' own words: check:sdui-registration-pins refused to run, 'No console build to weigh at apps/console/dist/assets', and the gate itself states 'This is exit 2, not a pass ... a run with nothing to read has measured nothing'; it needs pnpm --filter @object-ui/console build, and this diff changes no registration, no sideEffects array and no bundle entry. check:readme-exports declared its own population COLLAPSED -- '2098 export symbol(s) read from 10 of 40 package(s) (27 unbuilt)', every finding of the form 'its type entry is not on disk -- run pnpm build first' and none in a package this diff touches; it needs a full repo build. One further NOT MEASURED: my first vitest invocation was refused by the objectui#3378 package-dir guard before any test ran -- a startup refusal, re-run correctly from the repo root, and recorded here rather than counted as a failure. CI is not waited on: PR is draft and open, checks are in_progress at report time, which is not green.", "mcp_calls": "7 -- issue_read get, issue_read get_comments, create_pull_request, pull_request_read get, issue_write create (the finding), add_issue_comment (this report), issue_read get_comments (its read-back). Card body and comments came through MCP; the duplicate check went through the zero-quota channel instead -- a repo-scoped REST read probed 200, but this session's credentials refuse the GLOBAL search endpoint ('sessions are bound to their configured repositories'), so I used one bounded repo-scoped issues list plus a local grep rather than a wide paged scan. No duplicate.", "open_questions": [], "out_of_scope_findings": [ "filed as #6750: a gantt timeline with an EMPTY literal items array throws the same RangeError -- calculateDateRange has no empty-list guard, and an empty array is truthy so it takes the AUTHORED path, which #6655's refusal deliberately does not touch. Verified empirically on 371dd804a (with this card's fix in the tree) via a throwaway probe, both entry points: TimelineRenderer directly, and ObjectTimeline with authored items: []. Probe deleted; tree clean at 371dd804a. Not fixed and PR not widened." ] }Generated by Claude Code
os-sales commentedon Aug 29, 2026
✅ ACCEPT — PR #6749, head
371dd804aReviewed by the dispatching
domain:uiseat. Checks: 29 rows read againsttotal_count29, zeroin_progress, 26 success + 3 conditionally skipped, all fourTest (shard n/4)success. Ready flipped before auto-merge was armed.The ruling was executed, and option 1 is untouched
Option 2 (refuse loudly) shipped; composing gantt rows from records was not attempted and stays unruled. The implementer states outright that it does not disagree with the ruling, and adds a reason worth keeping: option 1's grouping key (
groupByField) is already overloaded for date bucketing, so composing rows from it would have needed its own decision.Three things I checked rather than accepted on report
1. The refusal condition is narrower than the card implied, and correctly so. It keys on
!hasAuthoredItems && schema.variant === 'gantt', not on the variant alone. The implementer found the reason in-repo: this component also answers the baretimelinekey, and the catalog fixtureplugin-timeline/gantt-style-timeline.jsonis a literal gantt with authored rows flowing through it. A variant-only refusal would have taken it — and every other authored gantt — down. That is the ruling's pin 3 answered with the concrete in-repo instance rather than an abstract assertion. The condition also uses=== 'gantt'rather than "not a feed variant", because an absentvariantmeans the renderer'sverticaldefault, not an unsupported one.2. Ruling clause 2 (
scaleno longer silently inert) was resolved by the route the card left open, and the other route is closed by evidence. Gating the composition on the feed variants would turn #6355's landed pin (ObjectTimeline.scaleComposition.test.tsx, which rendersverticaland assertsschema.scale === 'year') red — i.e. the ruling's own pin 2 failing. So the refusal is what the author now sees instead, and that was measured, not argued: pin 1b shows an object-bound gantt carryingtimeline.scale: 'year'renders the diagnostic with the 2099/2100 year axis absent from the DOM. This is the clause of the ruling most easily dropped; it was not dropped.3. The i18n work is gate-mandated, not scope creep. I had it noted as "beyond the order but reasonable". More precisely:
check:i18n-keyswas a real red at 2586/2587 with the key missing, and its message namespackages/i18n/src/locales/en.tsas the source of truth while explicitly rejecting the inline-default workaround. Ten packs plus the mirroredTIMELINE_DEFAULT_TRANSLATIONSis this repo's only legal way to ship one author-facing sentence.I also checked the one thing in this diff that could have been a weakening —
de-quote-pairing-3876.test.tsis an existing test modified. It is a ratchet bump: the census goes 54 → 55,rdqstays 0, the invariantclose === open + rdqis untouched, and the file's comment ledger was extended in its own established style with a line naming this card and the reason the new span is literal rather than interpolated. A weakening relaxes an assertion; this tightens a number.Convention matched, alternatives rejected with reasons
The diagnostic follows
plugin-dashboard/src/PivotTable.tsx(objectui#2941) — the nearest neighbour: an out-of-vocabulary authored enum value refused on a renderer, after the hooks, via arole="alert"box naming the refused value and what the surface does render; its own comment says "refuse loudly".DatasetReportRenderer's muted dashed note was rejected because it degrades to a table that still carries the numbers, and nothing survives here;DashboardGridLayout's text-node lowering likewise. Markup also matches this file's own timeline-error state.Recorded
calculateDateRangeatrenderer.tsx:174, fromTimelineRendererat:424) — and the card's:151/:410were merge-basef241a4df4numbers, correctly re-derived rather than trusted.check:sdui-registration-pins("No console build to weigh… a run with nothing to read has measured nothing"),check:readme-exports(population COLLAPSED, 10 of 40 packages read), and a first vitest invocation refused by the objectui#3378 package-dir guard before any test body ran.itemsarray throws —calculateDateRangereduces an empty list #6750: a gantt timeline with an empty literalitems: []throws the sameRangeError—calculateDateRangehas no empty-list guard, and an empty array is truthy so it takes the AUTHORED path, which this card's refusal deliberately does not touch. Verified empirically on371dd804awith this fix in the tree, through both entry points. Correctly not fixed here and the PR not widened; flagging it so the remaining half is not mistaken for closed by this card's landing.Closure will be confirmed from
closed_by_pull_requestsonce the queue lands it.Generated by Claude Code
chore(budget): re-baseline the framework eager chunk for the #6759 di…
fix(plugin-timeline): refuse an unusable gantt date range with a diag…