Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7203-gantt-toolbar-period-label.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/plugin-gantt': patch
---

Gantt toolbar: the period label names the visible window, and the prev/next
buttons step it (objectui#7203).

The label formatted `timelineRange.start` — the memo spanning the whole dataset
— so it named the first unit of the entire result set and could not change while
the chart was scrolled, because it was not derived from scroll position at all.
On a dataset running January to December it therefore read "January 2026" at
every scroll position, four pixels above a band header correctly reading
"Aug 2026". Measured on the demo fixture in Chromium at 1440x900: on first paint,
after the chart auto-scrolls to Today, the label read `December 2025` over
columns `28F 29S 30S 31M 1T 2W 3T` with the band beneath them reading `Aug 2026`.
Two month labels four pixels apart, disagreeing — and the wrong one is the
prominent one, so the chart reads as if the columns were mislabelled.

The label now names the period at the left edge of the viewport, snapped to the
same tier `headerGroups` bands the timeline by: a month under day and week view,
a year under month and quarter view, a decade under year view, the shift-day
under shift-segmented day view. The toolbar and the band header therefore agree
by construction rather than by two derivations that can drift. Wording is
unchanged for the month tier — the toolbar still spells the month out
("August 2026" beside the header's "Aug 2026").

The `‹` / `›` buttons rendered an `aria-label` and an icon and carried no
`onClick`. They now scroll the visible window one period backwards/forwards at
that same tier, clamped to the ends of the timeline (ADR-0049 enforce-or-remove:
wiring is the branch the label change makes available). They step the label's
tier rather than one column, so a click always changes what the label says.

The band header is untouched. It was already correct; it is the reference here.
15 changes: 15 additions & 0 deletions packages/plugin-gantt/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,6 +444,21 @@ Drag snapping follows the active scale: bars snap to days in day view, weeks
in week view, and whole calendar months/quarters (duration preserved) in the
coarse views.

### The toolbar period label and its steppers

The label between the `‹` / `›` buttons names the period **currently on screen**,
not the extent of the data: it reads the date at the left edge of the viewport
and snaps it to the same tier the band header groups by — a month under day and
week view, a year under month and quarter view, a decade under year view, and
the shift-day under shift-segmented day view. So the label and the band header
directly beneath it always name the same period, and the label moves as the
chart is scrolled.

`‹` / `›` step the visible window by one of those periods (one month in day and
week view, one year in month and quarter view, a decade in year view), clamped
to the ends of the timeline. They step the *label's* tier rather than a single
column, so one click always changes what the label says.

Set the initial scale with `viewMode`. It is read through the gantt config, so
it needs the field mapping beside it (or a `gantt` block of its own):

Expand Down
223 changes: 223 additions & 0 deletions packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
/**
* objectui#7203 — the toolbar period label and its prev/next steppers.
*
* The label used to format `timelineRange.start`, the memo spanning the WHOLE
* dataset, so it named the first month of the result set and could not change
* at any scroll position — it was not a function of scroll position at all. On
* a dataset running Jan–Dec it therefore read "January 2026" four pixels above
* a band header correctly reading "Aug 2026". The two stepper buttons beside it
* rendered an aria-label and an icon and carried no onClick.
*
* The band header (`data-testid="gantt-header-groups"`) is the REFERENCE here,
* never the thing under test: these tests read the header cell that owns the
* pixel at the left edge of the viewport and require the toolbar to name the
* same month. Comparison is by month identity (`monthKey`), not by wording, so
* the toolbar staying on the fuller "August 2026" beside the header's compact
* "Aug 2026" is not what is being asserted either way.
*
* Scroll is honestly modelled in this environment: `GanttView.virtual.test.tsx`
* already pins that `timeline.scrollLeft = N` + `fireEvent.scroll` moves the
* column window to N (its assertion reads a rendered `style.left` back). The
* label derives from the same `scrollPos.left`, so these readings are real.
* Client sizes ARE 0 here, so the component falls back to a 4000px virtual
* viewport — every fixture below is wider than that, which is what makes the
* stepper's clamp non-trivial and the window genuinely scrollable.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { GanttView, type GanttTask } from './GanttView';

beforeEach(() => {
// >=1024 → columnWidth 110 (deterministic), matching the sibling suites.
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
});

function toolbarLabel(container: HTMLElement): string {
const el = container.querySelector('[data-testid="gantt-toolbar-period"]');
expect(el, 'toolbar period label is missing').toBeTruthy();
return el!.textContent!.trim();
}

function groupCells(container: HTMLElement) {
return Array.from(
container.querySelectorAll('[data-testid="gantt-header-groups"] > div'),
).map((node) => {
const el = node as HTMLElement;
return {
label: el.textContent!.trim(),
left: parseFloat(el.style.left),
width: parseFloat(el.style.width),
};
});
}

/** The band-header cell that owns pixel `x` — what the user reads at the left
* edge of the viewport, and the reference the toolbar has to agree with. */
function bandAt(container: HTMLElement, x: number) {
const hit = groupCells(container).find((c) => c.left <= x && x < c.left + c.width);
expect(hit, `no band-header cell owns x=${x}`).toBeTruthy();
return hit!;
}

/** `year-monthIndex` of a rendered month label, so "August 2026" (toolbar) and
* "Aug 2026" (band header) compare as the same month without either one's
* exact wording being asserted. */
function monthKey(label: string): string {
const d = new Date(label);
expect(Number.isNaN(d.getTime()), `unparseable month label: "${label}"`).toBe(false);
return `${d.getFullYear()}-${d.getMonth()}`;
}

function timelineOf(container: HTMLElement): HTMLElement {
return container.querySelector('[data-testid="gantt-timeline"]') as HTMLElement;
}

function scrollTo(container: HTMLElement, x: number) {
const timeline = timelineOf(container);
timeline.scrollLeft = x;
fireEvent.scroll(timeline);
return timeline;
}

function click(container: HTMLElement, testid: string) {
const btn = container.querySelector(`[data-testid="${testid}"]`);
expect(btn, `${testid} is missing`).toBeTruthy();
fireEvent.click(btn!);
}

/** The reporter's shape: a year of history, no explicit window, so
* `timelineRange` spans the whole dataset and starts in January. */
function yearOfTasks(): GanttTask[] {
return [
{ id: 'a', title: 'Task a', start: new Date(2026, 0, 31), end: new Date(2026, 1, 20), progress: 0 },
{ id: 'b', title: 'Task b', start: new Date(2026, 7, 26), end: new Date(2026, 8, 6), progress: 0 },
{ id: 'c', title: 'Task c', start: new Date(2026, 11, 1), end: new Date(2026, 11, 31), progress: 0 },
];
}

function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
return render(
<div style={{ width: 1280, height: 600 }}>
<GanttView tasks={yearOfTasks()} {...props} />
</div>,
);
}

describe('GanttView toolbar period label (objectui#7203)', () => {
it('names the month the band header names, at a scrolled position', () => {
const { container } = renderView();
// The dataset really does start in January — which is precisely why the old
// `timelineRange.start` label read January at EVERY scroll position.
expect(monthKey(toolbarLabel(container))).toBe('2026-0');

const x = 23430; // ~7 months in at 110px/day; anywhere past January will do
scrollTo(container, x);

const band = bandAt(container, x);
expect(monthKey(band.label), 'fixture did not actually leave January').not.toBe('2026-0');
expect(monthKey(toolbarLabel(container))).toBe(monthKey(band.label));
});

it('changes as the chart scrolls, and comes back', () => {
// The assertion that fails if the label is ever wired back to a whole-range
// memo: a static string passes any single-position check.
const { container } = renderView();
const atStart = toolbarLabel(container);

scrollTo(container, 23430);
expect(toolbarLabel(container)).not.toBe(atStart);

scrollTo(container, 0);
expect(toolbarLabel(container)).toBe(atStart);
});

it('agrees with the band header at every scroll position, week view included', () => {
// Week view is the straddle case: a week column can start in one month and
// end in the next. The header keys such a column by the month its START
// falls in, so the label has to snap the COLUMN, not the instant under the
// pixel — otherwise the two disagree for part of every straddling week.
const { container } = renderView({ viewMode: 'week' });
for (const x of [0, 300, 777, 1234, 2000, 3111, 4200]) {
scrollTo(container, x);
expect(monthKey(toolbarLabel(container)), `at x=${x}`).toBe(monthKey(bandAt(container, x).label));
}
});

it('still labels a single-month dataset correctly (control)', () => {
// The fix is not "read whatever the header says": with one band and no
// scrolling, the label is still derived, and still right.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2026, 5, 5), end: new Date(2026, 5, 20), progress: 0 }]}
startDate={new Date(2026, 5, 1)}
endDate={new Date(2026, 5, 30)}
/>
</div>,
);
const bands = groupCells(container);
expect(bands.length).toBe(1);
expect(monthKey(toolbarLabel(container))).toBe('2026-5');
expect(monthKey(bands[0].label)).toBe('2026-5');
});
});

describe('GanttView toolbar period steppers (objectui#7203)', () => {
it('steps the visible window one period per click, and the label follows', () => {
const { container } = renderView();
const timeline = timelineOf(container);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
expect(timeline.scrollLeft).toBe(0);

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
const afterNext = timeline.scrollLeft;
expect(afterNext).toBeGreaterThan(0);
expect(monthKey(bandAt(container, afterNext).label)).toBe('2026-1');

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-2');
expect(timeline.scrollLeft).toBeGreaterThan(afterNext);

click(container, 'gantt-toolbar-prev-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
expect(timeline.scrollLeft).toBe(afterNext);
});

it('clamps at the left edge instead of scrolling out of the range', () => {
const { container } = renderView();
const timeline = timelineOf(container);
// January is the first period; the timeline itself starts on 24 Jan, so the
// period start is off-grid to the left. Stepping back parks at 0.
click(container, 'gantt-toolbar-prev-period');
expect(timeline.scrollLeft).toBe(0);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
});

it('bands by year in month view, and steps one year per click', () => {
// "One unit of the current granularity" is the tier the band header groups
// by, not the column unit: day/week band by month, month/quarter by year,
// year by decade. Stepping a single month column in month view would leave
// the toolbar's own label unchanged for eleven clicks out of twelve.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2024, 0, 10), end: new Date(2028, 11, 20), progress: 0 }]}
startDate={new Date(2024, 0, 1)}
endDate={new Date(2028, 11, 31)}
viewMode="month"
/>
</div>,
);
expect(toolbarLabel(container)).toBe('2024');
expect(bandAt(container, 0).label).toBe('2024');

const timeline = timelineOf(container);
click(container, 'gantt-toolbar-next-period');
expect(toolbarLabel(container)).toBe('2025');
expect(timeline.scrollLeft).toBeGreaterThan(0);
expect(bandAt(container, timeline.scrollLeft).label).toBe('2025');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7203-gantt-toolbar-period-label.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/plugin-gantt': patch
---

Gantt toolbar: the period label names the visible window, and the prev/next
buttons step it (objectui#7203).

The label formatted `timelineRange.start` — the memo spanning the whole dataset
— so it named the first unit of the entire result set and could not change while
the chart was scrolled, because it was not derived from scroll position at all.
On a dataset running January to December it therefore read "January 2026" at
every scroll position, four pixels above a band header correctly reading
"Aug 2026". Measured on the demo fixture in Chromium at 1440x900: on first paint,
after the chart auto-scrolls to Today, the label read `December 2025` over
columns `28F 29S 30S 31M 1T 2W 3T` with the band beneath them reading `Aug 2026`.
Two month labels four pixels apart, disagreeing — and the wrong one is the
prominent one, so the chart reads as if the columns were mislabelled.

The label now names the period at the left edge of the viewport, snapped to the
same tier `headerGroups` bands the timeline by: a month under day and week view,
a year under month and quarter view, a decade under year view, the shift-day
under shift-segmented day view. The toolbar and the band header therefore agree
by construction rather than by two derivations that can drift. Wording is
unchanged for the month tier — the toolbar still spells the month out
("August 2026" beside the header's "Aug 2026").

The `‹` / `›` buttons rendered an `aria-label` and an icon and carried no
`onClick`. They now scroll the visible window one period backwards/forwards at
that same tier, clamped to the ends of the timeline (ADR-0049 enforce-or-remove:
wiring is the branch the label change makes available). They step the label's
tier rather than one column, so a click always changes what the label says.

The band header is untouched. It was already correct; it is the reference here.
15 changes: 15 additions & 0 deletions packages/plugin-gantt/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,6 +444,21 @@ Drag snapping follows the active scale: bars snap to days in day view, weeks
in week view, and whole calendar months/quarters (duration preserved) in the
coarse views.

### The toolbar period label and its steppers

The label between the `‹` / `›` buttons names the period **currently on screen**,
not the extent of the data: it reads the date at the left edge of the viewport
and snaps it to the same tier the band header groups by — a month under day and
week view, a year under month and quarter view, a decade under year view, and
the shift-day under shift-segmented day view. So the label and the band header
directly beneath it always name the same period, and the label moves as the
chart is scrolled.

`‹` / `›` step the visible window by one of those periods (one month in day and
week view, one year in month and quarter view, a decade in year view), clamped
to the ends of the timeline. They step the *label's* tier rather than a single
column, so one click always changes what the label says.

Set the initial scale with `viewMode`. It is read through the gantt config, so
it needs the field mapping beside it (or a `gantt` block of its own):

Expand Down
223 changes: 223 additions & 0 deletions packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
/**
* objectui#7203 — the toolbar period label and its prev/next steppers.
*
* The label used to format `timelineRange.start`, the memo spanning the WHOLE
* dataset, so it named the first month of the result set and could not change
* at any scroll position — it was not a function of scroll position at all. On
* a dataset running Jan–Dec it therefore read "January 2026" four pixels above
* a band header correctly reading "Aug 2026". The two stepper buttons beside it
* rendered an aria-label and an icon and carried no onClick.
*
* The band header (`data-testid="gantt-header-groups"`) is the REFERENCE here,
* never the thing under test: these tests read the header cell that owns the
* pixel at the left edge of the viewport and require the toolbar to name the
* same month. Comparison is by month identity (`monthKey`), not by wording, so
* the toolbar staying on the fuller "August 2026" beside the header's compact
* "Aug 2026" is not what is being asserted either way.
*
* Scroll is honestly modelled in this environment: `GanttView.virtual.test.tsx`
* already pins that `timeline.scrollLeft = N` + `fireEvent.scroll` moves the
* column window to N (its assertion reads a rendered `style.left` back). The
* label derives from the same `scrollPos.left`, so these readings are real.
* Client sizes ARE 0 here, so the component falls back to a 4000px virtual
* viewport — every fixture below is wider than that, which is what makes the
* stepper's clamp non-trivial and the window genuinely scrollable.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { GanttView, type GanttTask } from './GanttView';

beforeEach(() => {
// >=1024 → columnWidth 110 (deterministic), matching the sibling suites.
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
});

function toolbarLabel(container: HTMLElement): string {
const el = container.querySelector('[data-testid="gantt-toolbar-period"]');
expect(el, 'toolbar period label is missing').toBeTruthy();
return el!.textContent!.trim();
}

function groupCells(container: HTMLElement) {
return Array.from(
container.querySelectorAll('[data-testid="gantt-header-groups"] > div'),
).map((node) => {
const el = node as HTMLElement;
return {
label: el.textContent!.trim(),
left: parseFloat(el.style.left),
width: parseFloat(el.style.width),
};
});
}

/** The band-header cell that owns pixel `x` — what the user reads at the left
* edge of the viewport, and the reference the toolbar has to agree with. */
function bandAt(container: HTMLElement, x: number) {
const hit = groupCells(container).find((c) => c.left <= x && x < c.left + c.width);
expect(hit, `no band-header cell owns x=${x}`).toBeTruthy();
return hit!;
}

/** `year-monthIndex` of a rendered month label, so "August 2026" (toolbar) and
* "Aug 2026" (band header) compare as the same month without either one's
* exact wording being asserted. */
function monthKey(label: string): string {
const d = new Date(label);
expect(Number.isNaN(d.getTime()), `unparseable month label: "${label}"`).toBe(false);
return `${d.getFullYear()}-${d.getMonth()}`;
}

function timelineOf(container: HTMLElement): HTMLElement {
return container.querySelector('[data-testid="gantt-timeline"]') as HTMLElement;
}

function scrollTo(container: HTMLElement, x: number) {
const timeline = timelineOf(container);
timeline.scrollLeft = x;
fireEvent.scroll(timeline);
return timeline;
}

function click(container: HTMLElement, testid: string) {
const btn = container.querySelector(`[data-testid="${testid}"]`);
expect(btn, `${testid} is missing`).toBeTruthy();
fireEvent.click(btn!);
}

/** The reporter's shape: a year of history, no explicit window, so
* `timelineRange` spans the whole dataset and starts in January. */
function yearOfTasks(): GanttTask[] {
return [
{ id: 'a', title: 'Task a', start: new Date(2026, 0, 31), end: new Date(2026, 1, 20), progress: 0 },
{ id: 'b', title: 'Task b', start: new Date(2026, 7, 26), end: new Date(2026, 8, 6), progress: 0 },
{ id: 'c', title: 'Task c', start: new Date(2026, 11, 1), end: new Date(2026, 11, 31), progress: 0 },
];
}

function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
return render(
<div style={{ width: 1280, height: 600 }}>
<GanttView tasks={yearOfTasks()} {...props} />
</div>,
);
}

describe('GanttView toolbar period label (objectui#7203)', () => {
it('names the month the band header names, at a scrolled position', () => {
const { container } = renderView();
// The dataset really does start in January — which is precisely why the old
// `timelineRange.start` label read January at EVERY scroll position.
expect(monthKey(toolbarLabel(container))).toBe('2026-0');

const x = 23430; // ~7 months in at 110px/day; anywhere past January will do
scrollTo(container, x);

const band = bandAt(container, x);
expect(monthKey(band.label), 'fixture did not actually leave January').not.toBe('2026-0');
expect(monthKey(toolbarLabel(container))).toBe(monthKey(band.label));
});

it('changes as the chart scrolls, and comes back', () => {
// The assertion that fails if the label is ever wired back to a whole-range
// memo: a static string passes any single-position check.
const { container } = renderView();
const atStart = toolbarLabel(container);

scrollTo(container, 23430);
expect(toolbarLabel(container)).not.toBe(atStart);

scrollTo(container, 0);
expect(toolbarLabel(container)).toBe(atStart);
});

it('agrees with the band header at every scroll position, week view included', () => {
// Week view is the straddle case: a week column can start in one month and
// end in the next. The header keys such a column by the month its START
// falls in, so the label has to snap the COLUMN, not the instant under the
// pixel — otherwise the two disagree for part of every straddling week.
const { container } = renderView({ viewMode: 'week' });
for (const x of [0, 300, 777, 1234, 2000, 3111, 4200]) {
scrollTo(container, x);
expect(monthKey(toolbarLabel(container)), `at x=${x}`).toBe(monthKey(bandAt(container, x).label));
}
});

it('still labels a single-month dataset correctly (control)', () => {
// The fix is not "read whatever the header says": with one band and no
// scrolling, the label is still derived, and still right.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2026, 5, 5), end: new Date(2026, 5, 20), progress: 0 }]}
startDate={new Date(2026, 5, 1)}
endDate={new Date(2026, 5, 30)}
/>
</div>,
);
const bands = groupCells(container);
expect(bands.length).toBe(1);
expect(monthKey(toolbarLabel(container))).toBe('2026-5');
expect(monthKey(bands[0].label)).toBe('2026-5');
});
});

describe('GanttView toolbar period steppers (objectui#7203)', () => {
it('steps the visible window one period per click, and the label follows', () => {
const { container } = renderView();
const timeline = timelineOf(container);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
expect(timeline.scrollLeft).toBe(0);

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
const afterNext = timeline.scrollLeft;
expect(afterNext).toBeGreaterThan(0);
expect(monthKey(bandAt(container, afterNext).label)).toBe('2026-1');

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-2');
expect(timeline.scrollLeft).toBeGreaterThan(afterNext);

click(container, 'gantt-toolbar-prev-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
expect(timeline.scrollLeft).toBe(afterNext);
});

it('clamps at the left edge instead of scrolling out of the range', () => {
const { container } = renderView();
const timeline = timelineOf(container);
// January is the first period; the timeline itself starts on 24 Jan, so the
// period start is off-grid to the left. Stepping back parks at 0.
click(container, 'gantt-toolbar-prev-period');
expect(timeline.scrollLeft).toBe(0);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
});

it('bands by year in month view, and steps one year per click', () => {
// "One unit of the current granularity" is the tier the band header groups
// by, not the column unit: day/week band by month, month/quarter by year,
// year by decade. Stepping a single month column in month view would leave
// the toolbar's own label unchanged for eleven clicks out of twelve.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2024, 0, 10), end: new Date(2028, 11, 20), progress: 0 }]}
startDate={new Date(2024, 0, 1)}
endDate={new Date(2028, 11, 31)}
viewMode="month"
/>
</div>,
);
expect(toolbarLabel(container)).toBe('2024');
expect(bandAt(container, 0).label).toBe('2024');

const timeline = timelineOf(container);
click(container, 'gantt-toolbar-next-period');
expect(toolbarLabel(container)).toBe('2025');
expect(timeline.scrollLeft).toBeGreaterThan(0);
expect(bandAt(container, timeline.scrollLeft).label).toBe('2025');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7203-gantt-toolbar-period-label.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/plugin-gantt': patch
---

Gantt toolbar: the period label names the visible window, and the prev/next
buttons step it (objectui#7203).

The label formatted `timelineRange.start` — the memo spanning the whole dataset
— so it named the first unit of the entire result set and could not change while
the chart was scrolled, because it was not derived from scroll position at all.
On a dataset running January to December it therefore read "January 2026" at
every scroll position, four pixels above a band header correctly reading
"Aug 2026". Measured on the demo fixture in Chromium at 1440x900: on first paint,
after the chart auto-scrolls to Today, the label read `December 2025` over
columns `28F 29S 30S 31M 1T 2W 3T` with the band beneath them reading `Aug 2026`.
Two month labels four pixels apart, disagreeing — and the wrong one is the
prominent one, so the chart reads as if the columns were mislabelled.

The label now names the period at the left edge of the viewport, snapped to the
same tier `headerGroups` bands the timeline by: a month under day and week view,
a year under month and quarter view, a decade under year view, the shift-day
under shift-segmented day view. The toolbar and the band header therefore agree
by construction rather than by two derivations that can drift. Wording is
unchanged for the month tier — the toolbar still spells the month out
("August 2026" beside the header's "Aug 2026").

The `‹` / `›` buttons rendered an `aria-label` and an icon and carried no
`onClick`. They now scroll the visible window one period backwards/forwards at
that same tier, clamped to the ends of the timeline (ADR-0049 enforce-or-remove:
wiring is the branch the label change makes available). They step the label's
tier rather than one column, so a click always changes what the label says.

The band header is untouched. It was already correct; it is the reference here.
15 changes: 15 additions & 0 deletions packages/plugin-gantt/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,6 +444,21 @@ Drag snapping follows the active scale: bars snap to days in day view, weeks
in week view, and whole calendar months/quarters (duration preserved) in the
coarse views.

### The toolbar period label and its steppers

The label between the `‹` / `›` buttons names the period **currently on screen**,
not the extent of the data: it reads the date at the left edge of the viewport
and snaps it to the same tier the band header groups by — a month under day and
week view, a year under month and quarter view, a decade under year view, and
the shift-day under shift-segmented day view. So the label and the band header
directly beneath it always name the same period, and the label moves as the
chart is scrolled.

`‹` / `›` step the visible window by one of those periods (one month in day and
week view, one year in month and quarter view, a decade in year view), clamped
to the ends of the timeline. They step the *label's* tier rather than a single
column, so one click always changes what the label says.

Set the initial scale with `viewMode`. It is read through the gantt config, so
it needs the field mapping beside it (or a `gantt` block of its own):

Expand Down
223 changes: 223 additions & 0 deletions packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
/**
* objectui#7203 — the toolbar period label and its prev/next steppers.
*
* The label used to format `timelineRange.start`, the memo spanning the WHOLE
* dataset, so it named the first month of the result set and could not change
* at any scroll position — it was not a function of scroll position at all. On
* a dataset running Jan–Dec it therefore read "January 2026" four pixels above
* a band header correctly reading "Aug 2026". The two stepper buttons beside it
* rendered an aria-label and an icon and carried no onClick.
*
* The band header (`data-testid="gantt-header-groups"`) is the REFERENCE here,
* never the thing under test: these tests read the header cell that owns the
* pixel at the left edge of the viewport and require the toolbar to name the
* same month. Comparison is by month identity (`monthKey`), not by wording, so
* the toolbar staying on the fuller "August 2026" beside the header's compact
* "Aug 2026" is not what is being asserted either way.
*
* Scroll is honestly modelled in this environment: `GanttView.virtual.test.tsx`
* already pins that `timeline.scrollLeft = N` + `fireEvent.scroll` moves the
* column window to N (its assertion reads a rendered `style.left` back). The
* label derives from the same `scrollPos.left`, so these readings are real.
* Client sizes ARE 0 here, so the component falls back to a 4000px virtual
* viewport — every fixture below is wider than that, which is what makes the
* stepper's clamp non-trivial and the window genuinely scrollable.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { GanttView, type GanttTask } from './GanttView';

beforeEach(() => {
// >=1024 → columnWidth 110 (deterministic), matching the sibling suites.
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
});

function toolbarLabel(container: HTMLElement): string {
const el = container.querySelector('[data-testid="gantt-toolbar-period"]');
expect(el, 'toolbar period label is missing').toBeTruthy();
return el!.textContent!.trim();
}

function groupCells(container: HTMLElement) {
return Array.from(
container.querySelectorAll('[data-testid="gantt-header-groups"] > div'),
).map((node) => {
const el = node as HTMLElement;
return {
label: el.textContent!.trim(),
left: parseFloat(el.style.left),
width: parseFloat(el.style.width),
};
});
}

/** The band-header cell that owns pixel `x` — what the user reads at the left
* edge of the viewport, and the reference the toolbar has to agree with. */
function bandAt(container: HTMLElement, x: number) {
const hit = groupCells(container).find((c) => c.left <= x && x < c.left + c.width);
expect(hit, `no band-header cell owns x=${x}`).toBeTruthy();
return hit!;
}

/** `year-monthIndex` of a rendered month label, so "August 2026" (toolbar) and
* "Aug 2026" (band header) compare as the same month without either one's
* exact wording being asserted. */
function monthKey(label: string): string {
const d = new Date(label);
expect(Number.isNaN(d.getTime()), `unparseable month label: "${label}"`).toBe(false);
return `${d.getFullYear()}-${d.getMonth()}`;
}

function timelineOf(container: HTMLElement): HTMLElement {
return container.querySelector('[data-testid="gantt-timeline"]') as HTMLElement;
}

function scrollTo(container: HTMLElement, x: number) {
const timeline = timelineOf(container);
timeline.scrollLeft = x;
fireEvent.scroll(timeline);
return timeline;
}

function click(container: HTMLElement, testid: string) {
const btn = container.querySelector(`[data-testid="${testid}"]`);
expect(btn, `${testid} is missing`).toBeTruthy();
fireEvent.click(btn!);
}

/** The reporter's shape: a year of history, no explicit window, so
* `timelineRange` spans the whole dataset and starts in January. */
function yearOfTasks(): GanttTask[] {
return [
{ id: 'a', title: 'Task a', start: new Date(2026, 0, 31), end: new Date(2026, 1, 20), progress: 0 },
{ id: 'b', title: 'Task b', start: new Date(2026, 7, 26), end: new Date(2026, 8, 6), progress: 0 },
{ id: 'c', title: 'Task c', start: new Date(2026, 11, 1), end: new Date(2026, 11, 31), progress: 0 },
];
}

function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
return render(
<div style={{ width: 1280, height: 600 }}>
<GanttView tasks={yearOfTasks()} {...props} />
</div>,
);
}

describe('GanttView toolbar period label (objectui#7203)', () => {
it('names the month the band header names, at a scrolled position', () => {
const { container } = renderView();
// The dataset really does start in January — which is precisely why the old
// `timelineRange.start` label read January at EVERY scroll position.
expect(monthKey(toolbarLabel(container))).toBe('2026-0');

const x = 23430; // ~7 months in at 110px/day; anywhere past January will do
scrollTo(container, x);

const band = bandAt(container, x);
expect(monthKey(band.label), 'fixture did not actually leave January').not.toBe('2026-0');
expect(monthKey(toolbarLabel(container))).toBe(monthKey(band.label));
});

it('changes as the chart scrolls, and comes back', () => {
// The assertion that fails if the label is ever wired back to a whole-range
// memo: a static string passes any single-position check.
const { container } = renderView();
const atStart = toolbarLabel(container);

scrollTo(container, 23430);
expect(toolbarLabel(container)).not.toBe(atStart);

scrollTo(container, 0);
expect(toolbarLabel(container)).toBe(atStart);
});

it('agrees with the band header at every scroll position, week view included', () => {
// Week view is the straddle case: a week column can start in one month and
// end in the next. The header keys such a column by the month its START
// falls in, so the label has to snap the COLUMN, not the instant under the
// pixel — otherwise the two disagree for part of every straddling week.
const { container } = renderView({ viewMode: 'week' });
for (const x of [0, 300, 777, 1234, 2000, 3111, 4200]) {
scrollTo(container, x);
expect(monthKey(toolbarLabel(container)), `at x=${x}`).toBe(monthKey(bandAt(container, x).label));
}
});

it('still labels a single-month dataset correctly (control)', () => {
// The fix is not "read whatever the header says": with one band and no
// scrolling, the label is still derived, and still right.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2026, 5, 5), end: new Date(2026, 5, 20), progress: 0 }]}
startDate={new Date(2026, 5, 1)}
endDate={new Date(2026, 5, 30)}
/>
</div>,
);
const bands = groupCells(container);
expect(bands.length).toBe(1);
expect(monthKey(toolbarLabel(container))).toBe('2026-5');
expect(monthKey(bands[0].label)).toBe('2026-5');
});
});

describe('GanttView toolbar period steppers (objectui#7203)', () => {
it('steps the visible window one period per click, and the label follows', () => {
const { container } = renderView();
const timeline = timelineOf(container);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
expect(timeline.scrollLeft).toBe(0);

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
const afterNext = timeline.scrollLeft;
expect(afterNext).toBeGreaterThan(0);
expect(monthKey(bandAt(container, afterNext).label)).toBe('2026-1');

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-2');
expect(timeline.scrollLeft).toBeGreaterThan(afterNext);

click(container, 'gantt-toolbar-prev-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
expect(timeline.scrollLeft).toBe(afterNext);
});

it('clamps at the left edge instead of scrolling out of the range', () => {
const { container } = renderView();
const timeline = timelineOf(container);
// January is the first period; the timeline itself starts on 24 Jan, so the
// period start is off-grid to the left. Stepping back parks at 0.
click(container, 'gantt-toolbar-prev-period');
expect(timeline.scrollLeft).toBe(0);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
});

it('bands by year in month view, and steps one year per click', () => {
// "One unit of the current granularity" is the tier the band header groups
// by, not the column unit: day/week band by month, month/quarter by year,
// year by decade. Stepping a single month column in month view would leave
// the toolbar's own label unchanged for eleven clicks out of twelve.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2024, 0, 10), end: new Date(2028, 11, 20), progress: 0 }]}
startDate={new Date(2024, 0, 1)}
endDate={new Date(2028, 11, 31)}
viewMode="month"
/>
</div>,
);
expect(toolbarLabel(container)).toBe('2024');
expect(bandAt(container, 0).label).toBe('2024');

const timeline = timelineOf(container);
click(container, 'gantt-toolbar-next-period');
expect(toolbarLabel(container)).toBe('2025');
expect(timeline.scrollLeft).toBeGreaterThan(0);
expect(bandAt(container, timeline.scrollLeft).label).toBe('2025');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7203-gantt-toolbar-period-label.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/plugin-gantt': patch
---

Gantt toolbar: the period label names the visible window, and the prev/next
buttons step it (objectui#7203).

The label formatted `timelineRange.start` — the memo spanning the whole dataset
— so it named the first unit of the entire result set and could not change while
the chart was scrolled, because it was not derived from scroll position at all.
On a dataset running January to December it therefore read "January 2026" at
every scroll position, four pixels above a band header correctly reading
"Aug 2026". Measured on the demo fixture in Chromium at 1440x900: on first paint,
after the chart auto-scrolls to Today, the label read `December 2025` over
columns `28F 29S 30S 31M 1T 2W 3T` with the band beneath them reading `Aug 2026`.
Two month labels four pixels apart, disagreeing — and the wrong one is the
prominent one, so the chart reads as if the columns were mislabelled.

The label now names the period at the left edge of the viewport, snapped to the
same tier `headerGroups` bands the timeline by: a month under day and week view,
a year under month and quarter view, a decade under year view, the shift-day
under shift-segmented day view. The toolbar and the band header therefore agree
by construction rather than by two derivations that can drift. Wording is
unchanged for the month tier — the toolbar still spells the month out
("August 2026" beside the header's "Aug 2026").

The `‹` / `›` buttons rendered an `aria-label` and an icon and carried no
`onClick`. They now scroll the visible window one period backwards/forwards at
that same tier, clamped to the ends of the timeline (ADR-0049 enforce-or-remove:
wiring is the branch the label change makes available). They step the label's
tier rather than one column, so a click always changes what the label says.

The band header is untouched. It was already correct; it is the reference here.
15 changes: 15 additions & 0 deletions packages/plugin-gantt/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,6 +444,21 @@ Drag snapping follows the active scale: bars snap to days in day view, weeks
in week view, and whole calendar months/quarters (duration preserved) in the
coarse views.

### The toolbar period label and its steppers

The label between the `‹` / `›` buttons names the period **currently on screen**,
not the extent of the data: it reads the date at the left edge of the viewport
and snaps it to the same tier the band header groups by — a month under day and
week view, a year under month and quarter view, a decade under year view, and
the shift-day under shift-segmented day view. So the label and the band header
directly beneath it always name the same period, and the label moves as the
chart is scrolled.

`‹` / `›` step the visible window by one of those periods (one month in day and
week view, one year in month and quarter view, a decade in year view), clamped
to the ends of the timeline. They step the *label's* tier rather than a single
column, so one click always changes what the label says.

Set the initial scale with `viewMode`. It is read through the gantt config, so
it needs the field mapping beside it (or a `gantt` block of its own):

Expand Down
223 changes: 223 additions & 0 deletions packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
/**
* objectui#7203 — the toolbar period label and its prev/next steppers.
*
* The label used to format `timelineRange.start`, the memo spanning the WHOLE
* dataset, so it named the first month of the result set and could not change
* at any scroll position — it was not a function of scroll position at all. On
* a dataset running Jan–Dec it therefore read "January 2026" four pixels above
* a band header correctly reading "Aug 2026". The two stepper buttons beside it
* rendered an aria-label and an icon and carried no onClick.
*
* The band header (`data-testid="gantt-header-groups"`) is the REFERENCE here,
* never the thing under test: these tests read the header cell that owns the
* pixel at the left edge of the viewport and require the toolbar to name the
* same month. Comparison is by month identity (`monthKey`), not by wording, so
* the toolbar staying on the fuller "August 2026" beside the header's compact
* "Aug 2026" is not what is being asserted either way.
*
* Scroll is honestly modelled in this environment: `GanttView.virtual.test.tsx`
* already pins that `timeline.scrollLeft = N` + `fireEvent.scroll` moves the
* column window to N (its assertion reads a rendered `style.left` back). The
* label derives from the same `scrollPos.left`, so these readings are real.
* Client sizes ARE 0 here, so the component falls back to a 4000px virtual
* viewport — every fixture below is wider than that, which is what makes the
* stepper's clamp non-trivial and the window genuinely scrollable.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { GanttView, type GanttTask } from './GanttView';

beforeEach(() => {
// >=1024 → columnWidth 110 (deterministic), matching the sibling suites.
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
});

function toolbarLabel(container: HTMLElement): string {
const el = container.querySelector('[data-testid="gantt-toolbar-period"]');
expect(el, 'toolbar period label is missing').toBeTruthy();
return el!.textContent!.trim();
}

function groupCells(container: HTMLElement) {
return Array.from(
container.querySelectorAll('[data-testid="gantt-header-groups"] > div'),
).map((node) => {
const el = node as HTMLElement;
return {
label: el.textContent!.trim(),
left: parseFloat(el.style.left),
width: parseFloat(el.style.width),
};
});
}

/** The band-header cell that owns pixel `x` — what the user reads at the left
* edge of the viewport, and the reference the toolbar has to agree with. */
function bandAt(container: HTMLElement, x: number) {
const hit = groupCells(container).find((c) => c.left <= x && x < c.left + c.width);
expect(hit, `no band-header cell owns x=${x}`).toBeTruthy();
return hit!;
}

/** `year-monthIndex` of a rendered month label, so "August 2026" (toolbar) and
* "Aug 2026" (band header) compare as the same month without either one's
* exact wording being asserted. */
function monthKey(label: string): string {
const d = new Date(label);
expect(Number.isNaN(d.getTime()), `unparseable month label: "${label}"`).toBe(false);
return `${d.getFullYear()}-${d.getMonth()}`;
}

function timelineOf(container: HTMLElement): HTMLElement {
return container.querySelector('[data-testid="gantt-timeline"]') as HTMLElement;
}

function scrollTo(container: HTMLElement, x: number) {
const timeline = timelineOf(container);
timeline.scrollLeft = x;
fireEvent.scroll(timeline);
return timeline;
}

function click(container: HTMLElement, testid: string) {
const btn = container.querySelector(`[data-testid="${testid}"]`);
expect(btn, `${testid} is missing`).toBeTruthy();
fireEvent.click(btn!);
}

/** The reporter's shape: a year of history, no explicit window, so
* `timelineRange` spans the whole dataset and starts in January. */
function yearOfTasks(): GanttTask[] {
return [
{ id: 'a', title: 'Task a', start: new Date(2026, 0, 31), end: new Date(2026, 1, 20), progress: 0 },
{ id: 'b', title: 'Task b', start: new Date(2026, 7, 26), end: new Date(2026, 8, 6), progress: 0 },
{ id: 'c', title: 'Task c', start: new Date(2026, 11, 1), end: new Date(2026, 11, 31), progress: 0 },
];
}

function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
return render(
<div style={{ width: 1280, height: 600 }}>
<GanttView tasks={yearOfTasks()} {...props} />
</div>,
);
}

describe('GanttView toolbar period label (objectui#7203)', () => {
it('names the month the band header names, at a scrolled position', () => {
const { container } = renderView();
// The dataset really does start in January — which is precisely why the old
// `timelineRange.start` label read January at EVERY scroll position.
expect(monthKey(toolbarLabel(container))).toBe('2026-0');

const x = 23430; // ~7 months in at 110px/day; anywhere past January will do
scrollTo(container, x);

const band = bandAt(container, x);
expect(monthKey(band.label), 'fixture did not actually leave January').not.toBe('2026-0');
expect(monthKey(toolbarLabel(container))).toBe(monthKey(band.label));
});

it('changes as the chart scrolls, and comes back', () => {
// The assertion that fails if the label is ever wired back to a whole-range
// memo: a static string passes any single-position check.
const { container } = renderView();
const atStart = toolbarLabel(container);

scrollTo(container, 23430);
expect(toolbarLabel(container)).not.toBe(atStart);

scrollTo(container, 0);
expect(toolbarLabel(container)).toBe(atStart);
});

it('agrees with the band header at every scroll position, week view included', () => {
// Week view is the straddle case: a week column can start in one month and
// end in the next. The header keys such a column by the month its START
// falls in, so the label has to snap the COLUMN, not the instant under the
// pixel — otherwise the two disagree for part of every straddling week.
const { container } = renderView({ viewMode: 'week' });
for (const x of [0, 300, 777, 1234, 2000, 3111, 4200]) {
scrollTo(container, x);
expect(monthKey(toolbarLabel(container)), `at x=${x}`).toBe(monthKey(bandAt(container, x).label));
}
});

it('still labels a single-month dataset correctly (control)', () => {
// The fix is not "read whatever the header says": with one band and no
// scrolling, the label is still derived, and still right.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2026, 5, 5), end: new Date(2026, 5, 20), progress: 0 }]}
startDate={new Date(2026, 5, 1)}
endDate={new Date(2026, 5, 30)}
/>
</div>,
);
const bands = groupCells(container);
expect(bands.length).toBe(1);
expect(monthKey(toolbarLabel(container))).toBe('2026-5');
expect(monthKey(bands[0].label)).toBe('2026-5');
});
});

describe('GanttView toolbar period steppers (objectui#7203)', () => {
it('steps the visible window one period per click, and the label follows', () => {
const { container } = renderView();
const timeline = timelineOf(container);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
expect(timeline.scrollLeft).toBe(0);

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
const afterNext = timeline.scrollLeft;
expect(afterNext).toBeGreaterThan(0);
expect(monthKey(bandAt(container, afterNext).label)).toBe('2026-1');

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-2');
expect(timeline.scrollLeft).toBeGreaterThan(afterNext);

click(container, 'gantt-toolbar-prev-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
expect(timeline.scrollLeft).toBe(afterNext);
});

it('clamps at the left edge instead of scrolling out of the range', () => {
const { container } = renderView();
const timeline = timelineOf(container);
// January is the first period; the timeline itself starts on 24 Jan, so the
// period start is off-grid to the left. Stepping back parks at 0.
click(container, 'gantt-toolbar-prev-period');
expect(timeline.scrollLeft).toBe(0);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
});

it('bands by year in month view, and steps one year per click', () => {
// "One unit of the current granularity" is the tier the band header groups
// by, not the column unit: day/week band by month, month/quarter by year,
// year by decade. Stepping a single month column in month view would leave
// the toolbar's own label unchanged for eleven clicks out of twelve.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2024, 0, 10), end: new Date(2028, 11, 20), progress: 0 }]}
startDate={new Date(2024, 0, 1)}
endDate={new Date(2028, 11, 31)}
viewMode="month"
/>
</div>,
);
expect(toolbarLabel(container)).toBe('2024');
expect(bandAt(container, 0).label).toBe('2024');

const timeline = timelineOf(container);
click(container, 'gantt-toolbar-next-period');
expect(toolbarLabel(container)).toBe('2025');
expect(timeline.scrollLeft).toBeGreaterThan(0);
expect(bandAt(container, timeline.scrollLeft).label).toBe('2025');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7203-gantt-toolbar-period-label.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/plugin-gantt': patch
---

Gantt toolbar: the period label names the visible window, and the prev/next
buttons step it (objectui#7203).

The label formatted `timelineRange.start` — the memo spanning the whole dataset
— so it named the first unit of the entire result set and could not change while
the chart was scrolled, because it was not derived from scroll position at all.
On a dataset running January to December it therefore read "January 2026" at
every scroll position, four pixels above a band header correctly reading
"Aug 2026". Measured on the demo fixture in Chromium at 1440x900: on first paint,
after the chart auto-scrolls to Today, the label read `December 2025` over
columns `28F 29S 30S 31M 1T 2W 3T` with the band beneath them reading `Aug 2026`.
Two month labels four pixels apart, disagreeing — and the wrong one is the
prominent one, so the chart reads as if the columns were mislabelled.

The label now names the period at the left edge of the viewport, snapped to the
same tier `headerGroups` bands the timeline by: a month under day and week view,
a year under month and quarter view, a decade under year view, the shift-day
under shift-segmented day view. The toolbar and the band header therefore agree
by construction rather than by two derivations that can drift. Wording is
unchanged for the month tier — the toolbar still spells the month out
("August 2026" beside the header's "Aug 2026").

The `‹` / `›` buttons rendered an `aria-label` and an icon and carried no
`onClick`. They now scroll the visible window one period backwards/forwards at
that same tier, clamped to the ends of the timeline (ADR-0049 enforce-or-remove:
wiring is the branch the label change makes available). They step the label's
tier rather than one column, so a click always changes what the label says.

The band header is untouched. It was already correct; it is the reference here.
15 changes: 15 additions & 0 deletions packages/plugin-gantt/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,6 +444,21 @@ Drag snapping follows the active scale: bars snap to days in day view, weeks
in week view, and whole calendar months/quarters (duration preserved) in the
coarse views.

### The toolbar period label and its steppers

The label between the `‹` / `›` buttons names the period **currently on screen**,
not the extent of the data: it reads the date at the left edge of the viewport
and snaps it to the same tier the band header groups by — a month under day and
week view, a year under month and quarter view, a decade under year view, and
the shift-day under shift-segmented day view. So the label and the band header
directly beneath it always name the same period, and the label moves as the
chart is scrolled.

`‹` / `›` step the visible window by one of those periods (one month in day and
week view, one year in month and quarter view, a decade in year view), clamped
to the ends of the timeline. They step the *label's* tier rather than a single
column, so one click always changes what the label says.

Set the initial scale with `viewMode`. It is read through the gantt config, so
it needs the field mapping beside it (or a `gantt` block of its own):

Expand Down
223 changes: 223 additions & 0 deletions packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
/**
* objectui#7203 — the toolbar period label and its prev/next steppers.
*
* The label used to format `timelineRange.start`, the memo spanning the WHOLE
* dataset, so it named the first month of the result set and could not change
* at any scroll position — it was not a function of scroll position at all. On
* a dataset running Jan–Dec it therefore read "January 2026" four pixels above
* a band header correctly reading "Aug 2026". The two stepper buttons beside it
* rendered an aria-label and an icon and carried no onClick.
*
* The band header (`data-testid="gantt-header-groups"`) is the REFERENCE here,
* never the thing under test: these tests read the header cell that owns the
* pixel at the left edge of the viewport and require the toolbar to name the
* same month. Comparison is by month identity (`monthKey`), not by wording, so
* the toolbar staying on the fuller "August 2026" beside the header's compact
* "Aug 2026" is not what is being asserted either way.
*
* Scroll is honestly modelled in this environment: `GanttView.virtual.test.tsx`
* already pins that `timeline.scrollLeft = N` + `fireEvent.scroll` moves the
* column window to N (its assertion reads a rendered `style.left` back). The
* label derives from the same `scrollPos.left`, so these readings are real.
* Client sizes ARE 0 here, so the component falls back to a 4000px virtual
* viewport — every fixture below is wider than that, which is what makes the
* stepper's clamp non-trivial and the window genuinely scrollable.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { GanttView, type GanttTask } from './GanttView';

beforeEach(() => {
// >=1024 → columnWidth 110 (deterministic), matching the sibling suites.
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
});

function toolbarLabel(container: HTMLElement): string {
const el = container.querySelector('[data-testid="gantt-toolbar-period"]');
expect(el, 'toolbar period label is missing').toBeTruthy();
return el!.textContent!.trim();
}

function groupCells(container: HTMLElement) {
return Array.from(
container.querySelectorAll('[data-testid="gantt-header-groups"] > div'),
).map((node) => {
const el = node as HTMLElement;
return {
label: el.textContent!.trim(),
left: parseFloat(el.style.left),
width: parseFloat(el.style.width),
};
});
}

/** The band-header cell that owns pixel `x` — what the user reads at the left
* edge of the viewport, and the reference the toolbar has to agree with. */
function bandAt(container: HTMLElement, x: number) {
const hit = groupCells(container).find((c) => c.left <= x && x < c.left + c.width);
expect(hit, `no band-header cell owns x=${x}`).toBeTruthy();
return hit!;
}

/** `year-monthIndex` of a rendered month label, so "August 2026" (toolbar) and
* "Aug 2026" (band header) compare as the same month without either one's
* exact wording being asserted. */
function monthKey(label: string): string {
const d = new Date(label);
expect(Number.isNaN(d.getTime()), `unparseable month label: "${label}"`).toBe(false);
return `${d.getFullYear()}-${d.getMonth()}`;
}

function timelineOf(container: HTMLElement): HTMLElement {
return container.querySelector('[data-testid="gantt-timeline"]') as HTMLElement;
}

function scrollTo(container: HTMLElement, x: number) {
const timeline = timelineOf(container);
timeline.scrollLeft = x;
fireEvent.scroll(timeline);
return timeline;
}

function click(container: HTMLElement, testid: string) {
const btn = container.querySelector(`[data-testid="${testid}"]`);
expect(btn, `${testid} is missing`).toBeTruthy();
fireEvent.click(btn!);
}

/** The reporter's shape: a year of history, no explicit window, so
* `timelineRange` spans the whole dataset and starts in January. */
function yearOfTasks(): GanttTask[] {
return [
{ id: 'a', title: 'Task a', start: new Date(2026, 0, 31), end: new Date(2026, 1, 20), progress: 0 },
{ id: 'b', title: 'Task b', start: new Date(2026, 7, 26), end: new Date(2026, 8, 6), progress: 0 },
{ id: 'c', title: 'Task c', start: new Date(2026, 11, 1), end: new Date(2026, 11, 31), progress: 0 },
];
}

function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
return render(
<div style={{ width: 1280, height: 600 }}>
<GanttView tasks={yearOfTasks()} {...props} />
</div>,
);
}

describe('GanttView toolbar period label (objectui#7203)', () => {
it('names the month the band header names, at a scrolled position', () => {
const { container } = renderView();
// The dataset really does start in January — which is precisely why the old
// `timelineRange.start` label read January at EVERY scroll position.
expect(monthKey(toolbarLabel(container))).toBe('2026-0');

const x = 23430; // ~7 months in at 110px/day; anywhere past January will do
scrollTo(container, x);

const band = bandAt(container, x);
expect(monthKey(band.label), 'fixture did not actually leave January').not.toBe('2026-0');
expect(monthKey(toolbarLabel(container))).toBe(monthKey(band.label));
});

it('changes as the chart scrolls, and comes back', () => {
// The assertion that fails if the label is ever wired back to a whole-range
// memo: a static string passes any single-position check.
const { container } = renderView();
const atStart = toolbarLabel(container);

scrollTo(container, 23430);
expect(toolbarLabel(container)).not.toBe(atStart);

scrollTo(container, 0);
expect(toolbarLabel(container)).toBe(atStart);
});

it('agrees with the band header at every scroll position, week view included', () => {
// Week view is the straddle case: a week column can start in one month and
// end in the next. The header keys such a column by the month its START
// falls in, so the label has to snap the COLUMN, not the instant under the
// pixel — otherwise the two disagree for part of every straddling week.
const { container } = renderView({ viewMode: 'week' });
for (const x of [0, 300, 777, 1234, 2000, 3111, 4200]) {
scrollTo(container, x);
expect(monthKey(toolbarLabel(container)), `at x=${x}`).toBe(monthKey(bandAt(container, x).label));
}
});

it('still labels a single-month dataset correctly (control)', () => {
// The fix is not "read whatever the header says": with one band and no
// scrolling, the label is still derived, and still right.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2026, 5, 5), end: new Date(2026, 5, 20), progress: 0 }]}
startDate={new Date(2026, 5, 1)}
endDate={new Date(2026, 5, 30)}
/>
</div>,
);
const bands = groupCells(container);
expect(bands.length).toBe(1);
expect(monthKey(toolbarLabel(container))).toBe('2026-5');
expect(monthKey(bands[0].label)).toBe('2026-5');
});
});

describe('GanttView toolbar period steppers (objectui#7203)', () => {
it('steps the visible window one period per click, and the label follows', () => {
const { container } = renderView();
const timeline = timelineOf(container);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
expect(timeline.scrollLeft).toBe(0);

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
const afterNext = timeline.scrollLeft;
expect(afterNext).toBeGreaterThan(0);
expect(monthKey(bandAt(container, afterNext).label)).toBe('2026-1');

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-2');
expect(timeline.scrollLeft).toBeGreaterThan(afterNext);

click(container, 'gantt-toolbar-prev-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
expect(timeline.scrollLeft).toBe(afterNext);
});

it('clamps at the left edge instead of scrolling out of the range', () => {
const { container } = renderView();
const timeline = timelineOf(container);
// January is the first period; the timeline itself starts on 24 Jan, so the
// period start is off-grid to the left. Stepping back parks at 0.
click(container, 'gantt-toolbar-prev-period');
expect(timeline.scrollLeft).toBe(0);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
});

it('bands by year in month view, and steps one year per click', () => {
// "One unit of the current granularity" is the tier the band header groups
// by, not the column unit: day/week band by month, month/quarter by year,
// year by decade. Stepping a single month column in month view would leave
// the toolbar's own label unchanged for eleven clicks out of twelve.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2024, 0, 10), end: new Date(2028, 11, 20), progress: 0 }]}
startDate={new Date(2024, 0, 1)}
endDate={new Date(2028, 11, 31)}
viewMode="month"
/>
</div>,
);
expect(toolbarLabel(container)).toBe('2024');
expect(bandAt(container, 0).label).toBe('2024');

const timeline = timelineOf(container);
click(container, 'gantt-toolbar-next-period');
expect(toolbarLabel(container)).toBe('2025');
expect(timeline.scrollLeft).toBeGreaterThan(0);
expect(bandAt(container, timeline.scrollLeft).label).toBe('2025');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7203-gantt-toolbar-period-label.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/plugin-gantt': patch
---

Gantt toolbar: the period label names the visible window, and the prev/next
buttons step it (objectui#7203).

The label formatted `timelineRange.start` — the memo spanning the whole dataset
— so it named the first unit of the entire result set and could not change while
the chart was scrolled, because it was not derived from scroll position at all.
On a dataset running January to December it therefore read "January 2026" at
every scroll position, four pixels above a band header correctly reading
"Aug 2026". Measured on the demo fixture in Chromium at 1440x900: on first paint,
after the chart auto-scrolls to Today, the label read `December 2025` over
columns `28F 29S 30S 31M 1T 2W 3T` with the band beneath them reading `Aug 2026`.
Two month labels four pixels apart, disagreeing — and the wrong one is the
prominent one, so the chart reads as if the columns were mislabelled.

The label now names the period at the left edge of the viewport, snapped to the
same tier `headerGroups` bands the timeline by: a month under day and week view,
a year under month and quarter view, a decade under year view, the shift-day
under shift-segmented day view. The toolbar and the band header therefore agree
by construction rather than by two derivations that can drift. Wording is
unchanged for the month tier — the toolbar still spells the month out
("August 2026" beside the header's "Aug 2026").

The `‹` / `›` buttons rendered an `aria-label` and an icon and carried no
`onClick`. They now scroll the visible window one period backwards/forwards at
that same tier, clamped to the ends of the timeline (ADR-0049 enforce-or-remove:
wiring is the branch the label change makes available). They step the label's
tier rather than one column, so a click always changes what the label says.

The band header is untouched. It was already correct; it is the reference here.
15 changes: 15 additions & 0 deletions packages/plugin-gantt/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,6 +444,21 @@ Drag snapping follows the active scale: bars snap to days in day view, weeks
in week view, and whole calendar months/quarters (duration preserved) in the
coarse views.

### The toolbar period label and its steppers

The label between the `‹` / `›` buttons names the period **currently on screen**,
not the extent of the data: it reads the date at the left edge of the viewport
and snaps it to the same tier the band header groups by — a month under day and
week view, a year under month and quarter view, a decade under year view, and
the shift-day under shift-segmented day view. So the label and the band header
directly beneath it always name the same period, and the label moves as the
chart is scrolled.

`‹` / `›` step the visible window by one of those periods (one month in day and
week view, one year in month and quarter view, a decade in year view), clamped
to the ends of the timeline. They step the *label's* tier rather than a single
column, so one click always changes what the label says.

Set the initial scale with `viewMode`. It is read through the gantt config, so
it needs the field mapping beside it (or a `gantt` block of its own):

Expand Down
223 changes: 223 additions & 0 deletions packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
/**
* objectui#7203 — the toolbar period label and its prev/next steppers.
*
* The label used to format `timelineRange.start`, the memo spanning the WHOLE
* dataset, so it named the first month of the result set and could not change
* at any scroll position — it was not a function of scroll position at all. On
* a dataset running Jan–Dec it therefore read "January 2026" four pixels above
* a band header correctly reading "Aug 2026". The two stepper buttons beside it
* rendered an aria-label and an icon and carried no onClick.
*
* The band header (`data-testid="gantt-header-groups"`) is the REFERENCE here,
* never the thing under test: these tests read the header cell that owns the
* pixel at the left edge of the viewport and require the toolbar to name the
* same month. Comparison is by month identity (`monthKey`), not by wording, so
* the toolbar staying on the fuller "August 2026" beside the header's compact
* "Aug 2026" is not what is being asserted either way.
*
* Scroll is honestly modelled in this environment: `GanttView.virtual.test.tsx`
* already pins that `timeline.scrollLeft = N` + `fireEvent.scroll` moves the
* column window to N (its assertion reads a rendered `style.left` back). The
* label derives from the same `scrollPos.left`, so these readings are real.
* Client sizes ARE 0 here, so the component falls back to a 4000px virtual
* viewport — every fixture below is wider than that, which is what makes the
* stepper's clamp non-trivial and the window genuinely scrollable.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { GanttView, type GanttTask } from './GanttView';

beforeEach(() => {
// >=1024 → columnWidth 110 (deterministic), matching the sibling suites.
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
});

function toolbarLabel(container: HTMLElement): string {
const el = container.querySelector('[data-testid="gantt-toolbar-period"]');
expect(el, 'toolbar period label is missing').toBeTruthy();
return el!.textContent!.trim();
}

function groupCells(container: HTMLElement) {
return Array.from(
container.querySelectorAll('[data-testid="gantt-header-groups"] > div'),
).map((node) => {
const el = node as HTMLElement;
return {
label: el.textContent!.trim(),
left: parseFloat(el.style.left),
width: parseFloat(el.style.width),
};
});
}

/** The band-header cell that owns pixel `x` — what the user reads at the left
* edge of the viewport, and the reference the toolbar has to agree with. */
function bandAt(container: HTMLElement, x: number) {
const hit = groupCells(container).find((c) => c.left <= x && x < c.left + c.width);
expect(hit, `no band-header cell owns x=${x}`).toBeTruthy();
return hit!;
}

/** `year-monthIndex` of a rendered month label, so "August 2026" (toolbar) and
* "Aug 2026" (band header) compare as the same month without either one's
* exact wording being asserted. */
function monthKey(label: string): string {
const d = new Date(label);
expect(Number.isNaN(d.getTime()), `unparseable month label: "${label}"`).toBe(false);
return `${d.getFullYear()}-${d.getMonth()}`;
}

function timelineOf(container: HTMLElement): HTMLElement {
return container.querySelector('[data-testid="gantt-timeline"]') as HTMLElement;
}

function scrollTo(container: HTMLElement, x: number) {
const timeline = timelineOf(container);
timeline.scrollLeft = x;
fireEvent.scroll(timeline);
return timeline;
}

function click(container: HTMLElement, testid: string) {
const btn = container.querySelector(`[data-testid="${testid}"]`);
expect(btn, `${testid} is missing`).toBeTruthy();
fireEvent.click(btn!);
}

/** The reporter's shape: a year of history, no explicit window, so
* `timelineRange` spans the whole dataset and starts in January. */
function yearOfTasks(): GanttTask[] {
return [
{ id: 'a', title: 'Task a', start: new Date(2026, 0, 31), end: new Date(2026, 1, 20), progress: 0 },
{ id: 'b', title: 'Task b', start: new Date(2026, 7, 26), end: new Date(2026, 8, 6), progress: 0 },
{ id: 'c', title: 'Task c', start: new Date(2026, 11, 1), end: new Date(2026, 11, 31), progress: 0 },
];
}

function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
return render(
<div style={{ width: 1280, height: 600 }}>
<GanttView tasks={yearOfTasks()} {...props} />
</div>,
);
}

describe('GanttView toolbar period label (objectui#7203)', () => {
it('names the month the band header names, at a scrolled position', () => {
const { container } = renderView();
// The dataset really does start in January — which is precisely why the old
// `timelineRange.start` label read January at EVERY scroll position.
expect(monthKey(toolbarLabel(container))).toBe('2026-0');

const x = 23430; // ~7 months in at 110px/day; anywhere past January will do
scrollTo(container, x);

const band = bandAt(container, x);
expect(monthKey(band.label), 'fixture did not actually leave January').not.toBe('2026-0');
expect(monthKey(toolbarLabel(container))).toBe(monthKey(band.label));
});

it('changes as the chart scrolls, and comes back', () => {
// The assertion that fails if the label is ever wired back to a whole-range
// memo: a static string passes any single-position check.
const { container } = renderView();
const atStart = toolbarLabel(container);

scrollTo(container, 23430);
expect(toolbarLabel(container)).not.toBe(atStart);

scrollTo(container, 0);
expect(toolbarLabel(container)).toBe(atStart);
});

it('agrees with the band header at every scroll position, week view included', () => {
// Week view is the straddle case: a week column can start in one month and
// end in the next. The header keys such a column by the month its START
// falls in, so the label has to snap the COLUMN, not the instant under the
// pixel — otherwise the two disagree for part of every straddling week.
const { container } = renderView({ viewMode: 'week' });
for (const x of [0, 300, 777, 1234, 2000, 3111, 4200]) {
scrollTo(container, x);
expect(monthKey(toolbarLabel(container)), `at x=${x}`).toBe(monthKey(bandAt(container, x).label));
}
});

it('still labels a single-month dataset correctly (control)', () => {
// The fix is not "read whatever the header says": with one band and no
// scrolling, the label is still derived, and still right.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2026, 5, 5), end: new Date(2026, 5, 20), progress: 0 }]}
startDate={new Date(2026, 5, 1)}
endDate={new Date(2026, 5, 30)}
/>
</div>,
);
const bands = groupCells(container);
expect(bands.length).toBe(1);
expect(monthKey(toolbarLabel(container))).toBe('2026-5');
expect(monthKey(bands[0].label)).toBe('2026-5');
});
});

describe('GanttView toolbar period steppers (objectui#7203)', () => {
it('steps the visible window one period per click, and the label follows', () => {
const { container } = renderView();
const timeline = timelineOf(container);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
expect(timeline.scrollLeft).toBe(0);

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
const afterNext = timeline.scrollLeft;
expect(afterNext).toBeGreaterThan(0);
expect(monthKey(bandAt(container, afterNext).label)).toBe('2026-1');

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-2');
expect(timeline.scrollLeft).toBeGreaterThan(afterNext);

click(container, 'gantt-toolbar-prev-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
expect(timeline.scrollLeft).toBe(afterNext);
});

it('clamps at the left edge instead of scrolling out of the range', () => {
const { container } = renderView();
const timeline = timelineOf(container);
// January is the first period; the timeline itself starts on 24 Jan, so the
// period start is off-grid to the left. Stepping back parks at 0.
click(container, 'gantt-toolbar-prev-period');
expect(timeline.scrollLeft).toBe(0);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
});

it('bands by year in month view, and steps one year per click', () => {
// "One unit of the current granularity" is the tier the band header groups
// by, not the column unit: day/week band by month, month/quarter by year,
// year by decade. Stepping a single month column in month view would leave
// the toolbar's own label unchanged for eleven clicks out of twelve.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2024, 0, 10), end: new Date(2028, 11, 20), progress: 0 }]}
startDate={new Date(2024, 0, 1)}
endDate={new Date(2028, 11, 31)}
viewMode="month"
/>
</div>,
);
expect(toolbarLabel(container)).toBe('2024');
expect(bandAt(container, 0).label).toBe('2024');

const timeline = timelineOf(container);
click(container, 'gantt-toolbar-next-period');
expect(toolbarLabel(container)).toBe('2025');
expect(timeline.scrollLeft).toBeGreaterThan(0);
expect(bandAt(container, timeline.scrollLeft).label).toBe('2025');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7203-gantt-toolbar-period-label.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/plugin-gantt': patch
---

Gantt toolbar: the period label names the visible window, and the prev/next
buttons step it (objectui#7203).

The label formatted `timelineRange.start` — the memo spanning the whole dataset
— so it named the first unit of the entire result set and could not change while
the chart was scrolled, because it was not derived from scroll position at all.
On a dataset running January to December it therefore read "January 2026" at
every scroll position, four pixels above a band header correctly reading
"Aug 2026". Measured on the demo fixture in Chromium at 1440x900: on first paint,
after the chart auto-scrolls to Today, the label read `December 2025` over
columns `28F 29S 30S 31M 1T 2W 3T` with the band beneath them reading `Aug 2026`.
Two month labels four pixels apart, disagreeing — and the wrong one is the
prominent one, so the chart reads as if the columns were mislabelled.

The label now names the period at the left edge of the viewport, snapped to the
same tier `headerGroups` bands the timeline by: a month under day and week view,
a year under month and quarter view, a decade under year view, the shift-day
under shift-segmented day view. The toolbar and the band header therefore agree
by construction rather than by two derivations that can drift. Wording is
unchanged for the month tier — the toolbar still spells the month out
("August 2026" beside the header's "Aug 2026").

The `‹` / `›` buttons rendered an `aria-label` and an icon and carried no
`onClick`. They now scroll the visible window one period backwards/forwards at
that same tier, clamped to the ends of the timeline (ADR-0049 enforce-or-remove:
wiring is the branch the label change makes available). They step the label's
tier rather than one column, so a click always changes what the label says.

The band header is untouched. It was already correct; it is the reference here.
15 changes: 15 additions & 0 deletions packages/plugin-gantt/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,6 +444,21 @@ Drag snapping follows the active scale: bars snap to days in day view, weeks
in week view, and whole calendar months/quarters (duration preserved) in the
coarse views.

### The toolbar period label and its steppers

The label between the `‹` / `›` buttons names the period **currently on screen**,
not the extent of the data: it reads the date at the left edge of the viewport
and snaps it to the same tier the band header groups by — a month under day and
week view, a year under month and quarter view, a decade under year view, and
the shift-day under shift-segmented day view. So the label and the band header
directly beneath it always name the same period, and the label moves as the
chart is scrolled.

`‹` / `›` step the visible window by one of those periods (one month in day and
week view, one year in month and quarter view, a decade in year view), clamped
to the ends of the timeline. They step the *label's* tier rather than a single
column, so one click always changes what the label says.

Set the initial scale with `viewMode`. It is read through the gantt config, so
it needs the field mapping beside it (or a `gantt` block of its own):

Expand Down
223 changes: 223 additions & 0 deletions packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
/**
* objectui#7203 — the toolbar period label and its prev/next steppers.
*
* The label used to format `timelineRange.start`, the memo spanning the WHOLE
* dataset, so it named the first month of the result set and could not change
* at any scroll position — it was not a function of scroll position at all. On
* a dataset running Jan–Dec it therefore read "January 2026" four pixels above
* a band header correctly reading "Aug 2026". The two stepper buttons beside it
* rendered an aria-label and an icon and carried no onClick.
*
* The band header (`data-testid="gantt-header-groups"`) is the REFERENCE here,
* never the thing under test: these tests read the header cell that owns the
* pixel at the left edge of the viewport and require the toolbar to name the
* same month. Comparison is by month identity (`monthKey`), not by wording, so
* the toolbar staying on the fuller "August 2026" beside the header's compact
* "Aug 2026" is not what is being asserted either way.
*
* Scroll is honestly modelled in this environment: `GanttView.virtual.test.tsx`
* already pins that `timeline.scrollLeft = N` + `fireEvent.scroll` moves the
* column window to N (its assertion reads a rendered `style.left` back). The
* label derives from the same `scrollPos.left`, so these readings are real.
* Client sizes ARE 0 here, so the component falls back to a 4000px virtual
* viewport — every fixture below is wider than that, which is what makes the
* stepper's clamp non-trivial and the window genuinely scrollable.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { GanttView, type GanttTask } from './GanttView';

beforeEach(() => {
// >=1024 → columnWidth 110 (deterministic), matching the sibling suites.
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
});

function toolbarLabel(container: HTMLElement): string {
const el = container.querySelector('[data-testid="gantt-toolbar-period"]');
expect(el, 'toolbar period label is missing').toBeTruthy();
return el!.textContent!.trim();
}

function groupCells(container: HTMLElement) {
return Array.from(
container.querySelectorAll('[data-testid="gantt-header-groups"] > div'),
).map((node) => {
const el = node as HTMLElement;
return {
label: el.textContent!.trim(),
left: parseFloat(el.style.left),
width: parseFloat(el.style.width),
};
});
}

/** The band-header cell that owns pixel `x` — what the user reads at the left
* edge of the viewport, and the reference the toolbar has to agree with. */
function bandAt(container: HTMLElement, x: number) {
const hit = groupCells(container).find((c) => c.left <= x && x < c.left + c.width);
expect(hit, `no band-header cell owns x=${x}`).toBeTruthy();
return hit!;
}

/** `year-monthIndex` of a rendered month label, so "August 2026" (toolbar) and
* "Aug 2026" (band header) compare as the same month without either one's
* exact wording being asserted. */
function monthKey(label: string): string {
const d = new Date(label);
expect(Number.isNaN(d.getTime()), `unparseable month label: "${label}"`).toBe(false);
return `${d.getFullYear()}-${d.getMonth()}`;
}

function timelineOf(container: HTMLElement): HTMLElement {
return container.querySelector('[data-testid="gantt-timeline"]') as HTMLElement;
}

function scrollTo(container: HTMLElement, x: number) {
const timeline = timelineOf(container);
timeline.scrollLeft = x;
fireEvent.scroll(timeline);
return timeline;
}

function click(container: HTMLElement, testid: string) {
const btn = container.querySelector(`[data-testid="${testid}"]`);
expect(btn, `${testid} is missing`).toBeTruthy();
fireEvent.click(btn!);
}

/** The reporter's shape: a year of history, no explicit window, so
* `timelineRange` spans the whole dataset and starts in January. */
function yearOfTasks(): GanttTask[] {
return [
{ id: 'a', title: 'Task a', start: new Date(2026, 0, 31), end: new Date(2026, 1, 20), progress: 0 },
{ id: 'b', title: 'Task b', start: new Date(2026, 7, 26), end: new Date(2026, 8, 6), progress: 0 },
{ id: 'c', title: 'Task c', start: new Date(2026, 11, 1), end: new Date(2026, 11, 31), progress: 0 },
];
}

function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
return render(
<div style={{ width: 1280, height: 600 }}>
<GanttView tasks={yearOfTasks()} {...props} />
</div>,
);
}

describe('GanttView toolbar period label (objectui#7203)', () => {
it('names the month the band header names, at a scrolled position', () => {
const { container } = renderView();
// The dataset really does start in January — which is precisely why the old
// `timelineRange.start` label read January at EVERY scroll position.
expect(monthKey(toolbarLabel(container))).toBe('2026-0');

const x = 23430; // ~7 months in at 110px/day; anywhere past January will do
scrollTo(container, x);

const band = bandAt(container, x);
expect(monthKey(band.label), 'fixture did not actually leave January').not.toBe('2026-0');
expect(monthKey(toolbarLabel(container))).toBe(monthKey(band.label));
});

it('changes as the chart scrolls, and comes back', () => {
// The assertion that fails if the label is ever wired back to a whole-range
// memo: a static string passes any single-position check.
const { container } = renderView();
const atStart = toolbarLabel(container);

scrollTo(container, 23430);
expect(toolbarLabel(container)).not.toBe(atStart);

scrollTo(container, 0);
expect(toolbarLabel(container)).toBe(atStart);
});

it('agrees with the band header at every scroll position, week view included', () => {
// Week view is the straddle case: a week column can start in one month and
// end in the next. The header keys such a column by the month its START
// falls in, so the label has to snap the COLUMN, not the instant under the
// pixel — otherwise the two disagree for part of every straddling week.
const { container } = renderView({ viewMode: 'week' });
for (const x of [0, 300, 777, 1234, 2000, 3111, 4200]) {
scrollTo(container, x);
expect(monthKey(toolbarLabel(container)), `at x=${x}`).toBe(monthKey(bandAt(container, x).label));
}
});

it('still labels a single-month dataset correctly (control)', () => {
// The fix is not "read whatever the header says": with one band and no
// scrolling, the label is still derived, and still right.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2026, 5, 5), end: new Date(2026, 5, 20), progress: 0 }]}
startDate={new Date(2026, 5, 1)}
endDate={new Date(2026, 5, 30)}
/>
</div>,
);
const bands = groupCells(container);
expect(bands.length).toBe(1);
expect(monthKey(toolbarLabel(container))).toBe('2026-5');
expect(monthKey(bands[0].label)).toBe('2026-5');
});
});

describe('GanttView toolbar period steppers (objectui#7203)', () => {
it('steps the visible window one period per click, and the label follows', () => {
const { container } = renderView();
const timeline = timelineOf(container);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
expect(timeline.scrollLeft).toBe(0);

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
const afterNext = timeline.scrollLeft;
expect(afterNext).toBeGreaterThan(0);
expect(monthKey(bandAt(container, afterNext).label)).toBe('2026-1');

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-2');
expect(timeline.scrollLeft).toBeGreaterThan(afterNext);

click(container, 'gantt-toolbar-prev-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
expect(timeline.scrollLeft).toBe(afterNext);
});

it('clamps at the left edge instead of scrolling out of the range', () => {
const { container } = renderView();
const timeline = timelineOf(container);
// January is the first period; the timeline itself starts on 24 Jan, so the
// period start is off-grid to the left. Stepping back parks at 0.
click(container, 'gantt-toolbar-prev-period');
expect(timeline.scrollLeft).toBe(0);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
});

it('bands by year in month view, and steps one year per click', () => {
// "One unit of the current granularity" is the tier the band header groups
// by, not the column unit: day/week band by month, month/quarter by year,
// year by decade. Stepping a single month column in month view would leave
// the toolbar's own label unchanged for eleven clicks out of twelve.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2024, 0, 10), end: new Date(2028, 11, 20), progress: 0 }]}
startDate={new Date(2024, 0, 1)}
endDate={new Date(2028, 11, 31)}
viewMode="month"
/>
</div>,
);
expect(toolbarLabel(container)).toBe('2024');
expect(bandAt(container, 0).label).toBe('2024');

const timeline = timelineOf(container);
click(container, 'gantt-toolbar-next-period');
expect(toolbarLabel(container)).toBe('2025');
expect(timeline.scrollLeft).toBeGreaterThan(0);
expect(bandAt(container, timeline.scrollLeft).label).toBe('2025');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/7203-gantt-toolbar-period-label.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
---
'@object-ui/plugin-gantt': patch
---

Gantt toolbar: the period label names the visible window, and the prev/next
buttons step it (objectui#7203).

The label formatted `timelineRange.start` — the memo spanning the whole dataset
— so it named the first unit of the entire result set and could not change while
the chart was scrolled, because it was not derived from scroll position at all.
On a dataset running January to December it therefore read "January 2026" at
every scroll position, four pixels above a band header correctly reading
"Aug 2026". Measured on the demo fixture in Chromium at 1440x900: on first paint,
after the chart auto-scrolls to Today, the label read `December 2025` over
columns `28F 29S 30S 31M 1T 2W 3T` with the band beneath them reading `Aug 2026`.
Two month labels four pixels apart, disagreeing — and the wrong one is the
prominent one, so the chart reads as if the columns were mislabelled.

The label now names the period at the left edge of the viewport, snapped to the
same tier `headerGroups` bands the timeline by: a month under day and week view,
a year under month and quarter view, a decade under year view, the shift-day
under shift-segmented day view. The toolbar and the band header therefore agree
by construction rather than by two derivations that can drift. Wording is
unchanged for the month tier — the toolbar still spells the month out
("August 2026" beside the header's "Aug 2026").

The `‹` / `›` buttons rendered an `aria-label` and an icon and carried no
`onClick`. They now scroll the visible window one period backwards/forwards at
that same tier, clamped to the ends of the timeline (ADR-0049 enforce-or-remove:
wiring is the branch the label change makes available). They step the label's
tier rather than one column, so a click always changes what the label says.

The band header is untouched. It was already correct; it is the reference here.
15 changes: 15 additions & 0 deletions packages/plugin-gantt/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -444,6 +444,21 @@ Drag snapping follows the active scale: bars snap to days in day view, weeks
in week view, and whole calendar months/quarters (duration preserved) in the
coarse views.

### The toolbar period label and its steppers

The label between the `‹` / `›` buttons names the period **currently on screen**,
not the extent of the data: it reads the date at the left edge of the viewport
and snaps it to the same tier the band header groups by — a month under day and
week view, a year under month and quarter view, a decade under year view, and
the shift-day under shift-segmented day view. So the label and the band header
directly beneath it always name the same period, and the label moves as the
chart is scrolled.

`‹` / `›` step the visible window by one of those periods (one month in day and
week view, one year in month and quarter view, a decade in year view), clamped
to the ends of the timeline. They step the *label's* tier rather than a single
column, so one click always changes what the label says.

Set the initial scale with `viewMode`. It is read through the gantt config, so
it needs the field mapping beside it (or a `gantt` block of its own):

Expand Down
223 changes: 223 additions & 0 deletions packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
/**
* objectui#7203 — the toolbar period label and its prev/next steppers.
*
* The label used to format `timelineRange.start`, the memo spanning the WHOLE
* dataset, so it named the first month of the result set and could not change
* at any scroll position — it was not a function of scroll position at all. On
* a dataset running Jan–Dec it therefore read "January 2026" four pixels above
* a band header correctly reading "Aug 2026". The two stepper buttons beside it
* rendered an aria-label and an icon and carried no onClick.
*
* The band header (`data-testid="gantt-header-groups"`) is the REFERENCE here,
* never the thing under test: these tests read the header cell that owns the
* pixel at the left edge of the viewport and require the toolbar to name the
* same month. Comparison is by month identity (`monthKey`), not by wording, so
* the toolbar staying on the fuller "August 2026" beside the header's compact
* "Aug 2026" is not what is being asserted either way.
*
* Scroll is honestly modelled in this environment: `GanttView.virtual.test.tsx`
* already pins that `timeline.scrollLeft = N` + `fireEvent.scroll` moves the
* column window to N (its assertion reads a rendered `style.left` back). The
* label derives from the same `scrollPos.left`, so these readings are real.
* Client sizes ARE 0 here, so the component falls back to a 4000px virtual
* viewport — every fixture below is wider than that, which is what makes the
* stepper's clamp non-trivial and the window genuinely scrollable.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { GanttView, type GanttTask } from './GanttView';

beforeEach(() => {
// >=1024 → columnWidth 110 (deterministic), matching the sibling suites.
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
});

function toolbarLabel(container: HTMLElement): string {
const el = container.querySelector('[data-testid="gantt-toolbar-period"]');
expect(el, 'toolbar period label is missing').toBeTruthy();
return el!.textContent!.trim();
}

function groupCells(container: HTMLElement) {
return Array.from(
container.querySelectorAll('[data-testid="gantt-header-groups"] > div'),
).map((node) => {
const el = node as HTMLElement;
return {
label: el.textContent!.trim(),
left: parseFloat(el.style.left),
width: parseFloat(el.style.width),
};
});
}

/** The band-header cell that owns pixel `x` — what the user reads at the left
* edge of the viewport, and the reference the toolbar has to agree with. */
function bandAt(container: HTMLElement, x: number) {
const hit = groupCells(container).find((c) => c.left <= x && x < c.left + c.width);
expect(hit, `no band-header cell owns x=${x}`).toBeTruthy();
return hit!;
}

/** `year-monthIndex` of a rendered month label, so "August 2026" (toolbar) and
* "Aug 2026" (band header) compare as the same month without either one's
* exact wording being asserted. */
function monthKey(label: string): string {
const d = new Date(label);
expect(Number.isNaN(d.getTime()), `unparseable month label: "${label}"`).toBe(false);
return `${d.getFullYear()}-${d.getMonth()}`;
}

function timelineOf(container: HTMLElement): HTMLElement {
return container.querySelector('[data-testid="gantt-timeline"]') as HTMLElement;
}

function scrollTo(container: HTMLElement, x: number) {
const timeline = timelineOf(container);
timeline.scrollLeft = x;
fireEvent.scroll(timeline);
return timeline;
}

function click(container: HTMLElement, testid: string) {
const btn = container.querySelector(`[data-testid="${testid}"]`);
expect(btn, `${testid} is missing`).toBeTruthy();
fireEvent.click(btn!);
}

/** The reporter's shape: a year of history, no explicit window, so
* `timelineRange` spans the whole dataset and starts in January. */
function yearOfTasks(): GanttTask[] {
return [
{ id: 'a', title: 'Task a', start: new Date(2026, 0, 31), end: new Date(2026, 1, 20), progress: 0 },
{ id: 'b', title: 'Task b', start: new Date(2026, 7, 26), end: new Date(2026, 8, 6), progress: 0 },
{ id: 'c', title: 'Task c', start: new Date(2026, 11, 1), end: new Date(2026, 11, 31), progress: 0 },
];
}

function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
return render(
<div style={{ width: 1280, height: 600 }}>
<GanttView tasks={yearOfTasks()} {...props} />
</div>,
);
}

describe('GanttView toolbar period label (objectui#7203)', () => {
it('names the month the band header names, at a scrolled position', () => {
const { container } = renderView();
// The dataset really does start in January — which is precisely why the old
// `timelineRange.start` label read January at EVERY scroll position.
expect(monthKey(toolbarLabel(container))).toBe('2026-0');

const x = 23430; // ~7 months in at 110px/day; anywhere past January will do
scrollTo(container, x);

const band = bandAt(container, x);
expect(monthKey(band.label), 'fixture did not actually leave January').not.toBe('2026-0');
expect(monthKey(toolbarLabel(container))).toBe(monthKey(band.label));
});

it('changes as the chart scrolls, and comes back', () => {
// The assertion that fails if the label is ever wired back to a whole-range
// memo: a static string passes any single-position check.
const { container } = renderView();
const atStart = toolbarLabel(container);

scrollTo(container, 23430);
expect(toolbarLabel(container)).not.toBe(atStart);

scrollTo(container, 0);
expect(toolbarLabel(container)).toBe(atStart);
});

it('agrees with the band header at every scroll position, week view included', () => {
// Week view is the straddle case: a week column can start in one month and
// end in the next. The header keys such a column by the month its START
// falls in, so the label has to snap the COLUMN, not the instant under the
// pixel — otherwise the two disagree for part of every straddling week.
const { container } = renderView({ viewMode: 'week' });
for (const x of [0, 300, 777, 1234, 2000, 3111, 4200]) {
scrollTo(container, x);
expect(monthKey(toolbarLabel(container)), `at x=${x}`).toBe(monthKey(bandAt(container, x).label));
}
});

it('still labels a single-month dataset correctly (control)', () => {
// The fix is not "read whatever the header says": with one band and no
// scrolling, the label is still derived, and still right.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2026, 5, 5), end: new Date(2026, 5, 20), progress: 0 }]}
startDate={new Date(2026, 5, 1)}
endDate={new Date(2026, 5, 30)}
/>
</div>,
);
const bands = groupCells(container);
expect(bands.length).toBe(1);
expect(monthKey(toolbarLabel(container))).toBe('2026-5');
expect(monthKey(bands[0].label)).toBe('2026-5');
});
});

describe('GanttView toolbar period steppers (objectui#7203)', () => {
it('steps the visible window one period per click, and the label follows', () => {
const { container } = renderView();
const timeline = timelineOf(container);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
expect(timeline.scrollLeft).toBe(0);

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
const afterNext = timeline.scrollLeft;
expect(afterNext).toBeGreaterThan(0);
expect(monthKey(bandAt(container, afterNext).label)).toBe('2026-1');

click(container, 'gantt-toolbar-next-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-2');
expect(timeline.scrollLeft).toBeGreaterThan(afterNext);

click(container, 'gantt-toolbar-prev-period');
expect(monthKey(toolbarLabel(container))).toBe('2026-1');
expect(timeline.scrollLeft).toBe(afterNext);
});

it('clamps at the left edge instead of scrolling out of the range', () => {
const { container } = renderView();
const timeline = timelineOf(container);
// January is the first period; the timeline itself starts on 24 Jan, so the
// period start is off-grid to the left. Stepping back parks at 0.
click(container, 'gantt-toolbar-prev-period');
expect(timeline.scrollLeft).toBe(0);
expect(monthKey(toolbarLabel(container))).toBe('2026-0');
});

it('bands by year in month view, and steps one year per click', () => {
// "One unit of the current granularity" is the tier the band header groups
// by, not the column unit: day/week band by month, month/quarter by year,
// year by decade. Stepping a single month column in month view would leave
// the toolbar's own label unchanged for eleven clicks out of twelve.
const { container } = render(
<div style={{ width: 1280, height: 600 }}>
<GanttView
tasks={[{ id: 'a', title: 'Task a', start: new Date(2024, 0, 10), end: new Date(2028, 11, 20), progress: 0 }]}
startDate={new Date(2024, 0, 1)}
endDate={new Date(2028, 11, 31)}
viewMode="month"
/>
</div>,
);
expect(toolbarLabel(container)).toBe('2024');
expect(bandAt(container, 0).label).toBe('2024');

const timeline = timelineOf(container);
click(container, 'gantt-toolbar-next-period');
expect(toolbarLabel(container)).toBe('2025');
expect(timeline.scrollLeft).toBeGreaterThan(0);
expect(bandAt(container, timeline.scrollLeft).label).toBe('2025');
});
});
Loading
Loading