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

Commit 2fb0f9a

Browse files
fix(plugin-timeline): a gantt-variant timeline draws its headers and bars on one continuous axis (objectui#11079) (#11109)
Fixes #11079 Clause-②: no ## What was wrong A `variant: 'gantt'` timeline built its header row and its bars from two different constructions in `packages/plugin-timeline/src/renderer.tsx`: - `generateTimeScaleHeaders` drew one equal-width `flex-1` column per unit, from the minimum date to the maximum date inclusive; - `calculateBarDimensions` placed each bar as a percentage of the span from the minimum date to the maximum date, which ends at the START of the last column. So the bars sat on a scale one column narrower than the headers. On a day axis over October 5th to 7th, the bar starting on the 6th was drawn at `left: 50%` under a column that starts at one third. ## What this changes (triage `5888963850`: one continuous axis) - **One axis function.** The new module-private `timelineAxis(scale, minDate, maxDate, locale, t)` returns `{ start, end, units: [{ start, end, label }] }`. The axis is `[start of the first unit, end of the last unit)`: the first unit contains `minDate`, and the last unit contains `maxDate`. - **One placement function.** `placeOnAxis(axis, start, end)` returns `{ start, width }` as percentages of the axis span. Each header cell's `width` and each bar's `left` / `width` come from this call. There is no second construction. - **Each header is as wide as its unit's real length.** `hour` steps a fixed hour. `day`, `week`, `month`, `quarter` and `year` step with local setters from a local midnight. So day and hour columns are equal, a month is as wide as its days, and a DST day is 23 or 25 hours wide. - **Units snap to their boundary.** The old walk stepped from `minDate` itself. So a month axis from January 15th to April 10th had no April column, and one from January 31st skipped February. `week` still counts from the axis's first day, as its `Week N` labels always did. - **The inverted-range refusal is the axis's own refusal.** The branch used to run a separate greater-than test "deliberately the same test `generateTimeScaleHeaders` makes". It now refuses when `timelineAxis` builds no axis: the same read. - **Dates are still read through `readGanttDate`**, the objectui#10866 slice-3 invariant. No date read changes. - `generateTimeScaleHeaders` keeps its exported signature and now returns the axis's labels. No export, prop, type or schema key is added or removed. - The header cells' `flex-1` becomes `shrink-0 min-w-0 overflow-hidden` plus an inline percentage `width`. That keeps a long label from widening its column off the bars. ## Pins New: `packages/plugin-timeline/src/__tests__/TimelineGantt.oneAxis-11079.test.tsx`, with 25 cases. - For each of the six spec scales, on a pinned three-unit range: - a bar starting at the second unit's start has `left` equal, as a string, to the first header's `width`; - that bar is exactly as wide as the second header; - a bar spanning the last unit ends at 100% (`left + width`, to 10 decimal places); - the header widths match each unit's length. - `month` across a 28/31-day boundary (January 2026, then February): the widths are 31/90 and 28/90. - The card's own computed day axis: the Oct 6 bar sits at one third, not 50%. - The three unit-boundary cases above. - The schema catalog's `gantt-style-timeline.json`, read from disk as the named real producer: months at 31/121, 29/121, 31/121 and 30/121. Implementation starts on the Feb header, and QA Phase on the Mar header. `TimelineGantt.dateOnlyZone-10866.test.tsx` gets one new driven case, run in Los Angeles: a day axis across the March 8th 2026 spring change. Its columns are 24/71, 23/71 and 24/71, and a bar from March 8th starts on its column. The zone-pin driver `scripts/__tests__/date-only-zone-pins-10183.test.ts` already lists this file, so its PINS list needed no change. Re-derived from the new axis, each with a comment naming objectui#11079 and its day-count derivation. Zone-rule assertions are untouched: - `TimelineGantt.dateOnlyZone-10866.test.tsx`: the computed-range and pinned-mixed bars, in the suite zone and both driven zones. A header-width read was added so the pinned day bar can be compared with its column. - `timeline-gantt-empty-items.test.tsx`: pin 4 (the catalog shape on 91 days) and pin 5b (see Acceptance notes). - `timeline-gantt-unusable-date-range.test.tsx`: the degenerate pinned range and the healthy-path pin. - `timeline-gantt-null-date-6770.test.tsx` and `timeline-gantt-date-type-rule-6781.test.tsx`: their bar-geometry pins. The dispatch did not list these two files, but they read the same geometry. ## Evidence (objectui HEAD `efee2d5bd`, base `88fbd793d`) - `pnpm exec vitest run packages/plugin-timeline/`: `Test Files 37 passed (37)`, `Tests 389 passed | 32 skipped (421)`. The base read 36 files, 364 passed and 31 skipped. The 25 new cases are all counted as passed; the new DST case is a driven case, so the normal run skips it. - `pnpm --filter @object-ui/plugin-timeline type-check` exits 0. `tsconfig.test.json` includes all six touched test files, counted with `--listFiles`. - In one run, four files pass (`Tests 660 passed (660)`): the zone driver `scripts/__tests__/date-only-zone-pins-10183.test.ts` (27 passed; the forked child ran every case of every pin, including the new DST case), `examples/schema-catalog/test/catalog-gallery-render.test.tsx` (587 passed), and the two `packages/types` tests that read `plugin-timeline.mdx` (46 passed). - eslint on the 7 changed `.ts`/`.tsx` files (`--format json`: 7 files): 0 errors and 34 warnings. The same 6 pre-existing files read 34 warnings on the base, and the new file has 0. The narrowing is safe: `eslint.config.js` enables no type-aware linting (no `parserOptions.project` / `projectService`), and no custom rule under `eslint-rules/` reads the filesystem. So this diff cannot move the verdict of any untouched file. The full `pnpm lint` run is CI's. - These gates exit 0: `check-changeset-presence`, `check-control-bytes`, `check-new-cross-file-line-citations` (0 new), `check-changeset-claims`, `check-pending-changeset-literals`, `check-test-path-roots`, `check-i18n-call-site-keys`, `check-unreferenced-sources`, `check-changeset-no-major` and `check-doc-fence-languages`. `check-governed-queue-guard --test` reports NOT GOVERNED. - **Reverse verification (one time, not kept).** With the fix committed, `renderer.tsx` was put back to the base blob, and the mutation was confirmed on disk (`function timelineAxis` 0 hits). The six touched test files then read `Tests 38 failed | 63 passed | 14 skipped`, and 24 of the 25 new cases went red; only the fixture-on-disk check stayed green. The file was then restored with `git checkout HEAD --`. The restore is proven by its blob hash matching `HEAD` and by an empty `git diff HEAD`. The direction was red, as predicted. - **Real layout engine.** The rendered gantt was laid out in Chromium at 1200px, using the Tailwind utilities it uses written out by hand, not the app's built stylesheet: - the card's Oct 6 bar starts at x 528.328, the Oct 6 header's x; - in the catalog gantt, Implementation starts at 450.734, the Feb header's x, and QA Phase at 691.828, the Mar header's x. ## Acceptance notes - **Inline `width` on the header cells.** AGENTS.md §2 bans inline styles outside the colour carve-out. `skills/objectui/rules/styling.md` says a runtime geometry value "is a separate question this carve-out does not answer". The header width follows `TimelineGanttBar`'s existing inline `left` / `width`, which is the same kind of value. The Zone 2 guidance asked for inline percentage widths. A computed percentage cannot be a static utility. - **Pin 5b moved from a full-width bar to a zero-width one.** Before, a plan whose only dates were one same-day task (`2024-05-01` to `2024-05-01`) drew its bar at `left: 0%; width: 100%`. That value was the `totalDuration === 0` special case. Now the axis always spans at least one whole unit, so the guard had nothing left to catch and was removed. The bar is `left: 0%; width: 0%`, as a zero-length task already was on any plan with two distinct dates. The pinned `minDate === maxDate` case is now a one-unit axis, and the bar overhangs it the way any bar outside a pinned range does. This is recorded in the changeset. - **Out of scope, reported for the seat.** The catalog's producer writes inclusive date-only ends (API Design `2024-01-01` to `2024-01-31`), but `readGanttDate` reads an end as the instant it names (local midnight at the start of that day). So each such bar now ends visibly one day short of its month column, and a same-day task is invisible. Before this change, the equal-width columns hid the one-day gap by coincidence. The claim forbids changing how a date is read, so this is left for its own card. - **Also outside the claimed file surface:** one sentence in `content/docs/plugins/plugin-timeline.mdx` ("Time Scales (Gantt)") describes the shared axis, per AGENTS.md #2. --- _Generated by [Claude Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent efb3021 commit 2fb0f9a

9 files changed

Lines changed: 791 additions & 236 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-timeline': patch
3+
---
4+
5+
fix(plugin-timeline): a gantt-variant timeline draws its headers and bars on one continuous axis, so each bar lines up under its header (objectui#11079)
6+
7+
- **Bars now sit under their headers.** The header row was one equal-width column per unit from the first date to the last date inclusive, while the bars were measured on the span from the first date to the last date, one unit shorter. So every bar after the first sat off its column: on a day axis over October 5th to 7th, the bar starting on the 6th was drawn halfway across the chart under a column that starts at one third. Both are now drawn on one axis, from the start of the first unit to the end of the last. A bar that starts on a day, a month or a quarter begins exactly where that unit's header begins.
8+
- **Each header is as wide as its unit is long.** Hour, day and week columns are equal. A month column is as wide as its days, so February is narrower than January, and a quarter or a year is as wide as its days. Across a daylight-saving change a day column is 23 or 25 hours wide, as the day is.
9+
- **Each unit starts on its boundary.** A month axis from January 15th to April 10th now has an April column, and one from January 31st no longer skips February. `generateTimeScaleHeaders`, which the package exports, returns these labels.
10+
- A task that starts and ends at the same instant is a zero-width bar at that instant, as it already was on any plan with two distinct dates. A plan whose only dates were one such day used to draw that bar across the whole chart.

‎content/docs/plugins/plugin-timeline.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,8 @@ const longProject: TimelineSchema['scale'] = 'month' // long projects
342342

343343
A date-only bar date, `minDate` or `maxDate` (`2024-01-15`) names a calendar day, so the axis headers, the bar positions and the bar tooltips all place it on that day in every viewer's time zone. A value with a time part is an instant and sits at its hour in the viewer's day. A plan with no bar dates and no pinned range shows a one-day axis on the viewer's today.
344344

345+
The headers and the bars share one axis. It runs from the start of the unit that holds the first date to the end of the unit that holds the last. Each header is as wide as its unit is long: hour, day and week columns are equal, and a month is as wide as its days. So a bar lines up under the header of the unit it starts in.
346+
345347
## Customization
346348

347349
### Custom Icons

‎packages/plugin-timeline/src/__tests__/TimelineGantt.dateOnlyZone-10866.test.tsx‎

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ afterEach(() => {
8787
interface Drawn {
8888
/** The axis header cells, in order. */
8989
axis: string[];
90+
/** Each axis header cell's inline width, in order (objectui#11079). */
91+
columns: string[];
9092
/** Each bar's `left` / `width`, in order. */
9193
bars: Array<{ left: string; width: string }>;
9294
/** Each bar's tooltip, in order. */
@@ -107,6 +109,9 @@ function draw(schema: Record<string, unknown>): Drawn {
107109
axis: Array.from(container.querySelectorAll('.border-r.text-xs.font-medium.text-center')).map(
108110
(n) => n.textContent ?? '',
109111
),
112+
columns: Array.from(
113+
container.querySelectorAll<HTMLElement>('.border-r.text-xs.font-medium.text-center'),
114+
).map((n) => n.style.width),
110115
bars: bars.map((b) => ({ left: b.style.left, width: b.style.width })),
111116
titles: bars.map((b) => b.getAttribute('title') ?? ''),
112117
refusal: container.querySelector('[data-testid="timeline-unusable-date-range"]')?.textContent ?? null,
@@ -188,9 +193,12 @@ describe('timeline gantt, in the suite zone (objectui#10866)', () => {
188193
it('a computed day axis over date-only bars reads their days', () => {
189194
const drawn = draw(COMPUTED);
190195
expect(drawn.axis).toEqual(['Oct 5', 'Oct 6', 'Oct 7']);
196+
// objectui#11079: the bars are measured on the axis the headers draw,
197+
// October 5th to the end of October 7th, three days — not on the two days
198+
// from the first midnight to the last, which put the second bar at 50%.
191199
expect(drawn.bars).toEqual([
192-
{ left: '0%', width: '50%' },
193-
{ left: '50%', width: '50%' },
200+
{ left: '0%', width: `${(1 / 3) * 100}%` },
201+
{ left: `${(1 / 3) * 100}%`, width: `${(1 / 3) * 100}%` },
194202
]);
195203
});
196204

@@ -238,20 +246,27 @@ describe.runIf(DRIVEN)('timeline gantt west of UTC (objectui#10866)', () => {
238246
enter(WEST);
239247
const drawn = draw(COMPUTED);
240248
expect(drawn.axis).toEqual(['Oct 5', 'Oct 6', 'Oct 7']);
249+
// objectui#11079: the bars are measured on the axis the headers draw,
250+
// October 5th to the end of October 7th, three days — not on the two days
251+
// from the first midnight to the last, which put the second bar at 50%.
241252
expect(drawn.bars).toEqual([
242-
{ left: '0%', width: '50%' },
243-
{ left: '50%', width: '50%' },
253+
{ left: '0%', width: `${(1 / 3) * 100}%` },
254+
{ left: `${(1 / 3) * 100}%`, width: `${(1 / 3) * 100}%` },
244255
]);
245256
});
246257

247258
it('a pinned day axis reads its days; a date-only bar sits on its day and an instant bar at its local hour, 21:00 on the 5th', () => {
248259
enter(WEST);
249260
const drawn = draw(PINNED_MIXED);
250261
expect(drawn.axis).toEqual(['Oct 5', 'Oct 6', 'Oct 7']);
262+
// objectui#11079: on the 72-hour axis the headers draw, October 5th to the
263+
// end of the 7th. The day bar is the second day, 24 hours from the start;
264+
// the instant bar starts 21 hours in and is 6 hours long.
251265
expect(drawn.bars).toEqual([
252-
{ left: '50%', width: '50%' },
253-
{ left: '43.75%', width: '12.5%' },
266+
{ left: `${(24 / 72) * 100}%`, width: `${(24 / 72) * 100}%` },
267+
{ left: `${(21 / 72) * 100}%`, width: `${(6 / 72) * 100}%` },
254268
]);
269+
expect(drawn.bars[0].left).toBe(drawn.columns[0]);
255270
});
256271

257272
it('the min-over-max guard reads what the axis reads: a pinned end that is the evening before the pinned start here is refused', () => {
@@ -262,6 +277,24 @@ describe.runIf(DRIVEN)('timeline gantt west of UTC (objectui#10866)', () => {
262277
expect(drawn.bars).toEqual([]);
263278
});
264279

280+
it('a day axis across the spring DST change draws the 23-hour day as a narrower column, and a bar from that day starts on it (objectui#11079)', () => {
281+
enter(WEST);
282+
// March 8th 2026 is 23 hours long here: the clocks go forward at 02:00.
283+
const drawn = draw({
284+
scale: 'day',
285+
minDate: '2026-03-07',
286+
maxDate: '2026-03-09',
287+
items: [{ label: 'Row', items: [{ title: 'Bar', startDate: '2026-03-08', endDate: '2026-03-09' }] }],
288+
});
289+
expect(drawn.axis).toEqual(['Mar 7', 'Mar 8', 'Mar 9']);
290+
expect(drawn.columns).toEqual([
291+
`${(24 / 71) * 100}%`,
292+
`${(23 / 71) * 100}%`,
293+
`${(24 / 71) * 100}%`,
294+
]);
295+
expect(drawn.bars).toEqual([{ left: drawn.columns[0], width: drawn.columns[1] }]);
296+
});
297+
265298
it("the empty plan's axis is the viewer's today, October 5th, not the UTC day", () => {
266299
enter(WEST);
267300
expect(emptyPlanAxis()).toEqual(['Oct 5']);
@@ -286,20 +319,27 @@ describe.runIf(DRIVEN)('timeline gantt east of UTC, the control (objectui#10866)
286319
enter(EAST);
287320
const drawn = draw(COMPUTED);
288321
expect(drawn.axis).toEqual(['Oct 5', 'Oct 6', 'Oct 7']);
322+
// objectui#11079: the bars are measured on the axis the headers draw,
323+
// October 5th to the end of October 7th, three days — not on the two days
324+
// from the first midnight to the last, which put the second bar at 50%.
289325
expect(drawn.bars).toEqual([
290-
{ left: '0%', width: '50%' },
291-
{ left: '50%', width: '50%' },
326+
{ left: '0%', width: `${(1 / 3) * 100}%` },
327+
{ left: `${(1 / 3) * 100}%`, width: `${(1 / 3) * 100}%` },
292328
]);
293329
});
294330

295331
it('a pinned day axis reads its days; a date-only bar sits on its day and an instant bar at its local hour, 12:00 on the 6th', () => {
296332
enter(EAST);
297333
const drawn = draw(PINNED_MIXED);
298334
expect(drawn.axis).toEqual(['Oct 5', 'Oct 6', 'Oct 7']);
335+
// objectui#11079: on the 72-hour axis the headers draw, October 5th to the
336+
// end of the 7th. The day bar is the second day, 24 hours from the start;
337+
// the instant bar starts 36 hours in and is 6 hours long.
299338
expect(drawn.bars).toEqual([
300-
{ left: '50%', width: '50%' },
301-
{ left: '75%', width: '12.5%' },
339+
{ left: `${(24 / 72) * 100}%`, width: `${(24 / 72) * 100}%` },
340+
{ left: `${(36 / 72) * 100}%`, width: `${(6 / 72) * 100}%` },
302341
]);
342+
expect(drawn.bars[0].left).toBe(drawn.columns[0]);
303343
});
304344

305345
it('the min-over-max guard: the same pinned pair is in order here, and draws', () => {

0 commit comments

Comments
 (0)