From e3c074869803bf9e43cff5b648510299691c29ac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 14:39:21 +0000 Subject: [PATCH] fix(types): align TimelineSchema with the keys the timeline renderer reads TimelineSchema declared events (required), orientation and position and nothing else, while TimelineRenderer -- annotated schema: TimelineSchema -- reads variant, items, dateFormat, onItemClick, minDate, maxDate, rowLabel, scale and timeScale off that node and none of the three declared keys. BaseSchema's index signature typed every undeclared key as any, so the annotation constrained nothing and tsc could not see the divergence. Declares the eight authorable keys on the interface and its zod mirror together; scale becomes the canonical axis key (the spec spelling the renderer already reads first) and the designer now offers all six buckets instead of three; timeScale stays as a deprecated alias. events goes required -> optional, which is what the docs page's own TypeScript example needed in order to compile. events, orientation and position stay declared and stay read by nothing: removing them is a breaking narrowing routed through ADR-0049, not done here. Part of #6170 --- .../6170-timeline-schema-declared-keys.md | 48 +++ content/docs/plugins/plugin-timeline.mdx | 19 +- packages/plugin-timeline/src/renderer.tsx | 24 +- .../__tests__/timeline-declared-keys.test.ts | 279 ++++++++++++++++++ packages/types/src/data-display.ts | 140 ++++++++- packages/types/src/index.ts | 1 + packages/types/src/zod/data-display.zod.ts | 37 ++- 7 files changed, 535 insertions(+), 13 deletions(-) create mode 100644 .changeset/6170-timeline-schema-declared-keys.md create mode 100644 packages/types/src/__tests__/timeline-declared-keys.test.ts diff --git a/.changeset/6170-timeline-schema-declared-keys.md b/.changeset/6170-timeline-schema-declared-keys.md new file mode 100644 index 0000000000..da89bd6eca --- /dev/null +++ b/.changeset/6170-timeline-schema-declared-keys.md @@ -0,0 +1,48 @@ +--- +'@object-ui/plugin-timeline': minor +'@object-ui/types': minor +--- + +`TimelineSchema` now declares the presentational keys the timeline renderer actually reads +(objectui#6170, maintainer ruling 2026-08-25 — the same family rule adopted on +objectui#6172: the exported type aligns to the measured authored + read set). + +Before this, `TimelineSchema` declared `events` (required), `orientation` and `position`, +and nothing else. `TimelineRenderer` is annotated `schema: TimelineSchema` and reads nine +keys off that node — `variant`, `items`, `dateFormat`, `onItemClick`, `minDate`, `maxDate`, +`rowLabel`, `scale`, `timeScale` — and **none** of the three that were declared. The docs +property table and the registration's own designer `inputs` had agreed with the renderer +all along; only the exported type disagreed. It was invisible to `tsc` because `BaseSchema` +carries `[key: string]: any`, so every undeclared key resolved as `any` and the annotation +constrained nothing. + +The most visible casualty was the docs page's own TypeScript example, which did not +compile: `Property 'events' is missing in type '{ type: "timeline"; variant: string; items: +… }' but required in type 'TimelineSchema'`. The page taught an authoring form its own +published type refused. + +**Declared now** (TS interface and the `@object-ui/types/zod` mirror together): `variant`, +`items`, `dateFormat`, `scale`, `timeScale`, `rowLabel`, `minDate`, `maxDate`. `onItemClick` +is deliberately left undeclared — it is a runtime slot `ObjectTimeline` installs, and this +package keeps callback-shaped keys off the authored surface. + +**`scale` is the canonical axis key.** It is `@objectstack/spec`'s `ui/TimelineConfig.json` +spelling and the one `resolveTimelineScale` reads first (`scale ?? timeScale`). The designer +now offers it, with all six buckets: `hour` / `quarter` / `year` have rendered correctly +since objectui#2942 but were offered by neither the designer (which listed three) nor the +exported type (which listed none), so they were authorable and undiscoverable. `timeScale` +stays as a deprecated alias so stored JSON keeps working; retiring it is routed separately. + +**`events` is now optional.** It was required, which is why the documented authoring form +did not type-check. That widening is the only non-additive change here — strictly more +programs compile and strictly more input parses than before. `events`, `orientation` and +`position` remain declared and remain read by nothing; a timeline authored with `events` +still renders an empty rail. Their removal is a breaking narrowing of a published type and +is routed through ADR-0049 enforce-or-remove as its own change, not smuggled into this one. + +Accept-set note for consumers: keys that previously resolved as `any` are now typed, so a +value the renderer never implemented — `variant: 'diagonal'`, `dateFormat: 'medieval'`, +`scale: 'fortnight'` — is a type error and a Zod rejection where it used to pass silently. +Nothing that renders today stops rendering. `BaseSchema`'s index signature is untouched, so +an undeclared key is still accepted by both halves (objectui#5155 / objectui#6269 own that +ceiling). diff --git a/content/docs/plugins/plugin-timeline.mdx b/content/docs/plugins/plugin-timeline.mdx index dd750a94f9..ed89574354 100644 --- a/content/docs/plugins/plugin-timeline.mdx +++ b/content/docs/plugins/plugin-timeline.mdx @@ -58,7 +58,7 @@ const schema = { - **Customizable Markers**: Color-coded markers with icon support - **Date Formatting**: Multiple date format options - **Gantt Charts**: Project timeline visualization with task bars -- **Time Scales**: Day, week, or month scales for Gantt view +- **Time Scales**: Hour, day, week, month, quarter, or year scales for Gantt view - **Lightweight**: Pure CSS and React components ## Schema API @@ -72,7 +72,8 @@ const schema = { items?: TimelineItem[], dateFormat?: 'short' | 'long' | 'iso', // Gantt-specific - timeScale?: 'day' | 'week' | 'month', + scale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year', + timeScale?: 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year', // deprecated alias of `scale` rowLabel?: string, minDate?: string, maxDate?: string, @@ -115,12 +116,24 @@ const schema = { | `variant` | string | `'vertical'` | Timeline layout: vertical, horizontal, or gantt | | `items` | array | `[]` | Array of timeline items | | `dateFormat` | string | `'short'` | Date formatting: short, long, or iso | -| `timeScale` | string | `'month'` | Gantt time scale: day, week, or month | +| `scale` | string | `'month'` | Gantt axis bucket: hour, day, week, month, quarter, or year | +| `timeScale` | string | — | **Deprecated** — the pre-spec spelling of `scale`, still read as a fallback | | `rowLabel` | string | `'Items'` | Label for Gantt rows | | `minDate` | string | auto | Override min date for Gantt (YYYY-MM-DD) | | `maxDate` | string | auto | Override max date for Gantt (YYYY-MM-DD) | | `className` | string | `''` | Additional Tailwind CSS classes | +Every key above is declared on the exported `TimelineSchema`, so an editor +completes them and a wrong value is a type error. `scale` is the canonical axis +key — it is `@objectstack/spec`'s `ui/TimelineConfig.json` spelling and the one +the renderer reads first (`scale ?? timeScale`). + + +`events`, `orientation` and `position` are also declared on `TimelineSchema` +and are **read by nothing**. A timeline authored with `events` renders an empty +rail — use `items`. They are deprecated and scheduled for removal. + + ## Variants ### Marker Variants diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index 29e4865a45..7e1a426478 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -524,13 +524,33 @@ ComponentRegistry.register( label: 'Date Format', defaultValue: 'short', }, + // The designer's axis key is `scale` — the spec's spelling + // (`ui/TimelineConfig.json`) and the one `resolveTimelineScale` prefers. + // It offers all six buckets: `hour` / `quarter` / `year` have rendered + // correctly since #2942 but were offered by neither the designer nor the + // exported type, so they were authorable and undiscoverable (objectui#6170). { - name: 'timeScale', + name: 'scale', type: 'enum', - enum: ['day', 'week', 'month'], + enum: [...TIMELINE_SCALES], label: 'Time Scale (Gantt only)', defaultValue: 'month', }, + // Kept so a stored `timeScale` still round-trips through the designer. + // Deprecated in favour of `scale`; retiring the alias is routed separately + // (objectui#6170 maintainer ruling 2026-08-25). `ComponentInput` has no + // `deprecated` slot and no index signature, so the notice lives in + // `description` — this package's stated ceiling for anything the coarse + // `type` cannot express. + { + name: 'timeScale', + type: 'enum', + enum: [...TIMELINE_SCALES], + label: 'Time Scale (Gantt only)', + description: + 'DEPRECATED — use `scale`, which @objectstack/spec owns and this renderer reads first. Kept so stored JSON keeps working.', + advanced: true, + }, { name: 'rowLabel', type: 'string', diff --git a/packages/types/src/__tests__/timeline-declared-keys.test.ts b/packages/types/src/__tests__/timeline-declared-keys.test.ts new file mode 100644 index 0000000000..114be7dca2 --- /dev/null +++ b/packages/types/src/__tests__/timeline-declared-keys.test.ts @@ -0,0 +1,279 @@ +/** + * 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. + */ + +/** + * Declaration pin — the eight presentational keys `TimelineRenderer` reads that + * `TimelineSchema` did not declare (objectui#6170). + * + * ## What was wrong + * + * `TimelineSchema` declared `events` (REQUIRED), `orientation` and `position`, + * and nothing else. `TimelineRenderer` + * (`plugin-timeline/src/renderer.tsx:250`) is annotated `schema: + * TimelineSchema` and reads nine keys off it — `variant`, `items`, + * `dateFormat`, `onItemClick`, `minDate`, `maxDate`, `rowLabel`, `scale`, + * `timeScale` — and NONE of the three that were declared. So the exported type + * matched neither what authors write, nor what the designer offers (the + * registration's own `inputs`), nor what the renderer reads; those three agreed + * with each other all along. + * + * The divergence was invisible to `tsc` because `BaseSchema` carries + * `[key: string]: any`, so every undeclared key resolved as `any` and the + * annotation constrained nothing. Its most visible casualty was the docs page's + * own TypeScript example, which did not compile — `events` was required and + * nothing on the page ever writes it. That example is pinned below. + * + * Eight of the nine are declared. `onItemClick` is deliberately NOT: it is a + * runtime slot `ObjectTimeline` installs when it composes the schema it hands + * to `TimelineRenderer`, and this package keeps callback-shaped keys off the + * authored surface (`RuntimeOnlyDeclared` in `zod-mirror-parity.test.ts`). + * + * ## What the pin has teeth against, and what it does not + * + * Same ceiling as objectui#5903's gantt pin, and stated here rather than left + * to be assumed. `BaseSchema` is `.passthrough()` on the zod side and carries + * an index signature on the TS side (objectui#5155 / objectui#6269 own that + * ceiling; this card does not touch it), so: + * + * - an UNDECLARED key is still accepted by both halves. Declaring these eight + * did NOT buy rejection of a misspelling; + * - a DECLARED key IS validated. `variant: 'diagonal'` type-checked and + * parsed green before this card and is refused now — that is the accept-set + * narrowing landed here; + * - on the TS side a read site can never be the detector, because the index + * signature types `schema.variant` as `any` either way. So the compile-time + * pin is the `@ts-expect-error` block at the bottom: remove a declaration + * and its member resolves to `any`, the wrong-typed assignment starts + * succeeding, and the now-unused directive fails the build (TS2578) NAMING + * the key. `tsconfig.test.json` compiles this file, so that is real + * enforcement and not decoration (objectui#3009). + * + * ## The three keys that are still declared and still dead + * + * `events` / `orientation` / `position` have zero read points. Their RETIREMENT + * is routed, not done — objectui#6170's maintainer ruling (2026-08-25) sends + * them down the ADR-0049 enforce-or-remove route, which is a breaking removal + * from a published type. `events` went required → OPTIONAL here, which is a + * strict widening and the smallest change that lets the documented authoring + * form compile. They are pinned below as STILL DECLARED so the removal, when it + * comes, is a deliberate edit against a red test rather than a silent drift. + */ + +import { describe, it, expect } from 'vitest'; +import { TimelineConfigSchema } from '@objectstack/spec/ui'; +import { TimelineSchema } from '../zod/data-display.zod.js'; +import type { TimelineSchema as TimelineSchemaTS, TimelineScale } from '../data-display.js'; + +const MINIMAL = { type: 'timeline' } as const; + +/** Unwrap ZodOptional/ZodDefault/description wrappers down to the inner enum schema. */ +function unwrap(schema: any): any { + let cur = schema; + while (cur?._def?.innerType) cur = cur._def.innerType; + return cur; +} + +/** The eight keys this card declared, each with a value its declared type refuses. */ +const DECLARED: ReadonlyArray = [ + ['variant', 'diagonal'], + ['items', 'not-an-array'], + ['dateFormat', 'medieval'], + ['scale', 'fortnight'], + ['timeScale', 'fortnight'], + ['rowLabel', 5], + ['minDate', 20240101], + ['maxDate', 20241231], +]; + +describe('TimelineSchema — the eight presentational keys are declared (objectui#6170)', () => { + it('the mirror declares every one of them', () => { + const shape = Object.keys(TimelineSchema.shape); + for (const [key] of DECLARED) expect(shape, `mirror is missing ${key}`).toContain(key); + }); + + it('declares them all OPTIONAL — a bare `{ type: "timeline" }` still parses', () => { + // Requiredness is the half the zod-mirror-parity ratchet compares against + // `../data-display.ts`, where all eight are `?:`. A mirror that required one + // would reject every timeline already published — including the three + // fixtures in `examples/schema-catalog/src/schemas/plugin-timeline/`. + const result = TimelineSchema.safeParse(MINIMAL); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('materialises NO defaults — an omitted key stays absent after parse', () => { + // `variant` and `dateFormat` default IN THE RENDERER, by destructuring + // (`variant = 'vertical'`). A `.default()` here would arrive downstream as + // an explicit author choice; the two spellings are not interchangeable. + const result = TimelineSchema.safeParse(MINIMAL); + expect(result.success).toBe(true); + if (!result.success) return; + for (const [key] of DECLARED) expect(key in result.data, `${key} must stay absent`).toBe(false); + }); + + it('refuses a wrong-typed value on each declared key (declared-key validation under passthrough)', () => { + for (const [key, bad] of DECLARED) { + const result = TimelineSchema.safeParse({ ...MINIMAL, [key]: bad }); + expect(result.success, `${key} accepted ${JSON.stringify(bad)}`).toBe(false); + if (result.success) continue; + const issue = result.error.issues.find((i) => i.path[0] === key); + expect(issue, `${key} failed, but not on the ${key} path`).toBeTruthy(); + } + }); + + it('accepts a well-typed value on each declared key', () => { + // Counter-probe for the assertion above: it must be the VALUE being refused, + // not the key. A pin that only ever sees red proves nothing. + const good = { + ...MINIMAL, + variant: 'gantt', + items: [{ label: 'Backend', items: [{ title: 'API', startDate: '2024-01-01', endDate: '2024-01-31' }] }], + dateFormat: 'iso', + scale: 'quarter', + timeScale: 'month', + rowLabel: 'Projects', + minDate: '2024-01-01', + maxDate: '2024-12-31', + }; + const result = TimelineSchema.safeParse(good); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('does NOT reject an undeclared key — objectui#5155’s ceiling, measured not assumed', () => { + // Declaring the eight bought validation of DECLARED keys, not rejection of + // undeclared ones: `BaseSchema` is `.passthrough()`. Anyone reading this + // card as "misspellings now fail" is reading it wrong, and this pin says so + // in the one place that cannot rot. + const misspelled = TimelineSchema.safeParse({ ...MINIMAL, varient: 'gantt', timescale: 'month' }); + expect(misspelled.success).toBe(true); + }); +}); + +describe('TimelineSchema — `scale` is canonical and shares the spec vocabulary', () => { + // The renderer resolves `scale ?? timeScale` and accepts six values on either + // (`resolveTimelineScale`, pinned against the spec by + // `plugin-timeline/src/__tests__/timeline-scale-spec-parity.test.ts`). This is + // the third leg of that agreement: the exported TYPE offers the same six. + const specScales: string[] = unwrap(TimelineConfigSchema.shape.scale).options; + + it('reads a non-empty scale enum from the spec', () => { + expect(specScales, 'could not read TimelineConfigSchema.shape.scale options').not.toEqual([]); + }); + + it('`scale` and the deprecated `timeScale` alias accept exactly the spec vocabulary', () => { + for (const key of ['scale', 'timeScale'] as const) { + for (const value of specScales) { + const result = TimelineSchema.safeParse({ ...MINIMAL, [key]: value }); + expect(result.success, `${key} refused spec scale '${value}'`).toBe(true); + } + } + }); + + it('the registry `inputs` three-value timeScale enum is NOT the contract', () => { + // Before this card the designer offered `timeScale: day | week | month` and + // the type offered neither key. `hour` / `quarter` / `year` were authorable, + // rendered correctly, and were undiscoverable from both surfaces. + for (const value of ['hour', 'quarter', 'year']) { + expect(TimelineSchema.safeParse({ ...MINIMAL, scale: value }).success, value).toBe(true); + } + }); +}); + +describe('TimelineSchema — the three unread keys are still declared (ADR-0049 route pending)', () => { + it('`events` / `orientation` / `position` remain in the mirror', () => { + const shape = Object.keys(TimelineSchema.shape); + for (const key of ['events', 'orientation', 'position']) { + expect(shape, `${key} left the mirror — see the ADR-0049 note in data-display.ts`).toContain(key); + } + }); + + it('`events` is OPTIONAL — this is the widening objectui#6170 landed', () => { + // It was required. That is why the docs page's own TypeScript example did + // not compile, and it is the single non-additive change in this card. + expect(TimelineSchema.safeParse({ type: 'timeline', items: [] }).success).toBe(true); + }); + + it('still validates them when authored, so retirement is a visible edit', () => { + expect(TimelineSchema.safeParse({ ...MINIMAL, orientation: 'diagonal' }).success).toBe(false); + expect(TimelineSchema.safeParse({ ...MINIMAL, position: 'centre' }).success).toBe(false); + expect(TimelineSchema.safeParse({ ...MINIMAL, events: 'nope' }).success).toBe(false); + }); +}); + +describe('TimelineSchema (TS) — compile-time pin on the same keys', () => { + it('accepts the docs page’s own TypeScript example', () => { + // `content/docs/plugins/plugin-timeline.mdx` — the "TypeScript Support" + // block, verbatim. Before objectui#6170 this exact object was + // `TS2741: Property 'events' is missing … but required in type + // 'TimelineSchema'`. The page taught an authoring form its own published + // type refused. + const timelineSchema: TimelineSchemaTS = { + type: 'timeline', + variant: 'vertical', + items: [ + { time: '2024-01-15', title: 'Event', description: 'Description', variant: 'success' }, + ], + }; + expect(timelineSchema.items).toHaveLength(1); + }); + + it('refuses a wrong-typed value on every declared key', () => { + // Each directive below fails the build (TS2578, "unused '@ts-expect-error'") + // the moment its key stops being declared, because the member then resolves + // to `any` through `BaseSchema`'s index signature and the assignment starts + // succeeding. That failure is the signal this card exists to create. + + // @ts-expect-error — `variant` is declared `'vertical' | 'horizontal' | 'gantt' | undefined`. + const variant: TimelineSchemaTS['variant'] = 'diagonal'; + // @ts-expect-error — `dateFormat` is declared `'short' | 'long' | 'iso' | undefined`. + const dateFormat: TimelineSchemaTS['dateFormat'] = 'medieval'; + // @ts-expect-error — `scale` is declared `TimelineScale | undefined`. + const scale: TimelineSchemaTS['scale'] = 'fortnight'; + // @ts-expect-error — `timeScale` is the deprecated alias, same six values. + const timeScale: TimelineSchemaTS['timeScale'] = 'fortnight'; + // @ts-expect-error — `rowLabel` is declared `string | undefined`. + const rowLabel: TimelineSchemaTS['rowLabel'] = 5; + // @ts-expect-error — `minDate` is declared `string | undefined` (schemas are JSON). + const minDate: TimelineSchemaTS['minDate'] = 20240101; + // @ts-expect-error — `maxDate` is declared `string | undefined`. + const maxDate: TimelineSchemaTS['maxDate'] = 20241231; + // @ts-expect-error — `orientation` is declared `'vertical' | 'horizontal' | undefined`. + const orientation: TimelineSchemaTS['orientation'] = 'diagonal'; + // @ts-expect-error — `position` is declared `'left' | 'right' | 'alternate' | undefined`. + const position: TimelineSchemaTS['position'] = 'centre'; + + expect([ + variant, dateFormat, scale, timeScale, rowLabel, minDate, maxDate, orientation, position, + ]).toHaveLength(9); + }); + + it('accepts the well-typed value on every declared key', () => { + // Counter-probe for the directives above: without this, a declaration + // narrowed to `never` would satisfy all nine of them. + const ok: TimelineSchemaTS = { + type: 'timeline', + variant: 'gantt', + items: [{ label: 'Backend', items: [{ title: 'API', startDate: '2024-01-01', endDate: '2024-01-31' }] }], + dateFormat: 'iso', + scale: 'quarter', + timeScale: 'month', + rowLabel: 'Projects', + minDate: '2024-01-01', + maxDate: '2024-12-31', + orientation: 'vertical', + position: 'left', + }; + expect(ok.rowLabel).toBe('Projects'); + }); + + it('`TimelineScale` is the one axis vocabulary, not a second spelling', () => { + const every: TimelineScale[] = ['hour', 'day', 'week', 'month', 'quarter', 'year']; + // @ts-expect-error — the type is closed; a seventh bucket is not authorable. + const extra: TimelineScale = 'fortnight'; + expect([...every, extra]).toHaveLength(7); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 939176ba14..44a3416da7 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1199,21 +1199,151 @@ export interface TimelineEvent { } /** - * Timeline component + * The axis-bucket vocabulary for the `gantt` variant. + * + * One spelling, one source: these are exactly the six values of + * `@objectstack/spec` `ui/TimelineConfig.json#scale`, and exactly the six + * `TIMELINE_SCALES` that `packages/plugin-timeline/src/renderer.tsx` + * (`resolveTimelineScale`) accepts. Declared here so the two agree by + * construction rather than by coincidence. + */ +export type TimelineScale = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; + +/** + * Timeline component (`type: 'timeline'`). + * + * ## The members below are the set `TimelineRenderer` actually reads + * + * objectui#6170, maintainer ruling 2026-08-25 (「同意」), the same family rule + * adopted on objectui#6172: **the exported type aligns to the measured + * authored + read set.** Before that ruling this interface declared `events` / + * `orientation` / `position` and nothing else, and the divergence was + * invisible to `tsc` because {@link BaseSchema} carries `[key: string]: any` — + * every key the renderer reads resolved as `any`, so `schema: TimelineSchema` + * constrained nothing. (The index signature itself is objectui#5155 / + * objectui#6269, deliberately not touched here.) + * + * Measured on `origin/main` @ `79ebf30d1`: `TimelineRenderer` + * (`plugin-timeline/src/renderer.tsx:250`) reads NINE keys off this node — + * `variant`, `items`, `dateFormat`, `onItemClick`, `minDate`, `maxDate`, + * `rowLabel`, `scale`, `timeScale` — and NONE of `events` / `orientation` / + * `position`. EIGHT of those nine are declared below; `onItemClick` is not, on + * purpose — it is a runtime slot `ObjectTimeline` installs when it composes + * this schema, not authorable metadata, and this package's convention keeps + * callback-shaped keys off the authored surface (see `RuntimeOnlyDeclared` in + * `__tests__/zod-mirror-parity.test.ts`). The registration's own `inputs` metadata and + * `content/docs/plugins/plugin-timeline.mdx`'s property table agreed with the + * renderer all along; only this type disagreed — including with the docs + * page's own TypeScript example, which did not compile, because `events` was + * required and nothing writes it. + * + * ## Two timelines, and this is the presentational one + * + * `TimelineSchema` describes HOW TO DRAW a rail from items already in hand. + * The OBJECT-BOUND config — WHICH RECORD FIELDS to project — is + * `@objectstack/spec`'s `TimelineConfig`, surfaced here as + * {@link ListViewTimelineConfig} (`startDateField` / `titleField` / …) and + * consumed by `ObjectTimeline`, which resolves those field names against + * fetched records and composes the presentational shape below before handing + * it to `TimelineRenderer`. The two vocabularies are disjoint by design; only + * `scale` is common to both, deliberately spelled the same in each. */ export interface TimelineSchema extends BaseSchema { type: 'timeline'; /** - * Timeline events + * Layout variant. The renderer implements exactly these three and returns + * `null` for anything else. + * @default 'vertical' + */ + variant?: 'vertical' | 'horizontal' | 'gantt'; + /** + * The rows to draw. + * + * TWO element shapes, discriminated by `variant`, both read dynamically by + * the renderer (`items.map((item: any) => …)`), so the element type is left + * open rather than narrowed to either one: + * + * - `vertical` / `horizontal` — a feed item: + * `{ time, title, description?, variant?, icon?, color?, content?, className?, meta?, group? }` + * - `gantt` — a row: + * `{ label, items: [{ title, startDate, endDate, variant? }] }` + * + * `content/docs/plugins/plugin-timeline.mdx` carries both in full. + */ + items?: any[]; + /** + * How item dates are rendered. + * @default 'short' + */ + dateFormat?: 'short' | 'long' | 'iso'; + /** + * Gantt axis bucket size. **Canonical spelling** — it is `@objectstack/spec` + * `ui/TimelineConfig.json`'s axis key AND the renderer's preferred read + * (`resolveTimelineScale` resolves `scale ?? timeScale`). + * @default 'month' */ - events: TimelineEvent[]; + scale?: TimelineScale; /** - * Timeline orientation + * Gantt axis bucket size — this renderer's pre-spec dialect, still read as a + * fallback so stored JSON keeps working. + * + * @deprecated Use {@link TimelineSchema.scale}, which `@objectstack/spec` + * owns and this renderer prefers. Retiring the alias is routed separately + * (objectui#6170 maintainer ruling 2026-08-25: "`timeScale` goes the + * alias-retirement route, not a silent second spelling"). + */ + timeScale?: TimelineScale; + /** + * Header label above the Gantt row-label gutter. + * @default 'Items' + */ + rowLabel?: string; + /** + * Override the auto-calculated Gantt axis start (`YYYY-MM-DD`). + */ + minDate?: string; + /** + * Override the auto-calculated Gantt axis end (`YYYY-MM-DD`). + */ + maxDate?: string; + /** + * Timeline events. + * + * ⚠️ ZERO read points — `packages/plugin-timeline` never reads this key, so a + * timeline authored with `events` renders an EMPTY rail. It was `required` + * until objectui#6170, which is why the docs page's own TypeScript example + * did not compile; it is OPTIONAL now so that documented authoring form + * type-checks, and that widening is the whole of the change made here. + * + * Its RETIREMENT is routed, not done: objectui#6170's maintainer ruling + * (2026-08-25) sends this key, {@link TimelineSchema.orientation} and + * {@link TimelineSchema.position} down the ADR-0049 enforce-or-remove route. + * That is a breaking removal from a published type and therefore its own + * change; the house form for it is the `?: never` tombstone convention on + * {@link StaticTableColumn} above (objectui#5474). + * + * @deprecated Never read by any renderer. Use `items` — see + * `content/docs/plugins/plugin-timeline.mdx`. + */ + events?: TimelineEvent[]; + /** + * Timeline orientation. + * + * ⚠️ ZERO read points — the renderer discriminates on + * {@link TimelineSchema.variant}, not on this key. Retirement routed via + * ADR-0049; see {@link TimelineSchema.events}. + * + * @deprecated Never read by any renderer. Use `variant`. * @default 'vertical' */ orientation?: 'vertical' | 'horizontal'; /** - * Timeline position (for vertical) + * Timeline position (for vertical). + * + * ⚠️ ZERO read points. Retirement routed via ADR-0049; see + * {@link TimelineSchema.events}. + * + * @deprecated Never read by any renderer. * @default 'left' */ position?: 'left' | 'right' | 'alternate'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 819c46cbc6..fb7687d4a9 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -200,6 +200,7 @@ export type { PivotTableSchema, DrillDownConfig, TimelineEvent, + TimelineScale, TimelineSchema, KbdSchema, HtmlSchema, diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 96b4847182..d8c289287d 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -298,14 +298,45 @@ export const TimelineEventSchema = z.object({ content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Custom content'), }); +/** + * Timeline scale — the six values of `@objectstack/spec` + * `ui/TimelineConfig.json#scale`, which are also the six `TIMELINE_SCALES` the + * gantt renderer accepts. Mirrors `TimelineScale` in `../data-display.ts`. + * + * Deliberately NOT exported: every exported const in this directory has to be + * registered in `zod-mirror-parity.test.ts`'s `MIRRORS` or `EXCLUSIONS`, and a + * shared inline enum is not a mirror of any declaration — it is spelling reuse + * between the two keys below. + */ +const TimelineScaleSchema = z.enum(['hour', 'day', 'week', 'month', 'quarter', 'year']); + /** * Timeline Schema - Timeline component + * + * Mirrors `TimelineSchema` in `../data-display.ts`, which objectui#6170 aligned + * to the key set `TimelineRenderer` actually reads (maintainer ruling + * 2026-08-25). `zod-mirror-parity.test.ts` pins the two together: a key + * declared there and absent here is `UnmirroredDeclaredKeys` and reddens the + * pair, so the nine presentational keys below are not optional to carry. + * + * `events` / `orientation` / `position` stay declared and stay mirrored: they + * have zero read points, but removing them is a breaking narrowing routed + * through ADR-0049 rather than done here. `events` follows the declaration from + * required to OPTIONAL — strictly more input parses than before. */ export const TimelineSchema = BaseSchema.extend({ type: z.literal('timeline'), - events: z.array(TimelineEventSchema).describe('Timeline events'), - orientation: z.enum(['vertical', 'horizontal']).optional().describe('Timeline orientation'), - position: z.enum(['left', 'right', 'alternate']).optional().describe('Event position'), + variant: z.enum(['vertical', 'horizontal', 'gantt']).optional().describe('Layout variant'), + items: z.array(z.any()).optional().describe('Rows to draw — feed items, or gantt rows when variant is gantt'), + dateFormat: z.enum(['short', 'long', 'iso']).optional().describe('How item dates are rendered'), + scale: TimelineScaleSchema.optional().describe('Gantt axis bucket size (canonical spelling — the spec key)'), + timeScale: TimelineScaleSchema.optional().describe('DEPRECATED pre-spec alias for scale; still read as a fallback'), + rowLabel: z.string().optional().describe('Header label above the gantt row-label gutter'), + minDate: z.string().optional().describe('Override the auto-calculated gantt axis start (YYYY-MM-DD)'), + maxDate: z.string().optional().describe('Override the auto-calculated gantt axis end (YYYY-MM-DD)'), + events: z.array(TimelineEventSchema).optional().describe('DEPRECATED — zero read points; renders an empty rail. Use items'), + orientation: z.enum(['vertical', 'horizontal']).optional().describe('DEPRECATED — zero read points. Use variant'), + position: z.enum(['left', 'right', 'alternate']).optional().describe('DEPRECATED — zero read points'), }); /**