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.

plugin-gantt: the toolbar period label names the timeline RANGE START, not the visible window — it reads "January 2026" over columns showing late August, and the band header one row lower says "Aug 2026" #7203

Closed

Description

@os-warren

Found in a browser pass over a real application's gantt view (objectstack-ai/duly#62). Filed unassigned, not claiming.

Measured on @objectstack/console 17.2.0 (objectui 190fbd01d061) in Chromium 1194 at 1440x900. Re-checked against current main 84ffdbcbb33762b5488742fb902faf45a3742e93: the expression below is unchanged there, so this is not a version-lag report.

Symptom

The toolbar reads January 2026 while the timeline underneath it shows 26 Aug through 6 Sep 2026, Today marker on 1 September.

toolbar says January 2026 while the columns show late August

Cause

packages/plugin-gantt/src/GanttView.tsx — the toolbar span formats the range start:

<span className="font-semibold text-xs sm:text-sm">
  {timelineRange.start.toLocaleDateString(dateLocale, { month: 'long', year: 'numeric' })}
</span>

timelineRange is the memo that spans the whole dataset. With no startDate prop it resolves to min(task.start) minus 7 days, snapped to the period; the end is max(task.end) plus 14 days. So the label names the first month of the entire dataset, and it cannot change while the chart is scrolled because it is not derived from scroll position at all.

The app in the screenshot holds tasks from 31 Jan to 31 Dec 2026, so this label sits on "January 2026" for every scroll position of an eleven-month timeline. On a first read it looks like the chart is showing January and the columns are mislabelled — the truth is the other way round.

The renderer already computes the right answer one row lower

Read from the DOM in the same frame as the screenshot:

element value
toolbar period label January 2026
band header, data-testid="gantt-header-groups" Aug 2026, Sep 2026
unit columns, data-testid="gantt-header-units" 26W 27T 28F 29S 30S 31M 1T 2W 3T 4F 5S 6S

The band header is already a viewport-derived month label and it contradicts the toolbar directly above it. Whatever the toolbar is meant to say, two month labels four pixels apart disagreeing is the part a user sees.

Related, same toolbar: the two buttons beside the label are inert

The prevPeriod / nextPeriod buttons render an aria-label and an icon and carry no onClick:

<Button variant="ghost" size="icon" className="h-8 w-8" aria-label={t('gantt.toolbar.prevPeriod')}>
  <ChevronLeft className="h-4 w-4" />
</Button>

So the obvious recovery — "this says January, let me page forward to the month I am looking at" — does nothing, with no diagnostic. This is arguably the same defect: a period label and a pair of period-stepper buttons that are not wired to a period.

Reproduction

Any gantt whose dataset spans more than one month, scrolled anywhere other than its first month. The chart auto-scrolls to Today on load, so a dataset with history reproduces it on first paint with no interaction:

  1. A gantt view over rows whose start dates run from January to December of the current year.
  2. Load it. The chart lands on Today.
  3. Toolbar reads the January label; the columns and the band header read the current month.

Suggested direction — maintainer's call, not proposed as decided

Deriving the label from the visible window is the fix that makes the toolbar agree with the band header: the scroll offset already maps to a date (the pixel-to-date binary search used by the drag handlers), so the leftmost visible column's month is available without new state. The two stepper buttons then have something to drive — scroll by one unit of the current granularity.

The alternative reading is that the label is meant to describe the whole range, in which case it should say so (a start-to-end range, not a single month) and the stepper buttons should go. Either way the current pairing cannot be right.

Related: #7070 (gantt/timeline date-axis findings), #5132 (GanttViewMode doc line).

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 1, 2026
  2. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    Triage — bug · domain:ui · priority:p2 · pm:queue

    Graded by the domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM). Queued behind three running lanes.

    The A/B is narrower than the card presents it

    The card offers two readings and declines to choose. But they are not symmetric, because ADR-0049 (enforce-or-remove) already disposes of the stepper half independently of the label half. Two prevPeriod / nextPeriod buttons that render an aria-label and an icon and carry no onClick are a control that does nothing. Under ADR-0049 they must either work or go — "leave them inert" is not one of the available outcomes, whichever way the label is settled.

    That collapses the decision to: does the toolbar label name the visible window (steppers get wired, because they now have a period to step) or the whole range (steppers get removed, because a range has no next)?

    Recommendation: the visible window. Three reasons, in descending strength:

    1. The renderer already computes it. data-testid="gantt-header-groups" reads Aug 2026 / Sep 2026 from the viewport, four pixels below a toolbar reading January 2026. One of these two is already right, and it is not the toolbar.
    2. No new state is needed. The card notes the scroll offset already maps to a date through the pixel-to-date binary search the drag handlers use.
    3. It preserves two affordances rather than deleting them, and the deletion is the harder change to reverse.

    Still flagged for a maintainer, because it is the card's own framing and I would rather it be confirmed than assumed — but the dispatch will not wait on it if a lane frees, since the recommended shape and ADR-0049 agree.

    One detail worth keeping

    The label cannot change while the chart is scrolled, because it is not derived from scroll position at all — it formats timelineRange.start, the memo spanning the whole dataset. On the reporter's data (31 Jan to 31 Dec 2026) it therefore reads January 2026 at every scroll position of an eleven-month timeline.

    And the failure is on first paint with no interaction, because the chart auto-scrolls to Today on load. So any dataset with history reproduces it immediately — there is no "scroll away and it corrects itself" state to mistake for correct behaviour.

    The card's sharpest line is the one to carry into the fix: "On a first read it looks like the chart is showing January and the columns are mislabelled — the truth is the other way round." The defect is not merely a wrong string; it actively discredits the part of the UI that is correct.

    ⚠️ Note on the quoted snippets

    The JSX blocks in this body have been eaten by GitHub's angle-bracket sanitizer — the toolbar span and the stepper-button block both render as blank or near-blank fences. A < followed by a letter is stripped, and inline backticks do not protect it. Nothing is wrong with the report; the element names are simply not in it. Read the real GanttView.tsx, do not reconstruct tags from the gaps. This is the fifth body on this seat to lose evidence this way.


    Generated by Claude Code

  3. self-assigned this
    on Sep 1, 2026
  4. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 7203,
      "status": "done",
      "branch": "claude/issue-7203-gantt-toolbar-period-label",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7227",
      "premise_still_valid": true,
      "summary": "Verified the card's premise against origin/main before writing anything: the toolbar span really did format `timelineRange.start` and the two stepper Buttons really did carry no onClick. The label now names the period at the left edge of the viewport, snapped to the same tier `headerGroups` bands by, and the steppers scroll that window one period at that tier. The band header is untouched. SCROLL-TO-DATE MAPPING WAS REACHABLE, and more cheaply than assumed: `xToDate` (the drag handlers' pixel-to-date binary search) is defined ~1200 lines above the toolbar in the same component, and `scrollPos` is ALREADY React state that `handleScroll` updates - so the label re-renders on scroll with zero new state. Two refinements the card could not have known: (a) `visibleRange`, the helper the virtualization windows use, compares `offsets[i+1] < from` strictly, so at an exact period boundary it names the PREVIOUS cell - correct as overscan, wrong for 'which period owns the left edge' - so I used the column binary search instead; (b) the label must snap the owning COLUMN's start date, not the interpolated instant under the pixel, because a week column can straddle a month boundary and `headerGroups` keys such a column by the month its start falls in. Interpolating there would have made the toolbar and the header disagree for part of every straddling week - the exact defect class being fixed. GRANULARITY: I read 'one unit of the current granularity' as the LABEL'S TIER, not the column unit - day/week step one month, month/quarter one year, year one decade, shift-segmented day one shift-day. Every viewMode has a natural unit at that tier, so nothing was invented. The literal column-unit reading is also well-defined but self-defeating: one month column in month view leaves the label unchanged for eleven clicks out of twelve, so the buttons would still not visibly drive the label they sit beside. Pinned in a test and documented in the README and the PR body. Also un-exported the four new period helpers (no consumer outside the module, and `index.tsx` never re-exported them) so the package's public surface is byte-identical to origin/main.",
      "tests": "All at the pushed head `e1facd916`, in worktree ../objectui-7203, every heavy run through the shared verify lock. (1) `pnpm exec vitest run packages/plugin-gantt/` from the REPO ROOT -- `Test Files 56 passed (56)` / `Tests 448 passed (448)`; my new file adds 1 file / 7 tests over the 55/441 baseline. (2) `pnpm --filter @object-ui/plugin-gantt run type-check` -- exit 0, output echoed both `tsc --noEmit` and `tsc -p tsconfig.test.json`, so the new test file IS type-checked (not the exclude-the-tests trap). It needed `pnpm --filter '@object-ui/plugin-gantt^...' build` first; before that build it exited 2 with 20 TS2307 module-not-found lines -- recorded as NOT MEASURED, not as a red gate. (3) Cross-package suites that read GanttView (3 in @object-ui/i18n, 1 in @object-ui/plugin-view) -- 4 files / 83 tests passed. (4) Gates: check:control-bytes OK (5988 files), check-changeset-presence OK (1 changeset for 2 changed published sources), check:i18n-keys / check:doc-fences / check:vi-mock-specifiers / check:vi-mock-inherit exit 0, check:governed-queue-guard --test on the 4 changed paths says NOT GOVERNED. check:i18n-dead-keys prints 'This is a REPORT, not a gate' and is recorded as a report, no colour. check:readme-exports exits 1 on its OWN population-collapse banner ('this run proves nothing', 24 of 40 packages unbuilt) -- NOT MEASURED, and measurably inert to this diff: README adds 0 fenced blocks (38 before, 38 after) and 0 import lines, src/index.tsx byte-identical to origin/main, GanttView.tsx exported-symbol set identical to origin/main. (5) eslint plain form (never --no-inline-config) over the whole packages/plugin-gantt directory, population enumerated by eslint's own config: 86 files, 0 errors, 323 warnings; GanttView.tsx 0 errors / 19 warnings BOTH before and after, measured by checking the origin/main blob in place under a trap. Repo-level `turbo run lint` declared-narrowed: the config extends tseslint.configs.recommended with no parserOptions.project and no projectService, so type-aware linting is off and a file's verdict depends only on its own text -- a two-file diff cannot move an untouched file's verdict. (6) ABLATIONS, direction AND counts predicted before running. NO REBUILD LEG EXISTS AND THAT IS LOAD-BEARING, not skipped: the test imports './GanttView' relatively and the root vitest config aliases @object-ui/plugin-gantt to packages/plugin-gantt/src, so no dist/ is on the resolution path and the mutation is read straight from source. Mutation proven on disk BOTH ways plus blob hash each time, restore proven by STATE. A: label reverted to timelineRange.start -- predicted 5 failed / 2 passed, measured `Tests 5 failed | 2 passed (7)`, and the five are the predicted five; markers injected=1 removed=0, blob 5e1dc634->300a920f. The 2 that stay green are the single-month control and the left-edge clamp, the only two a static January label satisfies. B: both onClick handlers removed -- predicted 2 failed / 5 passed, measured `Tests 2 failed | 5 passed (7)`; markers injected=2 removed=0, blob 5e1dc634->4cba8483. After each: `git diff HEAD`, `git diff --cached`, `git status --short` all empty and blob back to 5e1dc634, restored via `git checkout HEAD -- ABSOLUTE_PATH` (never the bare form) under `trap ... EXIT INT TERM` with an absolute REPO_ROOT. (7) REAL BROWSER, because happy-dom reports clientWidth 0 and therefore NEVER FIRES the auto-scroll-to-Today effect -- which is the card's whole first-paint repro, and is unmeasurable in the DOM harness. Preinstalled Chromium at /opt/pw-browsers (no playwright install), 1440x900, package demo ?perf=2000, real clientWidth 1106. AFTER: first paint auto-scrolled to scrollLeft 26576, toolbar 'August 2026', band 'Aug 2026', unit columns 28F 29S 30S 31M 1T 2W 3T; next click -> 27060 / 'September 2026' / 'Sep 2026'; prev click -> 23650 / 'August 2026' / 'Aug 2026'; scrolled to 0 -> 'December 2025' / 'Dec 2025'. Agreement true at all four. BEFORE (same page, label expression reverted in place under the same trap discipline, marker+hash proven): toolbar 'December 2025' at ALL FOUR positions -- agreement false, false, false, true, and the label never moved. (8) `git merge-tree --write-tree --name-only origin/main HEAD` exit 0 against f626808d4 (main moved once mid-task, #7220, no overlap with plugin-gantt).",
      "mcp_calls": "8",
      "open_questions": [
        {
          "question": "ZONE 1 point 2 says the steppers move the window by 'one unit of the current granularity'. I read that as one unit of the LABEL'S tier (month under day/week, year under month/quarter, decade under year, shift-day under segmented day) rather than one COLUMN of the active viewMode. Confirm, or say the column unit was meant.",
          "options": [
            "A - label tier (shipped): one click always changes what the label says; in month view one click moves a year.",
            "B - column unit: one click moves one day in day view and one month in month view, so the label is unchanged for 29 of 30 clicks in day view and 11 of 12 in month view."
          ],
          "recommendation": "A, and it is already shipped and pinned. B satisfies the words but not the purpose the ruling gives them - the wiring exists BECAUSE the label change gave the buttons something real to drive, and under B they mostly do not drive it. A also needs no per-viewMode invention: every mode has a natural tier unit. If B is actually wanted it is a small change (swap addPeriods for addUnits at the viewMode) plus one test edit."
        }
      ],
      "out_of_scope_findings": [
        "filed as #7228: `columnWidthForContainer` in GanttView.tsx branches on container width and returns 110 from all three arms - the breakpoint table it documents cannot change the result, while the two sibling sizing helpers right beneath it use the same breakpoints and do vary. Labelled `finding` + `domain:ui`, unassigned; searched open issues first (one targeted MCP search after a repo-scoped REST probe returned 403 and `gh` was absent - channel switch declared), 15 hits returned including #7204 / #7224 / #7221, none of them this."
      ]
    }

    ZONE 2 assumptions, one line each

    1. timelineRange is the whole-dataset memo, min(task.start) minus 7d snapped to the period, end max(task.end) plus 14d with no startDate prop — confirmed exactly, read off the memo.
    2. The scroll offset already maps to a date and no new state is needed — confirmed, and understated. xToDate is in the same component ~1200 lines above the toolbar, and scrollPos is already React state that handleScroll writes, so the label is reactive for free.
    3. The band header is genuinely viewport-derived — FALSIFIED in mechanism, upheld in conclusion. headerGroups computes bands for the WHOLE timeline and absolutely positions them at their true offsets; it is merely virtualized by groupWindow, with 240px of overscan. It is right at every scroll position because it is positionally right, not because anything computes a current month. So there was no existing visible-period answer to read — it had to be derived, from the same column source the header keys by. This changed the shape of the fix, not its direction.
    4. "One unit of the current granularity" is well-defined for every viewMode — confirmed under the tier reading, and no mode needed an invented unit. See the open question for the alternative reading.
    5. The label and the band header should show the same thing — confirmed and shipped. They now name the same period at the same tier; only the wording differs in the month tier, where the toolbar keeps the fuller "August 2026" beside the header's "Aug 2026". The card's alternative (label the whole range, delete the steppers) was not taken, per ZONE 1.

    Nothing was copied from the order or the card into code without measuring it first — every line number, path and expression in the PR body was read off origin/main or off a run.


    Generated by Claude Code

  5. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    Landed — 231d1b93c, verified by content with a live control

    PR #7227 merged. Landing note from the domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM).

    probe main before main after
    addPeriods 0 2
    visiblePeriodStart 0 6
    grep -c '^export ' on GanttView.tsx — live control 18 18

    Both probes were verified to discriminate before use — checked to be 0 on main and non-zero on the branch. That mattered: my first attempt probed scrollPos / xToDate, which already exist on main (that is precisely why the lane could reuse them), so it would have reported this PR as landed while it sat unmerged.

    The export-count control confirms the four new period helpers stayed un-exported, so a repair did not quietly become a permanent compatibility commitment.

    What closed

    The toolbar now names the period at the left edge of the viewport, snapped to the same tier the band header bands by, and the two stepper buttons scroll that window one period at that tier. Previously the label formatted timelineRange.start — the whole-dataset memo — so on an eleven-month dataset it read January 2026 at every scroll position, four pixels above a band header reading Aug 2026.

    The steppers carried no onClick at all; ADR-0049 (enforce-or-remove) settled that half independently of the label question.

    ⭐ The catch that made this a real fix rather than a plausible one

    The obvious implementation — interpolate the date under the left-edge pixel — passes any test written at a non-boundary scroll position. It is also wrong:

    a week column can straddle a month boundary, and headerGroups keys such a column by the month its start falls in

    So interpolating would have made the toolbar and the header disagree for part of every straddling week — the exact defect this card exists to fix, surviving in a window far harder to notice than "January over August columns". The lane snapped the owning column's start instead.

    It also rejected the existing visibleRange helper for a stated reason: its strict offsets[i+1] < from comparison names the previous cell at an exact boundary — correct as virtualization overscan, wrong for "which period owns the left edge".

    The measurement was impossible in the DOM harness

    happy-dom reports clientWidth 0 and therefore never fires the auto-scroll-to-Today effect — which is this card's entire first-paint reproduction. Not "unreliable there": unmeasurable. A lane asserting green from the DOM tests alone would have been measuring a page that never scrolled.

    Real Chromium, four scroll positions, with the before leg run on the same page under the same trap-and-hash discipline:

    • after — toolbar and band agree at all four positions
    • before — toolbar read December 2025 at all four; agreement false, false, false, true

    That single accidental agreement is the point: at scrollLeft 0 a static range-start label is correct, so a one-position test would have passed against the broken code.

    One question answered rather than assumed

    "One unit of the current granularity" was read as the label's tier (month under day/week, year under month/quarter, decade under year) rather than one column of the active viewMode — raised as an open question with the evidence, and confirmed. The column-unit reading satisfies the words and defeats the purpose: in month view one click would move one month, leaving the label unchanged for 11 of 12 clicks, and the steppers were wired precisely because the label gave them something to drive.

    Filed alongside

    #7228 — columnWidthForContainer branches on container width and returns 110 from all three arms, so the breakpoint table it documents cannot change the result, while the two sibling sizing helpers beside it use the same breakpoints and do vary. Blocked deliberately behind the #5560 gantt-width decision: it is the same family of helpers as #7204 / #7224, and repairing it first risks doing it twice or in a shape the decision contradicts.

    Closing. pm:dispatched cleared.


    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

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions