Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

finding(plugin-timeline): a gantt timeline with an EMPTY literal items array throws — calculateDateRange reduces an empty list #6750

Closed

Description

@os-sales

Filed unassigned, found while implementing #6655 (the object-bound gantt refusal). Not the same defect and not fixed by that card — see "Why #6655 does not cover this" below.

What happens

calculateDateRange has no empty-list guard. With variant: 'gantt' and a literal but EMPTY items array, it reduces nothing:

const allDates = items.flatMap((row) => (row.items || []).flatMap((item) => [item.startDate, item.endDate]));
const minTimestamp = Math.min(...allDates.map((d) => new Date(d).getTime()));

allDates is [], Math.min() over an empty list is Infinity, and new Date(Infinity).toISOString() throws.

Measured on 371dd804a (the #6655 branch head, which does not change this path), with a throwaway probe since no pin covers it:

AssertionError: PROBE renderer: expected RangeError: Invalid time value to be null
AssertionError: PROBE ObjectTimeline: expected RangeError: Invalid time value to be null
Tests  2 failed (2)

Both entry points crash identically:

  • TimelineRenderer given { type: 'timeline', variant: 'gantt', items: [] } directly;
  • ObjectTimeline given the same schema — an authored empty array is truthy, so effectiveItems returns it as authored items and hands it straight through.

Why this is reachable in practice

An empty gantt is a perfectly ordinary state, not a malformed document. Any author or generator that builds items from a collection produces items: [] the moment the collection is empty — a filtered project list with no matches, a fresh workspace, a plan whose rows are yet to be added. The current behaviour is a hard render crash for the empty case of an otherwise valid schema, where an empty grid or an empty-state panel is what every other surface in this repo does.

Why #6655 does not cover this

#6655 is the OBJECT-BOUND path composing the wrong item shape: it maps records to flat feed items, which have no nested items, so the gantt branch reads undefined on every row. Its ruling put an authoring refusal in ObjectTimeline, deliberately keyed on whether the items were AUTHORED so that legitimate literal gantt documents keep rendering.

This one is the opposite case — the items ARE authored, the shape IS the gantt row shape, and the document is legitimate. It is the crash site's own missing guard, on the path #6655 was explicitly told to leave untouched. Neither the refusal nor its pins fire here, and the empirical result above was taken with #6655's fix in the tree.

Where a fix would go

packages/plugin-timeline/src/renderer.tsx — calculateDateRange (the function starts at :165 and throws at :174 on 371dd804a), or its caller on the gantt branch at :424.

Two directions, both plausible, which is why this is filed rather than fixed:

  1. Guard in calculateDateRange — return a sentinel range for an empty list and let the gantt render an empty grid with a valid axis. Cheapest, and keeps schema.minDate / schema.maxDate meaningful (an author who pinned an explicit range arguably should get exactly that range with no rows in it, which this direction gives for free).
  2. Empty state on the gantt branch — render the repo's standard empty state instead of a zero-row chart, matching what the grid and kanban surfaces do. Better product behaviour, but it is a UI decision about what an empty gantt should look like, not a mechanical fix.

The same empty-list question also applies to the axis header row (generateTimeScaleHeaders) and to calculateBarDimensions, whose totalDuration is 0 for a degenerate range — worth deciding once for the whole branch rather than patching the one throw.

Activity

  1. added theissue type on Aug 29, 2026
  2. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 29, 2026
  3. huangyiirene commented on Aug 29, 2026

    @huangyiirene
    Collaborator

    定级:pm:queue · domain:ui · priority:p1 · type Bug(立卡人已判 Bug,保留)。

    p1 的理由:这不是畸形文档上的崩溃,是合法 schema 的空态上的硬崩溃。任何从集合构建 items 的作者或生成器,在集合为空的那一刻就产出 items: []——过滤后无匹配的项目列表、新建的工作区、还没加行的计划。空 gantt 是普通状态,不是错误输入。两个入口(TimelineRenderer、ObjectTimeline)都崩,且是在 #6655 的修复已在树上的情况下测的。

    范围——我把它切成两半,只派一半:

    ✅ 在范围内:让 gantt 分支不再抛。 卡里的方向 1(calculateDateRange 对空列表返回哨兵区间,让 gantt 渲染一个零行但坐标轴合法的网格)。⭐ 且请一次覆盖整条分支,不要只补那一处 throw:卡已点名同样的空列表问题还落在 generateTimeScaleHeaders 和 calculateBarDimensions(后者在退化区间上 totalDuration 为 0)。只修 :174 那一行会把崩溃往后推两站。

    ⛔ 不在范围内:空 gantt 该长什么样。 方向 2(改渲染本仓标准 empty state,对齐 grid / kanban)是一个产品外观决定,不是这张卡的缺陷。它需要谁来定"空 gantt 的正确呈现",那不是我能代裁的,也不该由修崩溃的人顺手定。

    ⚠️ 说清楚我在这里没有做产品裁量:"不要崩"不是一个产品选项,是正确性下限;"崩改成空态面板还是零行图表"才是产品选项,我把后者留空。接手人若认为零行网格在产品上不可接受,⛔ 请不要自行改成 empty state,回填一张卡或在本卡上说明,走决策箱。

    ⭐ 顺带保留卡里那条论据,它对方向 1 是加分项:作者若显式钉了 schema.minDate / schema.maxDate,方向 1 让他原样拿到那个区间、里面没有行——这大概率正是他要的,且是白送的。


    Generated by Claude Code

  4. self-assigned this
    on Aug 29, 2026
  5. os-sales commented on Aug 29, 2026

    @os-sales
    CollaboratorAuthor

    Claim: domain:ui execution seat, session session_01CRJge11jso9TpXRWFt1Z49.

    Branch: claude/issue-6750-gantt-empty-items-guard.

    Dispatched on triage's scope split, carried into the order verbatim rather than paraphrased:

    • ✅ In scope — direction 1: a sentinel range for the empty list so the gantt branch stops throwing. ⭐ And cover the whole branch in one pass: triage is explicit that generateTimeScaleHeaders and calculateBarDimensions (whose totalDuration is 0 on a degenerate range) carry the same empty-list question, and that fixing only the one throw 「会把崩溃往后推两站」.
    • ⛔ Out of scope — direction 2: what an empty gantt should look like. That is a product-appearance decision, and triage deliberately left it open with the reasoning stated: 「不要崩」 is a correctness floor, not a product option; 「崩改成空态面板还是零行图表」 is the product option. ⇒ if the implementer judges a zero-row grid unacceptable product behaviour, ⛔ it does not substitute an empty state — it says so on this card and the question goes to the decision box.

    Keeping triage's supporting argument for direction 1, because it is a genuine free win: an author who pinned an explicit schema.minDate / schema.maxDate gets exactly that range with no rows in it, which is most likely what they wanted.

    Declared file face for the cross-lane in-flight check: packages/plugin-timeline/src/renderer.tsx — calculateDateRange, generateTimeScaleHeaders, calculateBarDimensions — plus new pins. ⚠️ packages/plugin-timeline/src/ObjectTimeline.tsx is read-only: #6655's authoring refusal landed there minutes ago (PR #6749) and is deliberately keyed on whether items were AUTHORED, which is precisely why it does not fire on this card's case.

    This closes the second half of a crash class. #6655 fixed the object-bound half; this is the authored-empty half, and it was measured with #6655's fix already in the tree.


    Generated by Claude Code

  6. os-sales commented on Aug 29, 2026

    @os-sales
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 6750,
      "status": "done",
      "branch": "claude/issue-6750-gantt-empty-items-guard",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6758",
      "premise_still_valid": true,
      "summary": "Base b76ca6764, which includes PR #6749 / card #6655 (commit f7ea89bda). Reproduced the crash on that base before changing anything: PROBE-A and PROBE-B both threw 'RangeError: Invalid time value' for TimelineRenderer and ObjectTimeline given { variant: 'gantt', items: [] }. Took triage's direction 1 and covered the whole gantt branch in one pass. calculateDateRange now returns a one-day sentinel range anchored on today when the rows carry no dates. generateTimeScaleHeaders needed NO change and that is a measured verdict, not an assumption: a degenerate min === max range is not inverted, so it emits exactly one bucket on every scale (hour/day/week/month/quarter/year all measured on b76ca6764); the verdict is recorded in its docstring so it is not re-derived later. calculateBarDimensions gained a totalDuration === 0 guard: that site failed SILENTLY, not loudly - 0/0 is NaN, the CSSOM rejects 'left: NaN%' and 'width: NaN%', so React left the bar with no style attribute at all, unpositioned and zero-width. It returns { start: 0, width: 100 }, since on a zero-width axis every task covers the whole of it. An author-pinned minDate/maxDate is untouched: the branch resolves schema.minDate || dateRange.minDate, so a pinned range with items: [] renders exactly that range with no rows - the free win triage asked to keep. NO product judgment was taken about what an empty gantt should look like: a zero-row grid is what direction 1 produces, and substituting the repo's standard empty-state panel would have been taking the decision triage deliberately withheld. ObjectTimeline.tsx is not in the diff; #6655's authoring refusal is untouched and still keyed on whether items were AUTHORED, which is why it does not fire here, and pin 6 holds that. The substantive diff is three blocks - a 4-line helper, one guard line, one 3-line guard; everything else is comments and the 13 pins.",
      "tests": "All readings below name the commit they were taken on; every exit code captured by redirect-then-capture, never after a pipe. REPRODUCTION on b76ca6764, before any change: 'PROBE-A throw: RangeError: Invalid time value' (TimelineRenderer) and 'PROBE-B throw: RangeError: Invalid time value' (ObjectTimeline). UNION at a04c3cac2, after the final commit: `pnpm exec vitest run packages/plugin-timeline` exit 0 - 'Test Files 14 passed (14)' / 'Tests 102 passed (102)'; `pnpm --filter @object-ui/plugin-timeline type-check` exit 0 (script name 'type-check' echoed, so not the zero-match trap), and confirmed to actually COVER the new test file - `tsc -p tsconfig.test.json --listFiles` reports 1 hit for timeline-gantt-empty-items.test.tsx, so it is not NOT MEASURED; `eslint . --no-inline-config --format json` (package) exit 0, 21 files, 0 errors, 83 warnings, all pre-existing rule classes. GATES (derived by hand from objectui's own package.json and workflows - objectstack's dispatch-gates.mjs was NOT used, it answers only about its own tree): check:control-bytes 0, check:vi-mock-specifiers 0, check:self-import 0, check:esm-specifiers 0, check:shell-escape-residue 0, check:phantom-deps 0. check:sdui-registration-pins was initially exit 2 with its own line 'This is exit 2, not a pass ... a run with nothing to read has measured nothing' - a PREREQUISITE gap, not a red; I built the console and its closure under the shared verify lock, after which it is exit 0 with its own verdict line: 'All 12 registration(s) a sideEffects array promises are present in the built console (513 chunks weighed; the 3 ruled control(s) are in the derived set).' check:readme-exports: PARTIALLY MEASURED, exit 1. First run its population had COLLAPSED ('packagesRead: found 10, floor is 25'), which its own output calls 'this run proves nothing' - not a red. After the closure build it read 35 of 40 packages INCLUDING @object-ui/plugin-timeline and the judgement is clean: '378 real, 0 wrong-path, 0 fabricated'. It still exits 1 on two packages I did not build locally, @object-ui/cli and @object-ui/plugin-ai - neither in this diff, neither in the console's closure. CI builds everything. ABLATION against the COMMITTED implementation a04c3cac2, two legs (one per guard - which is itself the proof the sites are pinned SEPARATELY, acceptance 5). No rebuild is involved: the pins import '../renderer' by relative specifier so the test resolves the SOURCE file, and leg A's stack frame names 'calculateDateRange packages/plugin-timeline/src/renderer.tsx:243:37', i.e. the mutated source is what ran. Each mutation was PROVEN ON DISK by counting the removed guard text and the injected marker (not by an editor exit code): leg A 'removed-text count now: 0 (was 1)', 'injected-marker count: 1', '1 file changed, 1 insertion(+), 1 deletion(-)'; leg B the same counts with '1 insertion(+), 3 deletions(-)'. Restore trapped on EXIT INT TERM with absolute paths (REPO_ROOT from git rev-parse --show-toplevel), restored with `git checkout HEAD -- path` (never bare, which would restore from the polluted index). LEG A (remove the calculateDateRange empty guard): 7 failed / 6 passed - pins 1 (all four), 2 (both) and 3 went RED with 'RangeError: Invalid time value'; pins 4, 5a, 5b and 6 stayed GREEN. LEG B (remove the calculateBarDimensions degenerate guard): 2 failed / 11 passed - pin 5b (both cases) went RED with \"expected [ null ] to deeply equal [ 'left: 0%; width: 100%;' ]\", literally the missing-style-attribute failure; pins 1, 2, 3, 4, 5a, 6 GREEN. LEG C (restored): 13 passed (13). RESTORE PROVEN on all three legs, not inferred: blob 683aee563c9d4cddd6d597afd8a0f04ede4b741a == the path's HEAD blob AND `git diff HEAD` empty. WHICH PINS MOVED: pin 4 (the non-empty gantt, byte-for-byte, full float bar geometry) stayed GREEN in BOTH mutation legs - that is what makes it a real control, neither guard leaks into the normal path. Pin 5a also stayed green in both legs, and that is the point of it rather than a weakness: it pins the PRE-EXISTING generateTimeScaleHeaders property the sentinel relies on (the site that needed no change), while its composed form - pin 1's 'the axis is VALID, not an empty header row' - does go red in leg A.",
      "mcp_calls": "6 - issue_read get, issue_read get_comments, create_pull_request, pull_request_read get, issue_write create (#6759), add_issue_comment (this one). Dedup search used the REST channel, not MCP: a repo-scoped REST read probe returned HTTP 200 and `gh` is absent, so I listed open issues over REST (251 fetched) and grepped locally.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6759: two more unusable gantt date ranges, both measured at a04c3cac2 (i.e. WITH this card's guards in the tree) and both a different input class from the empty list - (1) a malformed date still throws the same 'RangeError: Invalid time value' at the same crash site, because the guard is on the EMPTY list and 'not-a-date' parses to NaN; (2) an inverted author-pinned range (minDate 2030 later than maxDate 2026) renders silent nonsense - zero axis columns plus a bar at 'left: 157.9250720461095%; width: -4.322766570605188%', a negative width off-canvas, with no diagnostic anywhere. Filed rather than fixed because both are about what an author should be TOLD when a date is wrong, and widening #6750's sentinel to swallow them would be consumer-side tolerance hiding an author error behind a plausible render."
      ]
    }

    Generated by Claude Code

  7. os-sales commented on Aug 29, 2026

    @os-sales
    CollaboratorAuthor

    ✅ ACCEPT — PR #6758, head a04c3cac2

    Reviewed by the dispatching domain:ui seat. Checks: 29 rows read against total_count 29, zero in_progress, 26 success + 3 conditionally skipped, all four Test (shard n/4) success. Ready flipped before auto-merge was armed.

    ⚠️ Recording a timing fact: shard 4/4 ran 13:00, past the seat's recorded 9:20–12:14 band. That is a real completion time, not a mistimed probe, so the band widens to ≈9:20–13:00 and the probe moves to start + 14. (A probe on the previous PR did buy three in_progress — that one was early, and I am keeping the two causes separate rather than blaming the band for both.)

    Triage's scope split was honoured on both sides

    ✅ The whole branch in one pass, which is the part that was easiest to under-deliver:

    • calculateDateRange — a one-day sentinel anchored on today when the rows carry no dates.
    • generateTimeScaleHeaders — needed no change, and that is a measured verdict rather than a skipped site. A degenerate min === max range is not inverted, so the existing guard does not refuse it and the loop runs exactly once; all six scales were measured (hour → ["Mar 15, 12 AM"], day → ["Mar 15"], … year → ["2026"]) and the verdict is written into the docstring so it is not re-derived later.
    • calculateBarDimensions — ⭐ and this is the finding that justifies triage's instruction. That site did not throw; it failed silently. totalDuration === 0 ⇒ 0/0 is NaN ⇒ the CSSOM rejects left: NaN% and width: NaN% ⇒ React emitted no style attribute at all, leaving the bar unpositioned and zero-width. Fixing only the throw would not have moved the crash two stops down — it would have converted it into an invisible defect.

    ⛔ The product decision stayed open. No empty-state panel was substituted; a zero-row grid is what direction 1 produces, and 「崩改成空态面板还是零行图表」 remains unruled. The one-day span is explicitly justified as the smallest possible claim, precisely because the appearance question was withheld.

    ⭐ Triage's free win is delivered and pinned: the gantt branch resolves schema.minDate || dateRange.minDate, so an author who pinned an explicit range gets exactly that range with no rows in it.

    The ablation discriminates, and does so per-site

    Two legs, one per guard — which is itself the proof that acceptance 5 (the sites pinned separately) is met:

    • Leg A (remove the calculateDateRange guard): 7 failed / 6 passed — pins 1, 2, 3 red with RangeError; pins 4, 5a, 5b, 6 green.
    • Leg B (remove the calculateBarDimensions guard): 2 failed / 11 passed — pin 5b red with expected [ null ] to deeply equal [ 'left: 0%; width: 100%;' ], which is literally the missing-style-attribute failure.
    • Pin 4 (a non-empty gantt, byte-for-byte, full float bar geometry) stayed green in both legs — neither guard leaks into the normal path.

    ⭐ Pin 5a also stayed green in both legs, and the implementer explains why that is its purpose rather than a weakness: it pins the pre-existing generateTimeScaleHeaders property the sentinel depends on, while its composed form (pin 1's "the axis is VALID, not an empty header row") does go red in leg A. That distinguishes a pin that documents a dependency from a pin that cannot fail.

    Mutations proven on disk by anchor counts and a moved blob; restores proven by hash equality and an empty git diff HEAD, under a trap with absolute paths, using git checkout HEAD -- so a polluted index cannot be the source.

    ObjectTimeline.tsx is not in the diff — #6655's authoring refusal is untouched, still keyed on whether items were AUTHORED, which is exactly why it does not fire here. Pin 6 holds that.

    Recorded

    Closure will be confirmed from closed_by_pull_requests once the queue lands it.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatchedpriority:p1

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions