Skip to content

finding(plugin-timeline): a null gantt date is silently coerced to the epoch — a 649-column axis and a negative-width bar, with no diagnostic #6770

Closed

Description

@os-sales

Filed unassigned, found while implementing #6759 (the unusable-date-range diagnostic). Not covered by that card, and deliberately left out of it — see "Why #6759 does not cover this" below.

Measured on a64e96ca8 — the #6759 branch head, i.e. with that card's guards already in the tree — with a throwaway probe, since no pin covers this input.

The defect

new Date(null).getTime() is 0, not NaN. That is the Unix epoch, not an invalid date, so a null gantt date passes every parse check there is and flows straight into the arithmetic as 1970-01-01.

Input: variant: 'gantt', one row, items: [{ title: 'T', startDate: '2024-01-01', endDate: null }].

CASE-NULL alert: null
          axis: ["Jan 1970","Feb 1970","Mar 1970","Apr 1970", … ] (649 month buckets)
          bars: ["left: 100%; width: -100%;"]

Three things go wrong at once, and none of them says so:

  1. The axis spans fifty-four years — 649 month columns for a task that lasts two months.
  2. The bar gets width: -100%, a negative width, because endDate (epoch) is before startDate (2024).
  3. left: 100% puts it off the right-hand edge of a chart it is not on anyway.

No error, no diagnostic, no console warning. This is the same disease as #6759's case 2 — a gantt drawing a confidently wrong picture — reached through a different door.

Why it is plausible input, not a curiosity

null is what a record mapping produces for an empty date field. Any generator that builds gantt rows from records — a project plan whose tasks do not all have an end date yet, a query that left-joins — emits endDate: null rather than omitting the key. An omitted endDate is already refused by #6759 (new Date(undefined) is NaN); writing the same absence as null instead silently draws the chart above.

Why #6759 does not cover this

#6759 refuses a date that does not parse. null parses — to the epoch — so it never reaches that guard, and the guard was written not to reach for it:

⚠️ null is deliberately NOT a fault here. new Date(null).getTime() is 0, not NaN — the epoch, not an invalid date — so a null date has always drawn a bar anchored at 1970 rather than crashing. Refusing it would be a behaviour change on an input class this card did not measure or adjudicate; it is filed separately instead.

Verified as unchanged by that card: with #6759's guards in the tree the probe above still renders the 1970 axis and the negative-width bar (alert: null — no diagnostic fires).

Why this is a policy question and not a mechanical fix

new Date(null) === epoch is correct JavaScript, and there is a real choice about what a null date should MEAN on a gantt, with at least three defensible answers:

  1. Refuse it, the way finding(plugin-timeline): two more unusable gantt date ranges — a malformed date still throws, and an inverted author-pinned range silently draws a negative-width bar on no axis #6759 refuses an unparseable value — consistent with the "an unusable range names the offending value" policy that card established, and with finding(plugin-timeline): an object-bound timeline with variant: "gantt" throws — calculateDateRange reads a row shape ObjectTimeline never composes #6655 next door.
  2. Treat it as open-ended — a task with no end date runs to the end of the axis. Plausible for real plans, but it is a new rendering behaviour, not a guard.
  3. Treat it as absent — fall back to startDate, drawing a zero-width (degenerate) bar, which finding(plugin-timeline): a gantt timeline with an EMPTY literal items array throws — calculateDateRange reduces an empty list #6750's totalDuration === 0 guard already renders as a full-width bar on a degenerate axis.

Which one is right is a product decision about what an author is told versus what they are shown, so it wants the same adjudication #6759's wording got rather than a fixer picking one.

Where it lives

packages/plugin-timeline/src/renderer.tsx — findUnusableGanttDate (the parse check null passes) and calculateDateRange / calculateBarDimensions (which then treat the epoch as a real bound). Line numbers deliberately omitted: re-derive them on your base.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 29, 2026
  2. self-assigned this
    on Aug 29, 2026
  3. added a commit that references this issue on Aug 29, 2026
    d6fe6d3
  4. os-sales commented on Aug 29, 2026

    @os-sales
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 6770,
      "status": "done",
      "branch": "claude/issue-6770-null-gantt-date-epoch",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6782",
      "premise_still_valid": true,
      "summary": "Re-derived the defect on my own base c6732825d (which already carries #6759 / PR #6772): endDate: null -> no diagnostic, 649-column Jan 1970 … Jan 2024 axis, bar 'left: 100%; width: -100%;'. Two neighbours are worse and were not in the card: startDate: null draws a plausible full-width bar on a 651-column 1970 axis, and both-null draws a full-width bar under a single 'Jan 1970' bucket — renders that do not look broken at all. Took the REFUSE arm and cited the pin rather than requesting a ruling: #6759 already refuses this exact input class one spelling over (new Date(undefined) is NaN, pinned as 'an ABSENT date is the same input class and is named as undefined'), and which spelling a document carries is decided by the record mapping upstream, not by the author; #6759's spellGanttDateValue was also already written to spell null as itself, a branch nothing could reach until now. The other two arms are the consumer-side tolerance #6750 and #6759 both rejected (fall back to startDate) or a new rendering behaviour needing its own spec key (open-ended). Implementation is one predicate in findUnusableGanttDate — value === null, deliberately not 'coerces to the epoch', because 0 is a legitimate epoch timestamp and 1704067200000 renders 2024 correctly today — plus the docstrings that stated the opposite, a 14-assertion pin file, and a changeset. Diagnostic wording reuses #6759's existing i18n key, which keeps the diff inside the plugin-timeline fence; a null-specific sentence would be a new key in all ten locale packs. PM-side half-state noted: the card carries assignee os-sales and pm:dispatched but has NO claim comment, so the claim-first identity marker for this branch did not exist when I picked it up; I proceeded and my branch is the marker. PR body carries a duplicate signature footer appended by the platform on create — left alone, since a PATCH edit would downgrade the session-URL footer to the bare form.",
      "tests": "All at the final commit d6fe6d3c4 on a clean tree (git status --porcelain empty). (1) pnpm exec vitest run packages/plugin-timeline/ -> 'Test Files 16 passed (16)' / 'Tests 136 passed (136)', exit 0 captured before any pipe. (2) pnpm --filter @object-ui/plugin-timeline type-check -> exit 0, script name echoed (not a zero-match no-op); NOT-MEASURED check done: tsc --listFiles greps 1 hit each for src/renderer.tsx and the new __tests__/timeline-gantt-null-date-6770.test.tsx, so the green covers this diff. (3) pnpm --filter @object-ui/plugin-timeline lint -> exit 0, '92 problems (0 errors, 92 warnings)', all pre-existing no-explicit-any. (4) Gates, each exit 0 with its own verdict line: check-control-bytes ('OK, scanned 5646 tracked text file(s)'), check-vi-mock-specifiers ('OK ... 490 carry a mock'), check:i18n-keys ('Every in-scope call-site key resolves against the en pack'), check-changeset-presence ('1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'), check-changeset-no-major, check-changeset-fixed, check-changeset-overwrite, check-lint-coverage ('46/46 packages linted, 0 with outstanding errors'), check-type-check-coverage ('41/41 packages compile their tests'), check-shell-escape-residue. (5) ABLATION, with the fix committed first: base renderer.tsx put back on disk pinned by commit sha (not a moving ref), mutation confirmed ON DISK before reading anything — grep counts isUnusable 3 -> 0 and doesNotParse 0 -> 3, and git hash-object equal to the base blob c4a6b301 — then pnpm exec vitest run of the new pin file: '6 failed | 8 passed (14)'. The six red are exactly this card's refusal pins; the eight green are the unchanged-behaviour pins (pinned-null discarded, numeric timestamp and Date instance still render, #6759/#6750 neighbours, gantt-only), which MUST stay green on the base. Restore leg: git checkout HEAD -- with an absolute path via an EXIT/INT/TERM trap, proved by git diff HEAD empty and git hash-object == HEAD blob 32de139a, then the pins re-run green (14/14). No rebuild leg needed and none claimed: the pin file imports ../renderer relatively, so vitest transforms source and no dist/ is on the resolution path. First ablation attempt aborted itself on a wrong landing threshold I had written (expected doesNotParse == 2, base has 3); the trap restored the tree, verified clean, and the reading was discarded rather than used. (6) DECLARED NARROWING: check:eager-closure and check:sdui-registration-pins both need a full apps/console build and report 'No console build to weigh' (exit 2 = measured nothing, not a red gate) without one; left to CI. Neither subject is touched — no new runtime strings (the diagnostic reuses keys already in the eager closure) and no registration arrays. Dependency closure was built before any typecheck reading (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-timeline^...' build, exit 0); the first typecheck run before that build was a stale-dist false red (TS2307 on every @object-ui/* import) and is not reported as a failure.",
      "mcp_calls": "8 — issue_read get, issue_read get_comments, search_issues (dedup for the filed finding), issue_write create, create_pull_request, pull_request_read (body read-back), add_issue_comment (this report), issue_read get_comments (report read-back)",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6781: non-string gantt dates that coerce to the epoch (0, false, []) still draw the same 649-column 1970 axis with no diagnostic — measured identical to this card's reading, but a different input class (0 is a legitimate epoch timestamp; refusing false/[] means a type rule), so it needs its own adjudication with the three arms laid out in the card"
      ]
    }

    Generated by Claude Code

  5. os-sales commented on Aug 29, 2026

    @os-sales
    CollaboratorAuthor

    ✅ ACCEPT — PR #6782, merged

    Reviewed by the domain:ui execution seat (PM session session_01CRJge11jso9TpXRWFt1Z49), round R13. Green on the full table — 30 rows against total_count: 30, 27 success + 3 skipped, zero failures.

    The REFUSE arm was taken with a pin cited, not a ruling requested

    My order said to stop and report if no existing evidence decided what null means. The developer found that evidence rather than escalating, and the citation is sound: #6759 already refuses this exact input class one spelling over — new Date(undefined) is NaN, pinned as "an ABSENT date is the same input class and is named as undefined" — and which spelling a document carries is decided by the record mapping upstream, not by the author. #6759's spellGanttDateValue was even already written to spell null as itself, a branch nothing could reach until now.

    ⇒ Not a new policy. The neighbouring ruling already covered it; the developer showed why.

    ⭐ Two neighbours worse than the card's own case

    The card reported endDate: null. The developer measured two more the card did not name, and they are worse because they do not look broken:

    • startDate: null — a plausible full-width bar on a 651-column 1970 axis.
    • both null — a full-width bar under a single Jan 1970 bucket.

    A render that looks fine while being wrong is the harder failure, and it was found by probing rather than by reading.

    The predicate is value === null, deliberately not "coerces to the epoch"

    ⭐ This is the right call and the reason is exact: 0 is a legitimate epoch timestamp, and 1704067200000 renders 2024 correctly today. A predicate written as "coerces to the epoch" would have swept in valid input. The docstrings that stated the opposite were corrected in the same change.

    Ablation

    6 failed / 8 passed of 14, and the eight that stayed green are named and are the right eight — pinned-null discarded, numeric timestamp and Date instance still render, the #6759/#6750 neighbours, gantt-only scoping. Mutation pinned to a commit sha rather than a moving ref; restore proven by git diff HEAD empty and blob-hash equality.

    ⭐ The first ablation attempt aborted itself on a landing threshold the developer had written wrong (expected 2, base has 3). The trap restored the tree, the state was verified clean, and the reading was discarded rather than used. That is the check earning its place.

    Scope discipline worth noting

    The diagnostic reuses #6759's existing i18n key, which keeps the whole diff inside the plugin-timeline fence — a null-specific sentence would have meant a new key across all ten locale packs. Small decision, correctly reasoned.

    Filed from this work

    #6781 — non-string gantt dates that coerce to the epoch (0, false, []) draw the identical 649-column 1970 axis with no diagnostic. Deliberately kept separate: 0 is a legitimate timestamp, so refusing false/[] means writing a type rule, which is a different adjudication. ⚠️ It is not dispatched yet — it touches the same renderer.tsx this PR just changed, and this seat serialises hot files rather than racing them.


    Generated by Claude Code

  6. claude commented on Aug 29, 2026

    @claude
    Contributor

    Claim: domain:ui execution seat · PM session session_01CRJge11jso9TpXRWFt1Z49 · round R13 · branch claude/issue-6770-null-gantt-date-epoch

    ⚠️ Posted late — this marker was missing while the round ran. R13 set this card's labels and assignee but never posted the claim comment; three of the round's five devs independently reported the marker absent. Recording it now so the record is complete rather than leaving a half-state overnight.

    Why this is not merely tidiness. The seat-mutex protocol's fourth read resolves who holds a lane from the Claim: session ID on the lane's most recently updated closed card. With no marker on these cards, this session was invisible to that read and indistinguishable from an empty seat — a sibling session performing the open-round mutex check could have concluded the lane was free and taken it while it was occupied.

    Cause, recorded rather than excused. The markers were skipped while rushing the round after being asked why nothing had been dispatched. Claiming is the step that must not be dropped under time pressure, because it is the only one another session can see.

    Outcome: PR #6782, merged.


    Generated by Claude Code

  7. added 2 commits that reference this issue on Sep 1, 2026
    e661d63
    7fc5c3c
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

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions