diff --git a/.changeset/6781-gantt-date-type-rule.md b/.changeset/6781-gantt-date-type-rule.md new file mode 100644 index 000000000..f4b05fd55 --- /dev/null +++ b/.changeset/6781-gantt-date-type-rule.md @@ -0,0 +1,43 @@ +--- +'@object-ui/plugin-timeline': patch +--- + +Gantt dates are now judged by TYPE: a `string`, a finite `number`, or a `Date` +— anything else is refused (objectui#6781, maintainer ruling 2026-08-30). + +**This is a reject-direction change. Metadata that renders a chart today can +stop rendering one.** `new Date(x)` runs ToPrimitive on anything, so values that +are not dates at all used to become instants silently. These now produce the +same loud diagnostic #6759 and #6770 already use — an alert naming the authored +path and the offending value — instead of a chart: + +| authored gantt date — a row item's `startDate` / `endDate`, or a **truthy** `minDate` / `maxDate` pin | before | after | +| --- | --- | --- | +| `false` | a 649-column axis starting Jan 1970, bar `width: -100%`, no warning | refused, named | +| `true` | the same 1970 axis | refused, named | +| `['2024-01-01']` | drew a normal-looking chart | refused, named | +| `[0]` | drew a chart dated to the **year 2000** | refused, named | +| `{ toString() { return '2024-01-01' } }` | drew a normal-looking chart | refused, named | +| a `bigint` or a `symbol` | threw an uncaught `TypeError` mid-render | refused, named | + +**Falsy pins are not affected, and never were.** A `minDate` / `maxDate` of +`false`, `0` or `''` is discarded by the renderer's existing truthy-only `||` +before anything judges it: such a pin never produced a 1970 axis and it is not +refused now — the chart simply uses the range computed from the rows, exactly as +it did before. The before/after readings above are row-date readings. + +**If your gantt stops drawing after this upgrade, the diagnostic names the exact +authored path** (e.g. `items[0].items[0].endDate`). Fix it at the producer: emit +a date string (`'2024-01-01'`), a millisecond timestamp (`1704067200000`), or a +`Date`. A boolean or an object arriving in a date field means an upstream +mapping picked the wrong column — the chart was drawing 1970 from it before, and +that render was never right. + +**`0` keeps working, deliberately.** It is a legitimate epoch timestamp — under +a millisecond encoding an author who writes `0` means 1970-01-01 — so it is +accepted and renders exactly as it did before. `NaN` and `Infinity` are refused, +as they already were. + +Unchanged for everyone else: valid string / numeric / `Date` dates draw the same +axis and the same bar geometry, an empty gantt keeps its one-bucket sentinel, +and `null` / absent dates keep the identical diagnostic they got before. diff --git a/packages/plugin-timeline/src/__tests__/timeline-gantt-date-type-rule-6781.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-gantt-date-type-rule-6781.test.tsx new file mode 100644 index 000000000..942b78f35 --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/timeline-gantt-date-type-rule-6781.test.tsx @@ -0,0 +1,417 @@ +/** + * 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#6781 — a gantt date must BE a date type; the rest is refused. + * + * ## The rule, as a rule + * + * Maintainer ruling 2026-08-30, option A of three. The accept set is + * + * string | FINITE number | Date + * + * and everything else is refused through #6759 / #6770's existing loud + * diagnostic — no second channel, no new i18n key. `renderer.tsx`'s + * `isGanttDateType` carries the rule and the reasoning; this file pins it. + * + * ## What was wrong + * + * `new Date(x)` runs ToPrimitive on anything, so it turns values that are not + * dates at all into instants. Measured on this card's base fab4802e3, one row + * item and a throwaway probe, `startDate: '2024-01-01'` with `endDate` varied: + * + * endDate: false -> NO diagnostic, axis 649 columns + * (Jan 1970 … Jan 2024), bars + * ["left: 100%; width: -100%;"] + * endDate: true -> NO diagnostic, axis 649 columns, bars + * ["left: 100%; width: -99.99999999994131%;"] + * endDate: ['2024-01-01'] -> NO diagnostic, axis ["Jan 2024"], bars + * ["left: 0%; width: 100%;"] + * endDate: {toString: () => '2024-01-01'} + * -> NO diagnostic, axis ["Jan 2024"] + * endDate: 0n -> THREW TypeError, uncaught, mid-render + * endDate: Symbol('s') -> THREW TypeError, uncaught, mid-render + * + * The `false` reading is byte-identical to the `endDate: 0` symptom the card + * was filed on — the fifty-four-year axis and the negative-width bar — which + * is the point: the chart could not tell "the author means the epoch" from + * "a mapping layer emitted a boolean into a date column". + * + * Two of those base readings are CRASHES, not silent renders: `new Date` throws + * on a `bigint` and on a `symbol`, so the guard itself died while trying to + * report. Judging the TYPE before parsing is what makes the predicate total. + * + * ## `0` is KEPT — the one behaviour this card must NOT change + * + * The ruling is explicit. `0` is a finite number, so it is accepted and still + * draws the epoch: under a `startDate: 1704067200000` encoding, `0` really is a + * date. Refusing it would take away a real capability to catch a hypothetical + * input. Pins 3 and 4 below are that control, and they include the card's own + * filed `endDate: 0` reading, asserted UNCHANGED down to the bar geometry. + * + * ## Assertions count BAR ELEMENTS, never styles + * + * #6759's rule, inherited through #6770: a bar whose geometry is `NaN` carries + * NO `style` attribute at all (the CSSOM rejects `left: NaN%`), so an assertion + * phrased over styles reads identically for "the bar is gone" and "the bar is + * there and broken". Refusal assertions are positive about the diagnostic and + * count elements. Styles are read only on the paths that must stay UNCHANGED, + * where their exact float spelling is the whole point. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ObjectTimeline } from '../ObjectTimeline'; +import { TimelineRenderer } from '../renderer'; + +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. See 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-path pins only. */ +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 row carrying one item, so a case only has to say what is wrong with it. */ +const rowWith = (item: Record) => [{ label: 'R', items: [{ title: 'T', ...item }] }]; + +/** One legitimate row — the thing that is NOT at fault in the pinned-range cases. */ +const GOOD_ROW = { + label: 'Backend', + items: [{ title: 'API Design', startDate: '2024-01-01', endDate: '2024-03-01' }], +}; + +describe('pin 1 — the REJECT half of the type rule (objectui#6781)', () => { + /** + * Every one of these rendered a chart on the base with NO diagnostic at all. + * They are the accept/reject move this card makes, one row per input class. + */ + const newlyRefused: [string, unknown][] = [ + ['false — a boolean in a date column, the filed shape', false], + ['true — the same class, one millisecond later', true], + ["['2024-01-01'] — an array whose toString happens to parse", ['2024-01-01']], + ['[0] — an array that parsed as the YEAR 2000', [0]], + ['{ toString } — any object with a plausible spelling', { toString: () => '2024-01-01' }], + ]; + + for (const [label, value] of newlyRefused) { + it(`refuses ${label}`, () => { + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: value }) }); + + const el = screen.getByTestId(TESTID); + // #6655's shape by way of #6759/#6770: an alert, not a silent panel, and + // the SAME channel — no second diagnostic was invented for type faults. + expect(el.getAttribute('role')).toBe('alert'); + expect(el.textContent ?? '', 'the diagnostic did not name the authored path').toContain( + 'items[0].items[0].endDate', + ); + // The 1970 axis and the bar are both gone. Counted, not read off a style. + expect(axisOf(container), 'a chart was still drawn from a non-date').toEqual([]); + expect(barCountOf(container)).toBe(0); + expect(screen.queryByText('T')).toBeNull(); + }); + } + + it('refuses a non-finite number — the FINITE half, stated rather than inherited', () => { + // `new Date(Infinity)` is already an invalid date, so this verdict is + // unchanged from the base. It is pinned because the rule now says "finite" + // out loud, and a later reader must be able to see that the words and the + // behaviour agree. + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: value }) }); + expect(diagnosticOf(container) ?? '', `${value} was accepted as a gantt date`).toContain( + 'items[0].items[0].endDate', + ); + expect(barCountOf(container)).toBe(0); + } + }); + + it('an invalid `Date` OBJECT is still refused — the type gate does not excuse it', () => { + // `new Date(NaN)` passes `instanceof Date` and is then refused by the parse + // check, which is exactly where that question belongs. The hazard this pins + // is a type gate written as an early RETURN instead of an early REJECT. + const { container } = gantt({ + items: rowWith({ startDate: '2024-01-01', endDate: new Date(Number.NaN) }), + }); + + expect(diagnosticOf(container) ?? '').toContain('items[0].items[0].endDate'); + expect(barCountOf(container)).toBe(0); + }); +}); + +describe('pin 2 — the guard no longer CRASHES while reporting (objectui#6781)', () => { + /** + * `new Date` throws a `TypeError` on these two rather than returning an + * invalid date, so on the base the render died inside `findUnusableGanttDate` + * itself — a blank screen where a named diagnostic belongs. Judging the type + * first is what makes the predicate total. + */ + it('a bigint date is named, not thrown', () => { + // Base: THREW TypeError: Cannot convert a BigInt value to a number. + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: 0n }) }); + + expect(diagnosticOf(container) ?? '').toContain('items[0].items[0].endDate'); + expect(barCountOf(container)).toBe(0); + }); + + it('a symbol date is named, not thrown — and reaches `spellGanttDateValue`’s symbol branch', () => { + // Base: THREW TypeError: Cannot convert a Symbol value to a number. That + // branch was written by #6759 as "total by construction" and documented as + // unreachable; the type rule is what reaches it. + const { container } = gantt({ + items: rowWith({ startDate: '2024-01-01', endDate: Symbol('oops') }), + }); + + const text = diagnosticOf(container) ?? ''; + expect(text).toContain('items[0].items[0].endDate'); + expect(text, 'the symbol was not spelled as itself').toContain('Symbol(oops)'); + expect(barCountOf(container)).toBe(0); + }); +}); + +describe('pin 3 — `0` IS KEPT: the control that must stay green (objectui#6781)', () => { + it('the card’s own `endDate: 0` reading is UNCHANGED, down to the bar geometry', () => { + // This is the measurement the card was filed on — axis 649 columns + // Jan 1970 … Jan 2024, bars ["left: 100%; width: -100%;"] — and the ruling + // makes it LEGAL. `0` is a finite number and therefore a date. If a future + // widening of the accept set catches `0` on its way past, this row is the + // one that goes red, and it is the one that must not. + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: 0 }) }); + + expect(diagnosticOf(container), '`0` was refused — the ruling keeps it').toBeNull(); + const axis = axisOf(container); + expect(axis.length).toBe(649); + expect(axis[0]).toBe('Jan 1970'); + expect(axis[axis.length - 1]).toBe('Jan 2024'); + expect(barStylesOf(container)).toEqual(['left: 100%; width: -100%;']); + }); + + it('`0` as a MEANINGFUL epoch start draws a correct chart', () => { + // The capability the ruling refused to take away, in the shape an author + // actually writes it: a `startDate: 1704067200000`-style millisecond + // encoding whose first row happens to begin at the epoch. + const { container } = gantt({ + scale: 'month', + items: rowWith({ startDate: 0, endDate: Date.UTC(1970, 2, 1) }), + }); + + expect(diagnosticOf(container), 'an epoch-anchored chart was refused').toBeNull(); + expect(axisOf(container)).toEqual(['Jan 1970', 'Feb 1970', 'Mar 1970']); + expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']); + }); +}); + +describe('pin 4 — the rest of the ACCEPT set still renders (objectui#6781)', () => { + it('a string date renders', () => { + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: '2024-03-01' }) }); + expect(diagnosticOf(container)).toBeNull(); + expect(barCountOf(container)).toBe(1); + }); + + it('a finite millisecond timestamp renders', () => { + // #6770 pin 3's fixture, re-asserted: the type rule must not narrow it. + const { container } = gantt({ + items: rowWith({ startDate: '2024-01-01', endDate: Date.UTC(2024, 2, 1) }), + }); + expect(diagnosticOf(container)).toBeNull(); + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); + expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']); + }); + + it('a `Date` instance renders', () => { + const { container } = gantt({ + items: rowWith({ startDate: new Date('2024-01-01'), endDate: new Date('2024-03-01') }), + }); + expect(diagnosticOf(container)).toBeNull(); + expect(barCountOf(container)).toBe(1); + }); + + it('a valid gantt draws the same axis and the same bar geometry as before this card', () => { + // #6759 pin 6's fixture and its baseline, inherited through #6770: a guard + // that rounded, clamped or short-circuited the arithmetic would pass a + // tolerance assertion and fail this one. + const { container } = gantt({ + scale: 'month', + items: [ + { + 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' }, + ], + }, + ], + }); + + expect(diagnosticOf(container), 'the type rule fired on a perfectly good gantt').toBeNull(); + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); + expect(barStylesOf(container)).toEqual([ + 'left: 0%; width: 33.33333333333333%;', + 'left: 34.44444444444444%; width: 65.55555555555556%;', + 'left: 15.555555555555555%; width: 34.44444444444444%;', + ]); + }); +}); + +describe('pin 5 — `null` / `undefined` are SUBSUMED, and nothing an author sees moves', () => { + /** + * The type rule is strictly wider than #6770's `value === null` and #6759's + * "does it parse", so both now come out of the SAME arm. The hazard that + * pins: a rewrite that reports them twice, re-routes them to a different + * message, or spells them alike. + */ + it('a `null` row date is still refused and still spelled `null` (objectui#6770)', () => { + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01', endDate: null }) }); + + const text = diagnosticOf(container) ?? ''; + expect(text).toContain('null'); + expect(text).toContain('items[0].items[0].endDate'); + expect(barCountOf(container)).toBe(0); + }); + + it('an ABSENT row date is still refused and still spelled `undefined` (objectui#6759)', () => { + const { container } = gantt({ items: rowWith({ startDate: '2024-01-01' }) }); + + const text = diagnosticOf(container) ?? ''; + expect(text).toContain('undefined'); + expect(text).not.toContain('null'); + expect(text).toContain('items[0].items[0].endDate'); + }); + + it('exactly ONE diagnostic is rendered — the wider rule does not double-report', () => { + const { container } = gantt({ items: rowWith({ startDate: null, endDate: false }) }); + + expect(container.querySelectorAll(`[data-testid="${TESTID}"]`).length).toBe(1); + // Rows before pins, first fault wins: the `null` startDate is reached first. + expect(diagnosticOf(container) ?? '').toContain('items[0].items[0].startDate'); + }); + + it('an unparseable STRING is still named by its quoted value (objectui#6759)', () => { + const { container } = gantt({ items: rowWith({ startDate: 'not-a-date', endDate: 'also-bad' }) }); + expect(diagnosticOf(container) ?? '').toContain('"not-a-date"'); + }); +}); + +describe('pin 6 — the PINNED path keeps its `||` asymmetry (objectui#6759 boundary)', () => { + it('a FALSY non-date pin is still discarded, not refused', () => { + // The caller resolves `schema.minDate || dateRange.minDate`, so `false` and + // `0` never reach the render at all. Judging a value nothing reads would + // refuse a chart that draws correctly — #6759's empty-string rule and + // #6770's null-pin rule, unchanged by the type rule. + for (const pin of [false, 0, '']) { + const { container } = gantt({ items: [GOOD_ROW], minDate: pin, maxDate: pin }); + expect(diagnosticOf(container), `a discarded ${String(pin)} pin was refused`).toBeNull(); + expect(axisOf(container)).toEqual(['Jan 2024', 'Feb 2024', 'Mar 2024']); + expect(barStylesOf(container)).toEqual(['left: 0%; width: 100%;']); + } + }); + + it('a TRUTHY non-date pin is refused and named at its authored path', () => { + // Base: `minDate: ['2024-01-01']` coerced to a parseable string and drew a + // chart. It is a pin the author wrote and the render reads, so it is judged. + const { container } = gantt({ items: [GOOD_ROW], minDate: ['2024-01-01'] }); + + expect(diagnosticOf(container) ?? '').toContain('minDate'); + expect(barCountOf(container)).toBe(0); + }); +}); + +describe('pin 7 — the neighbouring cards are untouched (objectui#6750, objectui#6655)', () => { + it("#6750's EMPTY gantt still gets its sentinel rather than this card's refusal", () => { + const { container } = gantt({ items: [] }); + + expect(diagnosticOf(container), "the type rule 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); + }); + + it('a non-date on the VERTICAL variant is untouched', () => { + // The gantt guard stays gantt-only, as #6759 pin 4 and #6770 pin 4 have it: + // the other variants never build a date range. + const { container } = render( + , + ); + + expect(diagnosticOf(container), 'the gantt guard fired on a vertical timeline').toBeNull(); + expect(screen.getByText('T')).toBeDefined(); + }); + + it('`ObjectTimeline` reaches the same refusal on an authored gantt', () => { + // `items` authored means `ObjectTimeline` is a pass-through, so #6655's + // composed-path refusal correctly stays away and the schema reaches + // `TimelineRenderer`. Both entry points drew the 1970 chart on the base. + const { container } = render( + , + ); + + expect(diagnosticOf(container) ?? '').toContain('items[0].items[0].endDate'); + expect( + screen.queryByTestId('timeline-unsupported-variant'), + "#6655's refusal fired on an authored gantt", + ).toBeNull(); + }); +}); diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index 32de139a2..5a47a6fef 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -388,28 +388,147 @@ type UnusableGanttDate = { path: string; value: unknown }; * itself, for "one they wrote as empty"; until this card that branch could not * be reached. * - * ## Where the line is: `null`, not "coerces to the epoch" + * ## Where the line is: the RULED TYPE RULE (objectui#6781) * - * The test is `value === null` and deliberately nothing wider. `0` coerces to - * the epoch too (measured: the same 649-column axis) — but `0` is a legitimate - * epoch timestamp, indistinguishable from an author who means 1970-01-01, and - * `startDate: 1704067200000` renders 2024 correctly today. `null` is not a - * timestamp at all; it is what a data source hands you for a field that is - * unset. That is the value refused here. + * #6770 drew this line at `value === null` and deliberately nothing wider, + * because it had measured only that one spelling. objectui#6781 measured the + * rest of the type space and the maintainer ruled the line to be a TYPE rule + * instead — see `isGanttDateType` below, which is now the whole of "is this a + * date at all". `null` is no longer named on its own; it is refused by that + * rule, through this same function, with the same path and the same spelling + * (measured — see the table on `isGanttDateType`). + * + * `0` stays ACCEPTED under that rule, which is the part worth reading twice. + * `0` coerces to the epoch just like `null` did (measured: the same 649-column + * axis) — but `0` is a legitimate epoch timestamp, indistinguishable from an + * author who means 1970-01-01, and `startDate: 1704067200000` renders 2024 + * correctly today. `null` is not a timestamp at all; it is what a data source + * hands you for a field that is unset. The type rule keeps exactly that + * distinction and widens nothing around it. + */ +/** + * THE RULED TYPE RULE for a gantt date — objectui#6781, maintainer ruling + * 2026-08-30 (option A of that card's three). + * + * ## The accept set, stated as an accept set + * + * string | FINITE number | Date + * + * and nothing else. This is a RULE, not a list of values someone tripped over: + * every input class is judged by the same question — "is this the kind of thing + * a date can be written as?" — and the answer does not depend on which + * coercion `new Date` happens to perform on it. The three cards before this one + * each added one more refused VALUE (`undefined` #6759, `null` #6770); this one + * replaces that accretion with the rule they were each approximating, so the + * next exotic value does not need a fourth card. + * + * ## Why a type rule rather than more values + * + * `new Date(x)` accepts far more than dates. It runs ToPrimitive on anything, + * so a boolean, an array, or any object with a plausible `toString` becomes an + * instant, silently. Measured on this card's base fab4802e3, one row item and a + * throwaway probe (`endDate` set, `startDate: '2024-01-01'`): + * + * endDate: false -> NO diagnostic, axis 649 columns + * (Jan 1970 … Jan 2024), bars ["left: 100%; + * width: -100%;"] + * endDate: true -> NO diagnostic, axis 649 columns, bars + * ["left: 100%; width: -99.99999999994131%;"] + * endDate: ['2024-01-01'] -> NO diagnostic, axis ["Jan 2024"], bars + * ["left: 0%; width: 100%;"] + * endDate: [0] -> NO diagnostic (ToPrimitive gives "0", which + * parses as the year 2000) + * endDate: {toString: () => '2024-01-01'} + * -> NO diagnostic, axis ["Jan 2024"] + * + * The first is the filed shape of this defect and is byte-identical to the + * `endDate: 0` reading the card was filed on: a fifty-four-year axis and a + * NEGATIVE-width bar, with no diagnostic. `false` is where a wrong value hides: + * a boolean reaching a date field means a mapping layer emitted the wrong + * column, and drawing a 1970 axis from it is the silent-wrong-render this + * chart's three previous cards each closed one spelling of. + * + * ## Why `0` is KEPT (the ruling is explicit, and it is the load-bearing half) + * + * `0` is a finite number, so it is ACCEPTED and still draws 1970-01-01. That is + * ruled, not incidental: `0` is a legitimate epoch timestamp, and under a + * `startDate: 1704067200000` encoding an author who writes `0` means the epoch. + * Refusing it would take away a real capability to catch a hypothetical input. + * Measured for this card: the repo has ZERO sites feeding an integer `0` into a + * gantt date (control: 222 gantt-date assignment sites repo-wide), so the + * capability is preserved on the encoding's terms rather than on a consumer's — + * which is why the pin for it is a TEST and not a call site. + * + * ## Why FINITE, when `new Date` already refuses `NaN` and `Infinity` + * + * It moves nothing measured: `new Date(NaN)` and `new Date(Infinity)` are both + * invalid dates, so the parse check below already refused them and still would. + * The clause is here so the accept set can be READ as the rule it is — "a + * number that is an instant" — instead of leaving the exclusion to a coercion + * detail that a later reader would have to rediscover. Same verdict, stated + * rather than inherited. + * + * ## `null` and `undefined` are SUBSUMED, and their diagnostics do not move + * + * Neither is a string, a finite number, or a `Date`, so both are refused here + * — #6759's `undefined` and #6770's `null` alike — and neither is named + * separately any more. This is strictly wider than `value === null` and it + * changes nothing an author sees: the path is the same, and + * `spellGanttDateValue` still spells them as themselves. The reasoning that put + * them here is preserved in this function's header, which is where a reader + * asking "why is an absent date refused?" goes. + * + * ## The order is load-bearing: this gate runs BEFORE `new Date` + * + * `new Date(x)` does not merely refuse a `bigint` or a `symbol` — it THROWS + * `TypeError: Cannot convert a BigInt value to a number`. Measured on the same + * base, both crashed the render from inside the guard itself: + * + * endDate: 0n -> THREW TypeError (uncaught, mid-render) + * endDate: Symbol('s') -> THREW TypeError (uncaught, mid-render) + * + * A guard that crashes while reporting an author error replaces a named + * diagnostic with a blank screen — the exact failure mode `spellGanttDateValue` + * calls out one docblock up, and the reason its `symbol` branch was written + * "total by construction". Refusing by TYPE first makes this predicate total: + * `new Date` is only ever reached with a string, a finite number, or a `Date`, + * none of which throw. Those two classes now take the ordinary refusal path, + * and that `symbol` branch is reachable at last. + * + * `instanceof Date` is this repo's single idiom for "is a Date" (see + * `packages/core/src/validation/validation-engine.ts` and + * `components/src/renderers/complex/data-table.tsx`); an invalid `Date` object + * passes this gate and is then refused by the parse check below, where it + * belongs. */ +const isGanttDateType = (value: unknown): value is string | number | Date => + typeof value === 'string' || + (typeof value === 'number' && Number.isFinite(value)) || + value instanceof Date; + function findUnusableGanttDate( items: any[], pinnedMinDate: unknown, pinnedMaxDate: unknown, ): UnusableGanttDate | undefined { /** - * Two ways a gantt date is unusable. Everything but `null` is judged by - * whether it parses; `null` has to be named separately precisely because it - * DOES parse — to the epoch — while meaning the opposite of a date - * (objectui#6770, see the header). + * Two ways a gantt date is unusable, in this order and only this order. + * + * 1. It is not one of the three things a date can be written as — the ruled + * TYPE rule, objectui#6781, spelled out on `isGanttDateType` above. This + * subsumes #6770's `null` and #6759's `undefined`, and it is what refuses + * `false` / `true` / an array / an object with a `toString`, all of which + * `new Date` would otherwise coerce into a silent 1970 axis. + * 2. It is the right TYPE but does not parse — #6759's original question, + * still asked, and still the only thing that separates `'2024-01-01'` from + * `'not-a-date'` or a valid `Date` from `new Date(NaN)`. + * + * The type gate must come first: `new Date` THROWS on a `bigint` or a + * `symbol`, so asking "does it parse?" of an unjudged value crashes the guard + * (measured — see `isGanttDateType`). */ const isUnusable = (value: unknown) => - value === null || Number.isNaN(new Date(value as any).getTime()); + !isGanttDateType(value) || Number.isNaN(new Date(value).getTime()); for (let rowIndex = 0; rowIndex < items.length; rowIndex++) { const rowItems = (items[rowIndex]?.items || []) as any[];