Skip to content

Gantt: a plan shorter than the pane stretches to fill it end to end - #122

Merged
ib823 merged 1 commit into
mainfrom
claude/codebase-audit-optimization-p6i6co
Aug 9, 2026
Merged

Gantt: a plan shorter than the pane stretches to fill it end to end#122
ib823 merged 1 commit into
mainfrom
claude/codebase-audit-optimization-p6i6co

Conversation

@ib823

@ib823 ib823 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Why

On the Timeline view, a short plan renders as a narrow sliver of chart with the rest of the frame dead white — and switching to a coarser grain shrinks it further (a 4-week plan is ~235px at Week zoom, 32px at Quarter). Expected: the timeline always covers the frame end to end, and the zoom control only changes date density.

Root cause

The chart width was totalDays × PX_PER_DAY[grain] — a fixed density with no relation to the viewport, applied independently in the canvas, the axis, and the milestone layer.

Fix

  • effectivePxPerDay(grain, totalDays, viewportPx) in scale.ts: the grain's density becomes a floor. A plan shorter than the pane widens its day so the chart spans the pane exactly; a longer plan keeps the spec density and scrolls (which is what the grain switch is for).
  • GanttCanvas measures its timeline pane with a ResizeObserver and threads the effective density into TimelineAxis and GanttMilestones, so ticks, shading, bars, today rule and markers can never disagree. The "T" (scroll to today) shortcut uses the same value.
  • Before first measure — and under jsdom, where ResizeObserver doesn't exist — behaviour is unchanged, keeping every existing width assertion valid.

Verification

  • Three new scale tests pin the stretch (all four grains fill a 1200px pane for a 28-day plan), the floor (3-year plan at Day zoom is not compressed), and the fallback.
  • tsc --noEmit clean, next lint --max-warnings=0 clean, production build succeeds, 1,779 unit tests pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TArsz4CrMDAKmeALMozkR5


Generated by Claude Code

The chart width was totalDays x the grain's fixed density, with no
relation to the viewport — a four-week plan at Week zoom drew ~235px of
chart and left the rest of the frame dead white, and every coarser grain
shrank it further (32px at Quarter).

The grain's density is now a floor, not the width: the canvas measures
its timeline pane with a ResizeObserver and widens the day so a short
plan spans the pane exactly, while a plan longer than the pane keeps the
spec density and scrolls — which is what the grain switch is for. The
effective density is threaded into the axis and the milestone layer so
ticks, shading, bars and markers can never disagree, and the "T" (scroll
to today) shortcut uses the same value.

Under jsdom (no ResizeObserver) and before first measure the behaviour
is unchanged, which keeps every existing width assertion valid; three
new scale tests pin the stretch, the floor, and the fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TArsz4CrMDAKmeALMozkR5
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cockpit Ready Ready Preview Aug 9, 2026 12:04am

@ib823
ib823 marked this pull request as ready for review August 9, 2026 00:18
@ib823
ib823 merged commit 93cb699 into main Aug 9, 2026
4 checks passed
ib823 pushed a commit that referenced this pull request Aug 9, 2026
… plan

The timeline sized itself to the plan and the zoom control only relabelled
the header, so a five-week phase filled the screen at every grain —
"Quarter" showed two quarters — and there was nowhere to put work that had
not been scheduled yet. Stretching short plans to fill the pane (#122)
treated the symptom; this replaces the model.

Each grain now carries its own planning horizon, independent of the plan:
a quarter of days, a year of weeks, three years of months, five years of
quarters, anchored on a whole unit just before the plan and scrolled
horizontally. A day is the grain's width, full stop — 26px at Day on every
screen — so two plans at one grain are directly comparable. The horizon
extends rather than compresses when the plan or the viewport is longer, so
a wide monitor shows more time, never wider time.

- Hierarchical headers, as spanning bands rather than point labels, so a
  label always describes exactly the columns beneath it: Day = month/year
  over days; Week = month/year over weeks with daily gridlines; Month =
  year over months with weekly gridlines; Quarter = year over quarters
  with monthly gridlines.
- Changing scale preserves the date in the middle of the screen, held as a
  date rather than a scroll offset because the window's origin moves with
  the grain.
- Fitting the view to the plan is now the explicit "Fit project" command,
  which picks the finest grain the span fits in and centres it.
- One working calendar (`lib/gantt-tool/working-calendar.ts`) feeds the
  weekend shading, the holiday markers AND the duration columns. They
  disagreed before: the axis shaded the region's public holidays while
  every working-day count had only ever been passed the project's own, so
  a bar over a public holiday was shaded and counted as a working day.
- Holidays render at all four grains at their own proportional date,
  floored to 3px so they survive Quarter zoom — a marked day, never the
  week, month or quarter containing it — and carry name and date in a
  tooltip. Weekends stay at Day and Week only, where a day is wide enough
  to read as a day rather than as stripes.
- ISO dates are parsed as local midnight throughout. Mixing `new
  Date("2026-06-17")` (midnight UTC) with a local-midnight origin was a
  day of error east of Greenwich, putting bars and the shading under them
  on different columns.

Validated on the specified plan — a phase from 15 June 2026 to 13 July
2026 with a holiday on 17 June — asserting at all four grains that the
phase stays 29 inclusive days from the 15th to the 13th, the holiday stays
on the 17th, two days into the phase, at the same fraction along the
calendar, and that the header bands tile the window with no gap or
overlap. 1,790 unit tests, 23 bundle budgets, typecheck, lint and build
all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TArsz4CrMDAKmeALMozkR5
ib823 pushed a commit that referenced this pull request Aug 9, 2026
… plan

The timeline sized itself to the plan and the zoom control only relabelled
the header, so a five-week phase filled the screen at every grain —
"Quarter" showed two quarters — and there was nowhere to put work that had
not been scheduled yet. Stretching short plans to fill the pane (#122)
treated the symptom; this replaces the model.

Each grain now carries its own planning horizon, independent of the plan:
a quarter of days, a year of weeks, three years of months, five years of
quarters, anchored on a whole unit just before the plan and scrolled
horizontally. A day is the grain's width, full stop — 26px at Day on every
screen — so two plans at one grain are directly comparable. The horizon
extends rather than compresses when the plan or the viewport is longer, so
a wide monitor shows more time, never wider time.

- Hierarchical headers, as spanning bands rather than point labels, so a
  label always describes exactly the columns beneath it: Day = month/year
  over days; Week = month/year over weeks with daily gridlines; Month =
  year over months with weekly gridlines; Quarter = year over quarters
  with monthly gridlines.
- Changing scale preserves the date in the middle of the screen, held as a
  date rather than a scroll offset because the window's origin moves with
  the grain.
- Fitting the view to the plan is now the explicit "Fit project" command,
  which picks the finest grain the span fits in and centres it.
- One working calendar (`lib/gantt-tool/working-calendar.ts`) feeds the
  weekend shading, the holiday markers AND the duration columns. They
  disagreed before: the axis shaded the region's public holidays while
  every working-day count had only ever been passed the project's own, so
  a bar over a public holiday was shaded and counted as a working day.
- Holidays render at all four grains at their own proportional date,
  floored to 3px so they survive Quarter zoom — a marked day, never the
  week, month or quarter containing it — and carry name and date in a
  tooltip. Weekends stay at Day and Week only, where a day is wide enough
  to read as a day rather than as stripes.
- ISO dates are parsed as local midnight throughout. Mixing `new
  Date("2026-06-17")` (midnight UTC) with a local-midnight origin was a
  day of error east of Greenwich, putting bars and the shading under them
  on different columns.

Validated on the specified plan — a phase from 15 June 2026 to 13 July
2026 with a holiday on 17 June — asserting at all four grains that the
phase stays 29 inclusive days from the 15th to the 13th, the holiday stays
on the 17th, two days into the phase, at the same fraction along the
calendar, and that the header bands tile the window with no gap or
overlap. 1,790 unit tests, 23 bundle budgets, typecheck, lint and build
all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TArsz4CrMDAKmeALMozkR5
ib823 added a commit that referenced this pull request Aug 9, 2026
* Gantt: true semantic zoom on a planning horizon, not a picture of the plan

The timeline sized itself to the plan and the zoom control only relabelled
the header, so a five-week phase filled the screen at every grain —
"Quarter" showed two quarters — and there was nowhere to put work that had
not been scheduled yet. Stretching short plans to fill the pane (#122)
treated the symptom; this replaces the model.

Each grain now carries its own planning horizon, independent of the plan:
a quarter of days, a year of weeks, three years of months, five years of
quarters, anchored on a whole unit just before the plan and scrolled
horizontally. A day is the grain's width, full stop — 26px at Day on every
screen — so two plans at one grain are directly comparable. The horizon
extends rather than compresses when the plan or the viewport is longer, so
a wide monitor shows more time, never wider time.

- Hierarchical headers, as spanning bands rather than point labels, so a
  label always describes exactly the columns beneath it: Day = month/year
  over days; Week = month/year over weeks with daily gridlines; Month =
  year over months with weekly gridlines; Quarter = year over quarters
  with monthly gridlines.
- Changing scale preserves the date in the middle of the screen, held as a
  date rather than a scroll offset because the window's origin moves with
  the grain.
- Fitting the view to the plan is now the explicit "Fit project" command,
  which picks the finest grain the span fits in and centres it.
- One working calendar (`lib/gantt-tool/working-calendar.ts`) feeds the
  weekend shading, the holiday markers AND the duration columns. They
  disagreed before: the axis shaded the region's public holidays while
  every working-day count had only ever been passed the project's own, so
  a bar over a public holiday was shaded and counted as a working day.
- Holidays render at all four grains at their own proportional date,
  floored to 3px so they survive Quarter zoom — a marked day, never the
  week, month or quarter containing it — and carry name and date in a
  tooltip. Weekends stay at Day and Week only, where a day is wide enough
  to read as a day rather than as stripes.
- ISO dates are parsed as local midnight throughout. Mixing `new
  Date("2026-06-17")` (midnight UTC) with a local-midnight origin was a
  day of error east of Greenwich, putting bars and the shading under them
  on different columns.

Validated on the specified plan — a phase from 15 June 2026 to 13 July
2026 with a holiday on 17 June — asserting at all four grains that the
phase stays 29 inclusive days from the 15th to the 13th, the holiday stays
on the 17th, two days into the phase, at the same fraction along the
calendar, and that the header bands tile the window with no gap or
overlap. 1,790 unit tests, 23 bundle budgets, typecheck, lint and build
all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TArsz4CrMDAKmeALMozkR5

* Allow CI to be re-run manually; hoist the canvas scroll constant

PR #123 opened with no validate or e2e run at all — the pull_request event
did not spawn one, and reopening the PR did not either. A PR carrying no
CI signal looks exactly like one whose CI has not finished, so the state
was unrecoverable without pushing a commit. workflow_dispatch makes a
re-run possible directly.

MS_PER_DAY moves to module scope alongside GRAINS: it is a constant, not
per-render state, and reads as one there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TArsz4CrMDAKmeALMozkR5

---------

Co-authored-by: Claude <noreply@anthropic.com>
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