From 5c1ad81b1422f5c1a047c3e1c87c855be23125ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 14:56:41 +0000 Subject: [PATCH 1/2] fix(plugin-gantt): derive the toolbar period label from the visible window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbar span 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 Jan–Dec dataset it read "January 2026" four pixels above a band header correctly reading "Aug 2026". The label now names the period at the left edge of the viewport, snapped to the same tier `headerGroups` bands by (month under day/week, year under month/quarter, decade under year, shift-day under segmented day mode), so the two agree by construction. The prev/next buttons — which rendered an aria-label and an icon and carried no onClick — step that window by one period, which is what gives them something real to drive (ADR-0049). The band header is untouched: it is the reference, not the defect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- .../src/GanttView.toolbarPeriod-7203.test.tsx | 223 ++++++++++++++++++ packages/plugin-gantt/src/GanttView.tsx | 157 +++++++++++- 2 files changed, 370 insertions(+), 10 deletions(-) create mode 100644 packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx diff --git a/packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx b/packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx new file mode 100644 index 0000000000..f9cb426d12 --- /dev/null +++ b/packages/plugin-gantt/src/GanttView.toolbarPeriod-7203.test.tsx @@ -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> = {}) { + return render( +
+ +
, + ); +} + +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( +
+ +
, + ); + 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( +
+ +
, + ); + 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'); + }); +}); diff --git a/packages/plugin-gantt/src/GanttView.tsx b/packages/plugin-gantt/src/GanttView.tsx index 6bd4c4cc0d..5e1dc634e9 100644 --- a/packages/plugin-gantt/src/GanttView.tsx +++ b/packages/plugin-gantt/src/GanttView.tsx @@ -308,6 +308,68 @@ export function addUnits(date: Date, units: number, mode: GanttViewMode): Date { return d; } +/** + * The tier the toolbar's period label and its prev/next steppers work in. + * + * It is deliberately the SAME tier `headerGroups` bands the timeline by, so the + * toolbar and the band header four pixels below it can never name different + * periods (objectui#7203: the toolbar formatted `timelineRange.start` — the memo + * spanning the whole dataset — and so read "January 2026" over columns the band + * header correctly called "Aug 2026", and could not change at any scroll + * position because it was not derived from scroll position at all). + * + * Every `GanttViewMode` has a natural unit here, so "step one period" is always + * defined: day/week band by month, month/quarter by year, year by decade, and + * shift-segmented day mode by shift-day (the tier its bands group into). + */ +export type GanttPeriodTier = 'shiftDay' | 'month' | 'year' | 'decade'; + +/** Mirror of the `groupBy` choice inside `headerGroups` — keep the two in step. */ +export function periodTierFor(mode: GanttViewMode, segmenting: boolean): GanttPeriodTier { + if (segmenting) return 'shiftDay'; + if (mode === 'year') return 'decade'; + if (mode === 'month' || mode === 'quarter') return 'year'; + return 'month'; +} + +/** Start of the period `date` falls in, at `tier`. */ +export function startOfPeriod(date: Date, tier: GanttPeriodTier, dayStartMin = 0): Date { + if (tier === 'shiftDay') return shiftDayStart(date, dayStartMin); + const d = new Date(date); + d.setHours(0, 0, 0, 0); + if (tier === 'month') { + d.setDate(1); + } else if (tier === 'year') { + d.setMonth(0, 1); + } else { + d.setMonth(0, 1); + d.setFullYear(Math.floor(d.getFullYear() / 10) * 10); + } + return d; +} + +/** Step whole periods at `tier` — one unit of the toolbar's granularity. */ +export function addPeriods(date: Date, n: number, tier: GanttPeriodTier): Date { + if (tier === 'shiftDay') return addUnits(date, n, 'day'); + if (tier === 'month') return addUnits(date, n, 'month'); + return addUnits(date, n * (tier === 'decade' ? 10 : 1), 'year'); +} + +/** + * Toolbar wording for a period start. The year and decade tiers reuse the band + * header's exact strings; the month tier spells the month out ("August 2026" + * beside the header's "Aug 2026") — a fuller form of the same month, never a + * different one. + */ +export function formatPeriodLabel(date: Date, tier: GanttPeriodTier, locale?: string): string { + if (tier === 'decade') return `${Math.floor(date.getFullYear() / 10) * 10}s`; + if (tier === 'year') return String(date.getFullYear()); + if (tier === 'shiftDay') { + return date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }); + } + return date.toLocaleDateString(locale, { month: 'long', year: 'numeric' }); +} + /** Custom vertical timeline marker (deadline, sprint boundary, release…). */ export interface GanttMarker { date: Date | string @@ -1984,12 +2046,15 @@ export function GanttView({ [timeColumns, colStartMs, colRealMs, colOffsets, folding, segmenting], ); - // x (px) → date. Inverse of dateToX, used by drag/resize to read the date - // under the pointer. Never returns a folded (non-working) instant. - const xToDate = React.useCallback( - (x: number): Date => { + // Index of the column that OWNS pixel x — the largest i with + // colOffsets[i] <= x. Shared by `xToDate` (which then interpolates inside the + // column) and by the toolbar's visible-period label (which must not + // interpolate: a week column straddling a month boundary belongs, header-group + // and label alike, to the month its START falls in). + const colIndexAtX = React.useCallback( + (x: number): number => { const n = timeColumns.length; - if (n === 0) return new Date(timelineRange.start); + if (n === 0) return -1; let lo = 0; let hi = n - 1; let i = 0; @@ -2002,11 +2067,22 @@ export function GanttView({ hi = m - 1; } } + return i; + }, + [timeColumns, colOffsets], + ); + + // x (px) → date. Inverse of dateToX, used by drag/resize to read the date + // under the pointer. Never returns a folded (non-working) instant. + const xToDate = React.useCallback( + (x: number): Date => { + const i = colIndexAtX(x); + if (i < 0) return new Date(timelineRange.start); const w = timeColumns[i].width || 1; const frac = (x - colOffsets[i]) / w; return new Date(colStartMs[i] + frac * (colRealMs[i] || MS_PER_DAY)); }, - [timeColumns, colStartMs, colRealMs, colOffsets, timelineRange], + [colIndexAtX, timeColumns, colStartMs, colRealMs, colOffsets, timelineRange], ); // Switch granularity *without* the date window jumping. The scroll container @@ -2267,6 +2343,51 @@ export function GanttView({ }, [scrollPos.top, viewport.height, rowHeight, rows.length]); const totalRowsHeight = rows.length * rowHeight; + // --- Toolbar period: label + prev/next steppers --------------------------- + // Both are derived from the VISIBLE WINDOW, never from `timelineRange` — the + // whole-dataset memo, whose start is the first unit of the entire result set + // and does not move when the chart is scrolled, because it is not a function + // of scroll position at all (objectui#7203). The left edge of the viewport + // picks the owning column; that column's own start date, snapped to the tier + // `headerGroups` bands by, is the period on screen — so the toolbar label and + // the band header four pixels below it agree by construction rather than by + // two parallel derivations that can drift apart. + const periodTier = periodTierFor(viewMode, segmenting); + const visiblePeriodStart = React.useMemo(() => { + const i = colIndexAtX(scrollPos.left); + // Snap the COLUMN's start, not the interpolated instant under the pixel: a + // week column straddling a month boundary belongs to the month its start + // falls in, which is exactly how `headerGroups` keys it. + const anchor = i >= 0 ? timeColumns[i].date : timelineRange.start; + return startOfPeriod(anchor, periodTier, shiftSegments?.dayStartMin ?? 0); + }, [colIndexAtX, scrollPos.left, timeColumns, timelineRange, periodTier, shiftSegments]); + + const periodLabel = React.useMemo( + () => formatPeriodLabel(visiblePeriodStart, periodTier, dateLocale), + [visiblePeriodStart, periodTier, dateLocale], + ); + + // Scroll the visible window one period backwards/forwards — what the toolbar's + // ChevronLeft/ChevronRight drive. Clamped against the same totalWidth/viewport + // pair the virtualization windows use, so the two never disagree about where + // the content ends. `scrollPos` is pushed here as well as from the scroll + // event because that event is queued (async) and a clamped assignment at + // either end of the range fires none at all — the label has to follow the + // move either way. Same "drive virtualization directly" reason as + // `handleListScroll` below. + const stepPeriod = React.useCallback( + (delta: number) => { + const el = scrollAreaRef.current; + if (!el) return; + const target = addPeriods(visiblePeriodStart, delta, periodTier); + const maxLeft = Math.max(0, totalWidth - viewport.width); + const left = Math.max(0, Math.min(Math.round(dateToX(target)), maxLeft)); + el.scrollLeft = left; + setScrollPos((prev) => (prev.left === left ? prev : { ...prev, left })); + }, + [visiblePeriodStart, periodTier, totalWidth, viewport.width, dateToX], + ); + // --- Fullscreen ----------------------------------------------------------- const [isFullscreen, setIsFullscreen] = React.useState(false); React.useEffect(() => { @@ -3178,14 +3299,30 @@ export function GanttView({ already exposes a fully-fielded create form for this object, and the toolbar's quick-create only set 3 fields which was confusing for required-field-heavy schemas. */} - - - - {timelineRange.start.toLocaleDateString(dateLocale, { month: 'long', year: 'numeric' })} + {/* The period ON SCREEN, not the start of the dataset — see + `visiblePeriodStart`. */} + + {periodLabel} {effectiveReadOnly && ( Date: Tue, 1 Sep 2026 15:06:24 +0000 Subject: [PATCH 2/2] docs(plugin-gantt): document the toolbar period label + steppers, add changeset Also drops the `export` from the four period helpers: nothing outside this module consumes them and `index.tsx` never re-exported them, so publishing them would have added public surface with no caller. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- .changeset/7203-gantt-toolbar-period-label.md | 33 +++++++++++++++++++ packages/plugin-gantt/README.md | 15 +++++++++ packages/plugin-gantt/src/GanttView.tsx | 10 +++--- 3 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 .changeset/7203-gantt-toolbar-period-label.md diff --git a/.changeset/7203-gantt-toolbar-period-label.md b/.changeset/7203-gantt-toolbar-period-label.md new file mode 100644 index 0000000000..1d6447a4f2 --- /dev/null +++ b/.changeset/7203-gantt-toolbar-period-label.md @@ -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. diff --git a/packages/plugin-gantt/README.md b/packages/plugin-gantt/README.md index 0dd9191aba..db8293c388 100644 --- a/packages/plugin-gantt/README.md +++ b/packages/plugin-gantt/README.md @@ -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): diff --git a/packages/plugin-gantt/src/GanttView.tsx b/packages/plugin-gantt/src/GanttView.tsx index 5e1dc634e9..8e7b4d8bc1 100644 --- a/packages/plugin-gantt/src/GanttView.tsx +++ b/packages/plugin-gantt/src/GanttView.tsx @@ -322,10 +322,10 @@ export function addUnits(date: Date, units: number, mode: GanttViewMode): Date { * defined: day/week band by month, month/quarter by year, year by decade, and * shift-segmented day mode by shift-day (the tier its bands group into). */ -export type GanttPeriodTier = 'shiftDay' | 'month' | 'year' | 'decade'; +type GanttPeriodTier = 'shiftDay' | 'month' | 'year' | 'decade'; /** Mirror of the `groupBy` choice inside `headerGroups` — keep the two in step. */ -export function periodTierFor(mode: GanttViewMode, segmenting: boolean): GanttPeriodTier { +function periodTierFor(mode: GanttViewMode, segmenting: boolean): GanttPeriodTier { if (segmenting) return 'shiftDay'; if (mode === 'year') return 'decade'; if (mode === 'month' || mode === 'quarter') return 'year'; @@ -333,7 +333,7 @@ export function periodTierFor(mode: GanttViewMode, segmenting: boolean): GanttPe } /** Start of the period `date` falls in, at `tier`. */ -export function startOfPeriod(date: Date, tier: GanttPeriodTier, dayStartMin = 0): Date { +function startOfPeriod(date: Date, tier: GanttPeriodTier, dayStartMin = 0): Date { if (tier === 'shiftDay') return shiftDayStart(date, dayStartMin); const d = new Date(date); d.setHours(0, 0, 0, 0); @@ -349,7 +349,7 @@ export function startOfPeriod(date: Date, tier: GanttPeriodTier, dayStartMin = 0 } /** Step whole periods at `tier` — one unit of the toolbar's granularity. */ -export function addPeriods(date: Date, n: number, tier: GanttPeriodTier): Date { +function addPeriods(date: Date, n: number, tier: GanttPeriodTier): Date { if (tier === 'shiftDay') return addUnits(date, n, 'day'); if (tier === 'month') return addUnits(date, n, 'month'); return addUnits(date, n * (tier === 'decade' ? 10 : 1), 'year'); @@ -361,7 +361,7 @@ export function addPeriods(date: Date, n: number, tier: GanttPeriodTier): Date { * beside the header's "Aug 2026") — a fuller form of the same month, never a * different one. */ -export function formatPeriodLabel(date: Date, tier: GanttPeriodTier, locale?: string): string { +function formatPeriodLabel(date: Date, tier: GanttPeriodTier, locale?: string): string { if (tier === 'decade') return `${Math.floor(date.getFullYear() / 10) * 10}s`; if (tier === 'year') return String(date.getFullYear()); if (tier === 'shiftDay') {