diff --git a/.changeset/6759-gantt-unusable-date-range.md b/.changeset/6759-gantt-unusable-date-range.md new file mode 100644 index 000000000..4fd2143bf --- /dev/null +++ b/.changeset/6759-gantt-unusable-date-range.md @@ -0,0 +1,22 @@ +--- +'@object-ui/plugin-timeline': patch +'@object-ui/i18n': patch +--- + +fix(plugin-timeline): refuse an unusable gantt date range with a diagnostic that names the offending value + +A gantt whose date range cannot be used now renders a `role="alert"` diagnostic +naming the value that made it unusable, instead of crashing or drawing a chart +that is confidently wrong. Two input classes, which failed in opposite +directions: + +- A date that does not parse threw `RangeError: Invalid time value` mid-render — + the same crash site objectui#6750 guarded for the empty list, on a different + input. This covers a malformed value (`startDate: 'not-a-date'`), an absent + one, and an unparseable `minDate` / `maxDate` pinned on the schema. +- An inverted pinned range (`minDate` after `maxDate`) drew a bar at + `left: 157.9%; width: -4.3%` under a header row with zero cells, with no + error and no diagnostic. + +Valid gantts, the empty-list sentinel from objectui#6750 and the degenerate +`minDate === maxDate` axis are unchanged. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index b878c6540..0e2750b86 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -677,6 +677,12 @@ const ar = { }, gantt: { rowLabel: "العناصر", + unusableRange: { + malformedDate: + "نطاق تواريخ جانت غير صالح للاستخدام — {{path}} يساوي {{value}}، وهو ليس تاريخًا صالحًا. يجب أن تكون كل تواريخ جانت قابلة للتحليل: startDate و endDate لكل عنصر صف، بالإضافة إلى أي minDate / maxDate مثبّت في المخطط.", + inverted: + "نطاق تواريخ جانت غير صالح للاستخدام — minDate {{minDate}} يقع بعد maxDate {{maxDate}}. يتجاوز minDate / maxDate المثبّت النطاق المحسوب من الصفوف، لذلك لا يحتوي هذا المحور على أي أعمدة ولا يمكن وضع أي شريط عليه؛ بدّل القيمتين.", + }, }, unsupported: { objectBoundGantt: "النوع \"gantt\" غير مدعوم — المخطط الزمني المرتبط بكائن يعرض أنواع التغذية ({{variants}}). يحتاج مخطط جانت إلى صفوف صريحة، لكل منها عناصر items متداخلة خاصة به، لذلك لا يؤثر محور جانت (scale) هنا.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3494a684d..8384cadc1 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -673,6 +673,12 @@ const de = { }, gantt: { rowLabel: "Elemente", + unusableRange: { + malformedDate: + "Unbrauchbarer Gantt-Datumsbereich — {{path}} ist {{value}} und damit kein gültiges Datum. Jedes Gantt-Datum muss parsebar sein: startDate und endDate jedes Zeilenelements sowie ein am Schema gesetztes minDate / maxDate.", + inverted: + "Unbrauchbarer Gantt-Datumsbereich — minDate {{minDate}} liegt nach maxDate {{maxDate}}. Ein gesetztes minDate / maxDate überschreibt den aus den Zeilen berechneten Bereich, daher hat diese Achse keine Spalten und es kann kein Balken platziert werden; vertauschen Sie die beiden Werte.", + }, }, unsupported: { objectBoundGantt: "Nicht unterstützte Variante „gantt“ — eine objektgebundene Timeline rendert die Feed-Varianten ({{variants}}). Gantt benötigt literale Zeilen mit jeweils eigenen verschachtelten items; die Gantt-Achse (scale) hat hier daher keine Wirkung.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index b29d23dad..09c23dbb7 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -759,6 +759,12 @@ const en = { }, gantt: { rowLabel: 'Items', + unusableRange: { + malformedDate: + 'Unusable gantt date range — {{path}} is {{value}}, which is not a valid date. Every gantt date has to parse: the startDate and endDate on every row item, plus any minDate / maxDate pinned on the schema.', + inverted: + 'Unusable gantt date range — minDate {{minDate}} is after maxDate {{maxDate}}. A pinned minDate / maxDate overrides the range computed from the rows, so this axis has no columns and no bar can be placed on it; swap the two values.', + }, }, unsupported: { objectBoundGantt: 'Unsupported variant "gantt" — an object-bound timeline renders the feed variants ({{variants}}). Gantt needs literal rows, each with its own nested items, so the gantt axis (scale) has no effect here.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index becec2948..37fbba5ad 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -677,6 +677,12 @@ const es = { }, gantt: { rowLabel: "Elementos", + unusableRange: { + malformedDate: + "Rango de fechas del gantt inutilizable: {{path}} es {{value}}, que no es una fecha válida. Todas las fechas del gantt deben poder analizarse: startDate y endDate de cada elemento de fila, además de cualquier minDate / maxDate fijado en el esquema.", + inverted: + "Rango de fechas del gantt inutilizable: minDate {{minDate}} es posterior a maxDate {{maxDate}}. Un minDate / maxDate fijado prevalece sobre el rango calculado a partir de las filas, por lo que este eje no tiene columnas y no se puede colocar ninguna barra; intercambie los dos valores.", + }, }, unsupported: { objectBoundGantt: "Variante \"gantt\" no admitida: una línea de tiempo vinculada a un objeto representa las variantes de feed ({{variants}}). Gantt necesita filas literales, cada una con sus propios items anidados, por lo que el eje gantt (scale) no tiene efecto aquí.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index e9925d708..a6293c053 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -673,6 +673,12 @@ const fr = { }, gantt: { rowLabel: "Éléments", + unusableRange: { + malformedDate: + "Plage de dates du gantt inutilisable — {{path}} vaut {{value}}, ce qui n'est pas une date valide. Toutes les dates du gantt doivent être analysables : startDate et endDate de chaque élément de ligne, ainsi que tout minDate / maxDate fixé sur le schéma.", + inverted: + "Plage de dates du gantt inutilisable — minDate {{minDate}} est postérieur à maxDate {{maxDate}}. Un minDate / maxDate fixé l'emporte sur la plage calculée à partir des lignes ; cet axe n'a donc aucune colonne et aucune barre ne peut y être placée. Intervertissez les deux valeurs.", + }, }, unsupported: { objectBoundGantt: "Variante « gantt » non prise en charge — une timeline liée à un objet affiche les variantes de flux ({{variants}}). Gantt nécessite des lignes littérales, chacune avec ses propres items imbriqués ; l'axe gantt (scale) est donc sans effet ici.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 7c3a0fbc5..0f9c17887 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -673,6 +673,12 @@ const ja = { }, gantt: { rowLabel: "項目", + unusableRange: { + malformedDate: + "ガントの日付範囲が使用できません — {{path}} が {{value}} で、有効な日付として解析できません。ガントのすべての日付は解析可能である必要があります: 各行アイテムの startDate と endDate、およびスキーマで固定した minDate / maxDate。", + inverted: + "ガントの日付範囲が使用できません — minDate {{minDate}} が maxDate {{maxDate}} より後です。固定した minDate / maxDate は行から計算した範囲より優先されるため、この軸には列がなくバーを配置できません。2 つの値を入れ替えてください。", + }, }, unsupported: { objectBoundGantt: "サポートされていないバリアント \"gantt\" — オブジェクト連動のタイムラインはフィードバリアント({{variants}})を描画します。ガントには、それぞれが入れ子の items を持つリテラル行が必要なため、ここではガント軸(scale)は効果がありません。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index f5010dc7c..283a888b6 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -673,6 +673,12 @@ const ko = { }, gantt: { rowLabel: "항목", + unusableRange: { + malformedDate: + "사용할 수 없는 간트 날짜 범위 — {{path}}은(는) {{value}}이며 유효한 날짜가 아닙니다. 모든 간트 날짜는 파싱할 수 있어야 합니다: 각 행 항목의 startDate와 endDate, 그리고 스키마에 고정한 minDate / maxDate.", + inverted: + "사용할 수 없는 간트 날짜 범위 — minDate {{minDate}}이(가) maxDate {{maxDate}}보다 뒤입니다. 고정한 minDate / maxDate는 행에서 계산한 범위보다 우선하므로 이 축에는 열이 없고 막대를 배치할 수 없습니다. 두 값을 서로 바꾸세요.", + }, }, unsupported: { objectBoundGantt: "지원되지 않는 variant \"gantt\" — 객체 바인딩 타임라인은 피드 variant({{variants}})를 렌더링합니다. 간트는 각각 중첩된 items를 가진 리터럴 행이 필요하므로 여기서는 간트 축(scale)이 적용되지 않습니다.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 3b5cf8d97..40ab35546 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -672,6 +672,12 @@ const pt = { }, gantt: { rowLabel: "Itens", + unusableRange: { + malformedDate: + "Intervalo de datas do gantt inutilizável: {{path}} é {{value}}, que não é uma data válida. Todas as datas do gantt têm de ser analisáveis: startDate e endDate de cada item de linha, além de qualquer minDate / maxDate fixado no esquema.", + inverted: + "Intervalo de datas do gantt inutilizável: minDate {{minDate}} é posterior a maxDate {{maxDate}}. Um minDate / maxDate fixado prevalece sobre o intervalo calculado a partir das linhas, pelo que este eixo não tem colunas e nenhuma barra pode ser colocada; troque os dois valores.", + }, }, unsupported: { objectBoundGantt: "Variante \"gantt\" não suportada: uma linha do tempo vinculada a um objeto renderiza as variantes de feed ({{variants}}). O gantt precisa de linhas literais, cada uma com os seus próprios items aninhados, por isso o eixo gantt (scale) não tem efeito aqui.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index f2b22cf95..6259301df 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -679,6 +679,12 @@ const ru = { }, gantt: { rowLabel: "Элементы", + unusableRange: { + malformedDate: + "Непригодный диапазон дат гантта — {{path}} равно {{value}}, что не является корректной датой. Все даты гантта должны разбираться: startDate и endDate каждого элемента строки, а также minDate / maxDate, заданные в схеме.", + inverted: + "Непригодный диапазон дат гантта — minDate {{minDate}} позже maxDate {{maxDate}}. Заданные minDate / maxDate имеют приоритет над диапазоном, вычисленным по строкам, поэтому у этой оси нет столбцов и полосу разместить негде; поменяйте оба значения местами.", + }, }, unsupported: { objectBoundGantt: "Неподдерживаемый вариант \"gantt\" — временная шкала, привязанная к объекту, отображает ленточные варианты ({{variants}}). Для гантта нужны литеральные строки, каждая со своим вложенным items, поэтому ось гантта (scale) здесь не действует.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 58516a94f..fecf32f15 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -697,6 +697,12 @@ const zh = { }, gantt: { rowLabel: '条目', + unusableRange: { + malformedDate: + '甘特图日期区间不可用 —— {{path}} 的值是 {{value}},无法解析为日期。每个甘特图日期都必须能被解析:每行条目的 startDate 与 endDate,以及 schema 上钉住的 minDate / maxDate。', + inverted: + '甘特图日期区间不可用 —— minDate {{minDate}} 晚于 maxDate {{maxDate}}。钉住的 minDate / maxDate 会覆盖由行数据算出的区间,因此该坐标轴没有任何列,也无法放置任何条形;请对调这两个值。', + }, }, unsupported: { objectBoundGantt: '不支持的变体 "gantt" —— 对象绑定的时间线只渲染 feed 变体({{variants}})。甘特图需要字面量行,每行自带嵌套的 items,因此此处的甘特轴(scale)不起作用。', diff --git a/packages/plugin-timeline/src/__tests__/timeline-gantt-unusable-date-range.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-gantt-unusable-date-range.test.tsx new file mode 100644 index 000000000..6d249878d --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/timeline-gantt-unusable-date-range.test.tsx @@ -0,0 +1,425 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6759 — a gantt whose date range is UNUSABLE refuses loudly, naming + * the value that made it unusable, instead of crashing or drawing nonsense. + * + * ## The two defects, and why they are one card + * + * objectui#6750 taught the gantt branch about the EMPTY list. Two other input + * classes were left, and they failed in OPPOSITE directions — which is itself + * the evidence that no policy existed yet, and the reason triage refused to let + * them be fixed one at a time: + * + * 1. A date that does not PARSE crashed the render. `calculateDateRange` + * reduced `NaN` into `Math.min`, and `new Date(NaN).toISOString()` throws + * `RangeError: Invalid time value` — #6750's crash site and #6750's + * signature, on a different input class. + * 2. An INVERTED author-pinned range drew confident nonsense. The axis guard + * in `generateTimeScaleHeaders` refuses an inverted range by emitting no + * headers, so the header row had zero cells — but the row loop under it + * still ran, and `calculateBarDimensions` divided by a NEGATIVE + * `totalDuration`. No error, no diagnostic. + * + * Measured on this card's base b98352a15, with a throwaway probe before any + * change. Five inputs, five different ways to be wrong, one policy: + * + * CASE-1 startDate 'not-a-date' -> RangeError: Invalid time value + * CASE-1b endDate 'also-bad' -> RangeError: Invalid time value + * CASE-1c endDate absent -> RangeError: Invalid time value + * CASE-2 min 2030-01-01 / max 2026-03-15 + * -> axis: [] bars: ["left: 157.9250720461095%; width: -4.322766570605188%;"] + * CASE-2b minDate 'whenever' -> axis: [] bars: [null] + * + * ## The trap CASE-2b is here to hold + * + * #6750's dev measured that `calculateBarDimensions` can fail SILENTLY: `0 / 0` + * is `NaN`, the CSSOM rejects `left: NaN%`, and React then emits no `style` + * attribute at all. CASE-2b is that same silence — `bars: [null]` is a bar + * element with NO `style`. So a pin written as "the bar no longer carries the + * bad geometry" would have passed on the unfixed code, for the wrong reason. + * Every assertion below is therefore positive about the diagnostic and counts + * BAR ELEMENTS, never the presence or absence of a style attribute. + * + * ## The policy is copied, not invented + * + * Triage (2026-08-29) ruled that only the WORDING was ever a policy question + * and that it already had a precedent one file away: objectui#6655 refuses the + * object-bound gantt with a `role="alert"` diagnostic. This is that shape. + * 「不要崩」 and 「不要自信地画错」 are correctness floors; the sentence the author + * reads is #6655's, reused. + * + * ## What is deliberately NOT here + * + * `generateTimeScaleHeaders` is untouched — pin 7 holds its refusal exactly as + * objectui#6750's pin 5a left it. The fix refuses ABOVE it rather than relaxing + * it, and #6750's `emptyGanttDateRange` sentinel is NOT widened to swallow + * these (pin 6): an empty list is an ordinary state, an unparseable value is an + * author error, and substituting a plausible range for the second is the + * consumer-side tolerance both cards rejected. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ObjectTimeline } from '../ObjectTimeline'; +import { TimelineRenderer, generateTimeScaleHeaders } from '../renderer'; +import { TIMELINE_DEFAULT_TRANSLATIONS } from '../useTimelineTranslation'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal() as Promise>); + return { + ...actual, + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'overlay', + view: undefined, + }), + useObjectLabel: () => ({ + fieldOptionLabel: (_o: string, _f: string, _v: string, fb: string) => fb, + translateOptions: (_o: string, _f: string, opts: unknown[]) => opts, + fieldLabel: (_o: string, _f: string, fb: string) => fb, + }), + }; +}); + +const TESTID = 'timeline-unusable-date-range'; + +/** The axis header cells the gantt branch emits, in order. */ +const axisOf = (container: HTMLElement): string[] => + Array.from(container.querySelectorAll('.border-r.text-xs.font-medium.text-center')).map( + (n) => n.textContent ?? '', + ); + +/** + * How many bar ELEMENTS exist — not their geometry. + * + * The count, deliberately: a bar whose geometry is `NaN` carries no `style` + * attribute at all, so any assertion phrased over styles reads identically for + * "the bar is gone" and "the bar is there and broken". See CASE-2b in the + * header. + */ +const barCountOf = (container: HTMLElement): number => + container.querySelectorAll('.absolute.h-8.rounded-md').length; + +/** Every bar's inline geometry, in order — for the unchanged-normal-path pin. */ +const barStylesOf = (container: HTMLElement): (string | null)[] => + Array.from(container.querySelectorAll('.absolute.h-8.rounded-md')).map((n) => + (n as HTMLElement).getAttribute('style'), + ); + +/** The diagnostic's text, or `null` when the gantt rendered instead. */ +const diagnosticOf = (container: HTMLElement): string | null => { + const el = container.querySelector(`[data-testid="${TESTID}"]`); + return el ? el.textContent ?? '' : null; +}; + +const gantt = (schema: Record) => + render(); + +/** One legitimate row — the thing that is NOT at fault in the case-2 inputs. */ +const GOOD_ROW = { + label: 'Backend', + items: [{ title: 'API Design', startDate: '2024-01-01', endDate: '2024-03-01' }], +}; + +describe('pin 1 — a malformed row date refuses instead of throwing (objectui#6759 case 1)', () => { + const MALFORMED = { + items: [{ label: 'R', items: [{ title: 'T', startDate: 'not-a-date', endDate: 'also-bad' }] }], + }; + + it('renders instead of raising `RangeError: Invalid time value`', () => { + // The crash itself, in one assertion. On the base this call threw out of + // `calculateDateRange`. + expect(() => gantt(MALFORMED)).not.toThrow(); + }); + + it('the diagnostic NAMES the offending value and where it was authored', () => { + gantt(MALFORMED); + + const el = screen.getByTestId(TESTID); + // #6655's shape, copied: an alert, not a silent panel. + expect(el.getAttribute('role')).toBe('alert'); + + const text = el.textContent ?? ''; + // Naming the VALUE is the whole ruling — a diagnostic that only said "bad + // date" would satisfy "does not crash" and none of what was asked for. + expect(text, 'the diagnostic did not name the offending value').toContain('"not-a-date"'); + // …and where the author wrote it. + expect(text, 'the diagnostic did not name the authored path').toContain( + 'items[0].items[0].startDate', + ); + }); + + it('draws no chart in its place — no axis, no bars', () => { + const { container } = gantt(MALFORMED); + expect(axisOf(container)).toEqual([]); + expect(barCountOf(container), 'a bar was drawn from an unusable range').toBe(0); + }); + + it('names the SECOND date when that is the one at fault', () => { + // The scan reports the first fault it meets, so a valid `startDate` beside + // a broken `endDate` must move the report to `endDate` — otherwise the + // "names the offending value" guarantee is only true for the first field. + const { container } = gantt({ + items: [{ label: 'R', items: [{ title: 'T', startDate: '2024-01-01', endDate: 'also-bad' }] }], + }); + const text = diagnosticOf(container) ?? ''; + expect(text).toContain('"also-bad"'); + expect(text).toContain('items[0].items[0].endDate'); + }); + + it('an ABSENT date is the same input class and is named as `undefined`', () => { + // Measured on the base as CASE-1c: `new Date(undefined)` is also an invalid + // date, so a row item that merely OMITS `endDate` crashed the render too. + // It is not a widening — any guard phrased as "every date must parse" + // covers it, and excluding it would mean writing extra code to keep one + // input class crashing. + const { container } = gantt({ + items: [{ label: 'R', items: [{ title: 'T', startDate: '2024-01-01' }] }], + }); + const text = diagnosticOf(container) ?? ''; + expect(text).toContain('items[0].items[0].endDate'); + // Spelled as itself, so a forgotten key reads differently from an empty one. + expect(text).toContain('undefined'); + expect(barCountOf(container)).toBe(0); + }); + + it('an EMPTY-STRING date is visible in the diagnostic rather than vanishing', () => { + // The reason values are quoted: unquoted, `endDate: ''` would render as a + // sentence with a hole in it and the author would learn nothing. + const { container } = gantt({ + items: [{ label: 'R', items: [{ title: 'T', startDate: '2024-01-01', endDate: '' }] }], + }); + expect(diagnosticOf(container) ?? '').toContain('""'); + }); +}); + +describe('pin 2 — an inverted pinned range refuses instead of drawing nonsense (objectui#6759 case 2)', () => { + const INVERTED = { items: [GOOD_ROW], minDate: '2030-01-01', maxDate: '2026-03-15' }; + + it('the diagnostic names BOTH ends of the inverted range', () => { + gantt(INVERTED); + + const el = screen.getByTestId(TESTID); + expect(el.getAttribute('role')).toBe('alert'); + + const text = el.textContent ?? ''; + expect(text).toContain('"2030-01-01"'); + expect(text).toContain('"2026-03-15"'); + }); + + it('the negative-width bar is GONE — counted, not read off a style attribute', () => { + const { container } = gantt(INVERTED); + + // Measured on the base: + // CASE-2 axis: [] bars: ["left: 157.9250720461095%; width: -4.322766570605188%;"] + // The bar ELEMENT count is what this asserts. A style-shaped assertion + // would also pass on a bar whose geometry became `NaN`, which is a + // different defect wearing the same clothes (see CASE-2b, pin 3). + expect(barCountOf(container), 'a bar was still drawn on an inverted range').toBe(0); + // The zero-column axis the bar used to hang under is gone with it. + expect(axisOf(container)).toEqual([]); + expect(screen.queryByText('API Design')).toBeNull(); + }); + + it('one pinned end is enough to invert the range', () => { + // Only a pin can invert it — `calculateDateRange` orders its pair with + // `Math.min`/`Math.max`. Pinning just `minDate` past the rows' own end is + // the cheapest way an author gets there. + const { container } = gantt({ items: [GOOD_ROW], minDate: '2030-01-01' }); + const text = diagnosticOf(container) ?? ''; + expect(text).toContain('"2030-01-01"'); + expect(text).toContain('"2024-03-01"'); + expect(barCountOf(container)).toBe(0); + }); + + it('a DEGENERATE range is not inverted and still renders (objectui#6750 boundary)', () => { + // `minDate === maxDate` is #6750's one-bucket axis, and the guard is `>` + // rather than `>=` precisely so it keeps rendering. This is the boundary + // between the two cards and the assertion that stops this one eating the + // other's case. + const { container } = gantt({ items: [GOOD_ROW], minDate: '2024-02-01', maxDate: '2024-02-01' }); + + expect(diagnosticOf(container), 'the degenerate range was refused as inverted').toBeNull(); + expect(axisOf(container)).toEqual(['Feb 2024']); + expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']); + }); +}); + +describe('pin 3 — an unparseable PINNED date refuses too (objectui#6759, the silent one)', () => { + it('names the pinned key and its value', () => { + // Measured on the base as CASE-2b: `axis: [] bars: [null]`. A pinned value + // never reaches `calculateDateRange` — the caller resolves + // `schema.minDate || dateRange.minDate` afterwards — so this input did not + // crash. It failed the other way, silently, which is worse. + const { container } = gantt({ items: [GOOD_ROW], minDate: 'whenever' }); + + const el = screen.getByTestId(TESTID); + expect(el.getAttribute('role')).toBe('alert'); + const text = el.textContent ?? ''; + expect(text).toContain('"whenever"'); + expect(text).toContain('minDate'); + + // The bar that used to be there with no `style` at all. + expect(barCountOf(container)).toBe(0); + }); + + it('an unparseable `maxDate` is caught on the same scan', () => { + const { container } = gantt({ items: [GOOD_ROW], maxDate: 'sometime' }); + const text = diagnosticOf(container) ?? ''; + expect(text).toContain('"sometime"'); + expect(text).toContain('maxDate'); + }); + + it('an EMPTY pinned date is not a fault — the caller discards it', () => { + // `schema.minDate || dateRange.minDate`: an empty string is falsy, so the + // computed range is used and the chart is correct. Judging a value the + // render never reads would refuse a gantt that draws fine. + const { container } = gantt({ items: [GOOD_ROW], minDate: '', maxDate: '' }); + + expect(diagnosticOf(container), 'an ignored empty pin was refused').toBeNull(); + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); + expect(barCountOf(container)).toBe(1); + }); +}); + +describe('pin 4 — the guard is gantt-only (objectui#6759)', () => { + it('a malformed date on the VERTICAL variant is untouched', () => { + // The other variants never build a date range; `formatDate` handles their + // dates on its own terms. Refusing there would be this card reaching past + // its own defect. + const { container } = render( + , + ); + + expect(diagnosticOf(container), 'the gantt guard fired on a vertical timeline').toBeNull(); + expect(screen.getByText('T')).toBeDefined(); + }); +}); + +describe('pin 5 — `ObjectTimeline` reaches the same guard (objectui#6759)', () => { + it('an AUTHORED gantt with a malformed date refuses through the object-bound entry point', () => { + // `items` authored means `ObjectTimeline` is a pass-through, so #6655's + // composed-path refusal correctly does not fire and the schema reaches + // `TimelineRenderer`. Both entry points crashed identically on the base; + // both must refuse identically now. + const schema = { + type: 'timeline', + variant: 'gantt', + items: [{ label: 'R', items: [{ title: 'T', startDate: 'not-a-date', endDate: 'also-bad' }] }], + }; + const { container } = render(); + + expect(diagnosticOf(container) ?? '').toContain('"not-a-date"'); + // #6655's diagnostic belongs to the COMPOSED path and must stay away. + expect( + screen.queryByTestId('timeline-unsupported-variant'), + "#6655's refusal fired on an authored gantt", + ).toBeNull(); + }); +}); + +describe('pin 6 — the healthy path and #6750 are untouched (objectui#6759)', () => { + /** #6750's pin 4 fixture, and its baseline captured on b76ca6764. */ + const GANTT_ROWS = [ + { + label: 'Backend Development', + items: [ + { title: 'API Design', startDate: '2024-01-01', endDate: '2024-01-31', variant: 'success' }, + { title: 'Implementation', startDate: '2024-02-01', endDate: '2024-03-31', variant: 'info' }, + ], + }, + { + label: 'Frontend Development', + items: [ + { title: 'UI Design', startDate: '2024-01-15', endDate: '2024-02-15', variant: 'warning' }, + ], + }, + ]; + + it('a valid gantt draws the same axis and the same bar geometry as before this card', () => { + const { container } = gantt({ scale: 'month', items: GANTT_ROWS }); + + expect(diagnosticOf(container), 'the guard fired on a perfectly good gantt').toBeNull(); + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); + // The full float spelling, same as #6750's pin 4: a guard that rounded, + // clamped or short-circuited the arithmetic would pass a tolerance + // assertion and fail this one. + expect(barStylesOf(container)).toEqual([ + 'left: 0%; width: 33.33333333333333%;', + 'left: 34.44444444444444%; width: 65.55555555555556%;', + 'left: 15.555555555555555%; width: 34.44444444444444%;', + ]); + }); + + it("#6750's EMPTY gantt still gets its sentinel, not this card's refusal", () => { + // The hazard in both directions: this card's guard swallowing the empty + // state, or #6750's sentinel being widened to swallow an unparseable value. + // An empty list has no dates to be unparseable, so it must pass straight + // through to the sentinel. + const { container } = gantt({ items: [] }); + + expect(diagnosticOf(container), "this card's guard ate #6750's empty state").toBeNull(); + expect(axisOf(container).length, 'the empty gantt lost its one-bucket axis').toBe(1); + expect(barCountOf(container)).toBe(0); + }); +}); + +describe('pin 7 — `generateTimeScaleHeaders` was NOT relaxed (objectui#6759)', () => { + it('still refuses an unparseable or inverted range by drawing nothing', () => { + // The fix refuses ABOVE this function; the function itself keeps the + // verdict objectui#6750 recorded in its docstring. Re-pinned here because + // this card is the one that could have been tempted to relax it instead — + // and because it is exported and called directly, so it is not dead code. + expect(generateTimeScaleHeaders('month', '2030-01-01', '2026-03-15')).toEqual([]); + expect(generateTimeScaleHeaders('month', 'not-a-date', '2026-03-15')).toEqual([]); + expect(generateTimeScaleHeaders('month', '', '')).toEqual([]); + }); + + it('and the gantt branch can no longer reach that refusal', () => { + // The invariant the guard buys: every input that used to produce a + // zero-column axis now produces a diagnostic instead. Both of them, read as + // one statement. + for (const schema of [ + { items: [GOOD_ROW], minDate: '2030-01-01', maxDate: '2026-03-15' }, + { items: [GOOD_ROW], minDate: 'whenever' }, + { items: [{ label: 'R', items: [{ title: 'T', startDate: 'not-a-date', endDate: 'x' }] }] }, + ]) { + const { container, unmount } = gantt(schema); + expect(diagnosticOf(container), `no diagnostic for ${JSON.stringify(schema)}`).not.toBeNull(); + unmount(); + } + }); +}); + +describe('pin 8 — the diagnostic strings are translatable (objectui#6759)', () => { + it('both keys live in the package fallback table with their holes intact', () => { + // The provider-less last resort — unit tests and embeds render through it, + // and it is the table `createSafeTranslation` is built from. Holes rather + // than concatenation, for the word-order reason `timeline.scale.*` states. + const malformed = TIMELINE_DEFAULT_TRANSLATIONS['timeline.gantt.unusableRange.malformedDate']; + expect(malformed).toContain('{{path}}'); + expect(malformed).toContain('{{value}}'); + + const inverted = TIMELINE_DEFAULT_TRANSLATIONS['timeline.gantt.unusableRange.inverted']; + expect(inverted).toContain('{{minDate}}'); + expect(inverted).toContain('{{maxDate}}'); + }); +}); diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index 683aee563..c4a6b301f 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -115,6 +115,22 @@ export function resolveTimelineScale(schema: { scale?: unknown }): string { * `./__tests__/timeline-gantt-empty-items.test.tsx` pins — separately from the * other two sites, so a later change that fixes one and not the others goes * red. + * + * ## The refusal on the next line is NOT dead code (objectui#6759) + * + * #6759 put a guard in the gantt branch that refuses an unparseable or inverted + * range before this function is ever called, so the `return headers` below can + * no longer be reached FROM THERE. It was reached before: a `zero-column axis` + * is exactly what case 2 rendered its negative-width bar under, and the fix was + * to refuse above rather than to relax the guard here — this function's verdict + * is still "needed no change". + * + * Two reasons it stays. It is EXPORTED and called directly, including by + * `timeline-gantt-empty-items.test.tsx`'s pin 5a, which holds these exact + * inputs (`'2030-01-01'` / `'2026-03-15'`, and `''` / `''`) returning `[]`. And + * it is the reason the caller's guard is allowed to be the only one: an axis + * that silently drew nothing is what let the row loop below it keep running, so + * the two guards are one invariant read from both ends, not a duplicate. */ export function generateTimeScaleHeaders( scale: string, @@ -236,6 +252,13 @@ function calculateDateRange(items: any[]): { minDate: string; maxDate: string } // there are no rows to draw bars for. See `emptyGanttDateRange` above. if (allDates.length === 0) return emptyGanttDateRange(); + // objectui#6759 — a list whose dates do not PARSE is a different input class + // and is refused by the caller, above this function, naming the offending + // value. So by the time control reaches the reduce below, every entry parses + // and `Math.min` / `Math.max` are finite. Do NOT add a second guard here: a + // sentinel substituted for a value the author got wrong is the consumer-side + // tolerance both #6750 and #6759 rejected — see `findUnusableGanttDate`. + const minTimestamp = Math.min(...allDates.map((d: string) => new Date(d).getTime())); const maxTimestamp = Math.max(...allDates.map((d: string) => new Date(d).getTime())); @@ -245,6 +268,112 @@ function calculateDateRange(items: any[]): { minDate: string; maxDate: string } }; } +/** + * How a gantt date value is SPELLED inside a diagnostic (objectui#6759). + * + * The diagnostic's whole job is to name the value the author actually wrote, so + * the three spellings that would blur it are all avoided: a string is quoted + * (`"not-a-date"`), so an empty or space-padded value is visible rather than + * vanishing into the sentence; `undefined` and `null` are spelled as + * themselves, which is how an author reads a key they forgot to write versus + * one they wrote as empty; and everything else falls back to `String`. + * + * Total by construction, including the `symbol` branch that looks like padding: + * `String(Symbol())` THROWS, and a helper that crashes while reporting an + * author error would replace a named diagnostic with an unexplained blank + * render — the exact failure mode this card exists to remove. + */ +function spellGanttDateValue(value: unknown): string { + if (typeof value === 'string') return JSON.stringify(value); + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + if (typeof value === 'symbol') return value.toString(); + return String(value); +} + +/** A gantt date that does not parse, with the authored path that names it. */ +type UnusableGanttDate = { path: string; value: unknown }; + +/** + * The first gantt date that does not parse — objectui#6759 case 1. + * + * ## What was wrong + * + * objectui#6750 taught `calculateDateRange` about the EMPTY list and nothing + * else. A list whose dates do not PARSE reduced exactly as before: + * `new Date('not-a-date').getTime()` is `NaN`, `Math.min(NaN)` is `NaN`, and + * `new Date(NaN).toISOString()` throws `RangeError: Invalid time value` + * mid-render — the same crash site and the same signature as #6750, on a + * different input class. Measured on this card's base b98352a15, with a + * throwaway probe before any change: + * + * CASE-1 malformed startDate + endDate -> RangeError: Invalid time value + * CASE-1b one date parses, one does not -> RangeError: Invalid time value + * CASE-1c endDate absent entirely -> RangeError: Invalid time value + * + * 1c is the one worth reading twice. `new Date(undefined)` is also an invalid + * date, so a row item that simply OMITS `endDate` crashed the render too — it + * arrives at the same reduce through the same `[item.startDate, item.endDate]` + * flatMap. It is not a separate defect and it is not a widening: any guard + * phrased as "every date in the list must parse" necessarily covers it, and + * deliberately excluding it would mean writing extra code to keep one input + * class crashing. + * + * ## Why the PINS are scanned here too + * + * `schema.minDate` / `schema.maxDate` never reach `calculateDateRange` — the + * caller resolves `schema.minDate || dateRange.minDate` afterwards — so an + * author who pins an unparseable value does not crash. They fail the OTHER + * way, which is worse and is case 2's disease: measured on the same base, + * `minDate: 'whenever'` rendered an axis with zero columns and a bar carrying + * NO `style` attribute at all (`CASE-2b ... bars: [null]`), because + * `calculateBarDimensions` divided by `NaN` and the CSSOM rejects + * `left: NaN%`. One scan covers both origins because they are one question: + * does every date this chart is about to be drawn from parse? + * + * Only a TRUTHY pin is judged. `schema.minDate: ''` is falsy, so the caller's + * `||` discards it and the computed range is used instead; judging a value the + * render will never read would refuse a chart that draws correctly. + * + * Rows before pins, which is the order the caller resolves them in — compute + * from the rows, then let a pin override. When both are wrong the diagnostic + * names the one a reader tracing the render reaches first. + * + * ⚠️ `null` is deliberately NOT a fault here. `new Date(null).getTime()` is + * `0`, not `NaN` — the epoch, not an invalid date — so a `null` date has always + * drawn a bar anchored at 1970 rather than crashing. Refusing it would be a + * behaviour change on an input class this card did not measure or adjudicate; + * it is filed separately instead. + */ +function findUnusableGanttDate( + items: any[], + pinnedMinDate: unknown, + pinnedMaxDate: unknown, +): UnusableGanttDate | undefined { + const doesNotParse = (value: unknown) => Number.isNaN(new Date(value as any).getTime()); + + for (let rowIndex = 0; rowIndex < items.length; rowIndex++) { + const rowItems = (items[rowIndex]?.items || []) as any[]; + for (let itemIndex = 0; itemIndex < rowItems.length; itemIndex++) { + for (const key of ['startDate', 'endDate'] as const) { + const value = rowItems[itemIndex]?.[key]; + if (doesNotParse(value)) { + return { path: `items[${rowIndex}].items[${itemIndex}].${key}`, value }; + } + } + } + } + + for (const [path, value] of [ + ['minDate', pinnedMinDate], + ['maxDate', pinnedMaxDate], + ] as const) { + if (value && doesNotParse(value)) return { path, value }; + } + + return undefined; +} + // Helper function to calculate bar position and width based on dates function calculateBarDimensions( startDate: string, @@ -513,11 +642,94 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time // Gantt/Airtable-style Timeline if (variant === 'gantt') { + /** + * objectui#6759 — an UNUSABLE date range refuses loudly, naming the value + * that made it unusable. + * + * ## The policy, and why it is not a new adjudication + * + * Two defects arrived together because a fixer has to decide ONE policy + * for both, and they failed in OPPOSITE directions, which is itself the + * evidence that no policy existed yet: a malformed date crashed the + * render (`RangeError: Invalid time value`), while an inverted + * author-pinned range drew a bar at `left: 157.9%; width: -4.3%` under a + * header row with zero cells and said nothing. The 2026-08-29 triage + * ruled both are ordinary defects rather than policy questions, and that + * the WORDING — the only genuinely open question — already has a + * precedent one file away: objectui#6655 refuses the object-bound gantt + * with a `role="alert"` diagnostic. So this is that neighbour's shape, + * copied rather than invented. + * + * ## Why HERE, above everything + * + * Placed before `calculateDateRange` and before + * `generateTimeScaleHeaders`, and it establishes an invariant for both: + * every date reaching them parses, and the resolved range is not + * inverted. That matters most for `generateTimeScaleHeaders`, whose own + * guard already refuses an unparseable or inverted range by returning NO + * headers — the zero-column axis case 2 rendered under. That guard is + * pinned by objectui#6750 as "a different input class, left exactly as it + * was", and it stays untouched: it is now simply unreachable from this + * branch, rather than being widened or relaxed. Downstream, + * `calculateBarDimensions` can no longer see a `NaN` or negative + * `totalDuration` at all, so #6750's `totalDuration === 0` guard keeps + * covering exactly the degenerate case it was written for. + * + * ## What this deliberately does NOT do + * + * It does not widen #6750's `emptyGanttDateRange` sentinel to absorb + * these. The card and its triage both rejected that path for the same + * reason: substituting a plausible range for a value the author got wrong + * is consumer-side tolerance, and hiding an author error behind a + * believable render is precisely case 2's disease. An EMPTY list stays an + * ordinary state with a sentinel; an UNPARSEABLE value is refused. + */ + const unusableDate = findUnusableGanttDate(items, schema.minDate, schema.maxDate); + if (unusableDate) { + return ( +
+ {t('timeline.gantt.unusableRange.malformedDate', { + path: unusableDate.path, + value: spellGanttDateValue(unusableDate.value), + })} +
+ ); + } + // Calculate date range from all items const dateRange = calculateDateRange(items); const minDate = schema.minDate || dateRange.minDate; const maxDate = schema.maxDate || dateRange.maxDate; + /** + * objectui#6759 case 2 — the INVERTED range, refused on the same policy. + * + * Only a pin can invert it: `calculateDateRange` builds its pair with + * `Math.min` / `Math.max`, so the computed range is ordered by + * construction, and the sentinel is a single day. It is the caller's + * `schema.minDate || dateRange.minDate` resolution — one pinned end, or + * both — that can put the start after the end. + * + * The comparison is `>` on timestamps, deliberately the same test + * `generateTimeScaleHeaders` makes (`start > end` on two `Date`s, which + * compares by `valueOf`). Equal is NOT inverted: a degenerate + * `minDate === maxDate` range is objectui#6750's one-bucket axis and must + * keep rendering. Measured on this card's base b98352a15: + * + * CASE-2 minDate 2030-01-01 / maxDate 2026-03-15 + * -> axis: [] bars: ["left: 157.9250720461095%; width: -4.322766570605188%;"] + */ + if (new Date(minDate).getTime() > new Date(maxDate).getTime()) { + return ( +
+ {t('timeline.gantt.unusableRange.inverted', { + minDate: spellGanttDateValue(minDate), + maxDate: spellGanttDateValue(maxDate), + })} +
+ ); + } + // Generate time scale headers — the spec `scale` key is the only axis // spelling (the `timeScale` alias is retired, objectui#6355); every spec // scale produces a header row (#2942). diff --git a/packages/plugin-timeline/src/useTimelineTranslation.ts b/packages/plugin-timeline/src/useTimelineTranslation.ts index a34cae3b9..e876d1da6 100644 --- a/packages/plugin-timeline/src/useTimelineTranslation.ts +++ b/packages/plugin-timeline/src/useTimelineTranslation.ts @@ -37,6 +37,18 @@ export const TIMELINE_DEFAULT_TRANSLATIONS: Record = { 'timeline.scale.week': 'Week {{n}}', 'timeline.scale.quarter': 'Q{{quarter}} {{year}}', 'timeline.gantt.rowLabel': 'Items', + // objectui#6759 — an UNUSABLE gantt date range refuses loudly, in the shape + // objectui#6655 established one file away. Two strings rather than one + // because they state two different facts and each has to name ITS OWN + // offending value: a date that does not parse, and a range whose ends are + // the wrong way round. `{{path}}` is the authored location + // (`items[0].items[1].endDate`, or the pinned key), `{{value}}` the value + // spelled as the author wrote it — both holes rather than concatenation, + // for the same word-order reason `timeline.scale.*` above gives. + 'timeline.gantt.unusableRange.malformedDate': + 'Unusable gantt date range — {{path}} is {{value}}, which is not a valid date. Every gantt date has to parse: the startDate and endDate on every row item, plus any minDate / maxDate pinned on the schema.', + 'timeline.gantt.unusableRange.inverted': + 'Unusable gantt date range — minDate {{minDate}} is after maxDate {{maxDate}}. A pinned minDate / maxDate overrides the range computed from the rows, so this axis has no columns and no bar can be placed on it; swap the two values.', // objectui#6655 — the object-bound path REFUSES `variant: 'gantt'`. // // It composes one flat FEED item per record; the renderer's gantt branch diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index f90aa70ca..186ff4501 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -291,6 +291,40 @@ export const REGRESSION_THIS_GATE_MUST_CATCH_BYTES = 89 * 1024; * group is renamed or removed in `vite.config.ts`, this gate stops the build * and says so, and the mapping is re-pinned deliberately. * + * ## Why `framework` moved (objectui#6759) + * + * From 502,000 over a 492,399 reading on `2c8474c04` to 512,000 over 502,405 + * on `a64e96ca8`. What the added bytes buy is user-facing diagnostic text, in + * ten locales, for two ordinary author mistakes that used to be unreported: + * + * - a malformed or absent gantt date crashed the render outright + * (`RangeError: Invalid time value`), and + * - an inverted author-pinned range drew a bar at `width: -4.3%` under a + * header row with zero cells, and said nothing at all. + * + * A hard crash and a confidently-wrong render, both reachable from a typo in + * authored metadata, replaced by a refusal that names the offending value. + * + * The CODE that fix added buys none of this chunk, which is why there was + * nothing to trim instead: `plugin-timeline` is lazy and is not in the eager + * closure at all, so the guard, the value speller and both refusal branches + * cost this budget zero. Every byte of the growth is `packages/i18n` locale + * data, which lands here. Measured on the built chunk by deleting the twenty + * strings and re-gzipping: the ten locales' message text is 1,999 of the + * ~2,283 bytes the chunk grew by, and PER MESSAGE it costs 1,000 bytes + * against the 1,002 that objectui#6655's single refusal — the neighbour whose + * shape this one copied — already costs in the same chunk. The only lever + * left was to say less, in ten languages, about what the author got wrong. + * + * ⚠️ Most of the headroom this consumed was not spent by that card. The + * previous ceiling carried 9,601 bytes and `main` had already drifted to + * 488.4 KB of the 490.2 KB it allowed — 1.8 KB left, 0.02x the regression + * this gate must catch — before objectui#6759 added a byte (`b98352a15`, + * this PR's base, the gate's own printed line). Re-pinning the baseline onto + * a fresh measurement is what puts the gauge back in range. The drift itself, + * and that nobody could say which side moved it, is objectui#6631; this card + * does not close it and deliberately leaves the other two entries alone. + * * ## Raising one * * Same discipline as {@link MAX_EAGER_CLOSURE_GZIP_BYTES}, and the same two @@ -306,15 +340,25 @@ export const REGRESSION_THIS_GATE_MUST_CATCH_BYTES = 89 * 1024; */ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ 'vendor-objectstack': 967_000, - framework: 502_000, + framework: 512_000, 'ui-components': 399_000, }); /** - * The measurement {@link PER_CHUNK_GZIP_CEILINGS} was derived from: one - * `vite build` of `apps/console` on `2c8474c04`, read out of the report that - * build wrote. Exported so the ceilings are CHECKED against it instead of - * merely asserted in this comment. + * The measurement {@link PER_CHUNK_GZIP_CEILINGS} was derived from, read out of + * the report a `vite build` of `apps/console` wrote. Provenance is per KEY, not + * per file, and saying so is the point — a comment that names one commit for + * three numbers taken on two is the drift objectui#6631 is open about: + * + * - `vendor-objectstack`, `ui-components` — `2c8474c04` (objectui#5490). + * - `framework` — `a64e96ca8` (objectui#6759); see "Why `framework` moved" + * above. Safe to state rather than hope, for the reason {@link BASELINE} + * gives about its own commit: the console build's turbo `inputs` cover + * `scripts/vite-*.ts`, not `scripts/check-*.mjs`, so the commit that + * records this figure cannot have changed the figure. + * + * Exported so the ceilings are CHECKED against it instead of merely asserted + * in this comment. * * ⚠️ This is a DIFFERENT and LATER reading than {@link BASELINE} above, which * still carries `4c1623c0c`. On `2c8474c04` the same build measures the closure @@ -335,7 +379,7 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ */ export const PER_CHUNK_BASELINE = Object.freeze({ 'vendor-objectstack': 948_461, - framework: 492_399, + framework: 502_405, 'ui-components': 391_095, });