From 22b883bc768994532bb3447bcd1f0f22f7b01e07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 07:55:43 +0000 Subject: [PATCH] fix(types,i18n): bind dateRange.defaultRange to the spec, and pin the preset label half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tie-restorations on the date-range preset vocabulary. Both copies were byte-faithful to `@objectstack/spec` 17 today — which is when this is cheapest, and when a pin is easiest to write badly, because everything passes either way. 1. `DashboardComponentSchema.dateRange.defaultRange` (packages/types) was a hand-written 14-member union restating `DATE_RANGE_DEFAULT_RANGES`. It is now `DateRangeDefaultRange` imported from `@objectstack/spec/ui`. That union is what typechecks every TS-constructed dashboard through `resolveDashboardFilterDefs`, so a preset the spec ADDS would have been a legal document objectui's own types said could not exist — the same "narrower than the contract it implements" shape as objectui#4163's `label`. No gate could see it: `check:spec-symbols` rule 1 matches by NAME and an inline union on an interface member has no symbol to collide with, and rule 2's claim heuristic was waved through by the `SpecGlobalFilter` reference a few lines up. 2. `packages/i18n/src/__tests__/dashboard-range-preset-labels-4984.test.ts` pins the LABEL half of the same fan-out, mirroring the BOUNDS half already pinned in `packages/core/src/utils/__tests__/dashboard-filters.test.ts`. `DashboardFilterBar` builds its key as a template literal, so `check:i18n-keys` cannot see it and a new preset would render as its own member name with underscores swapped for spaces, in ten locales, with every gate green. The published type surface does not move. Measured, not assumed: `dist/` and the out-of-tree `packages/types/tsconfig.tsbuildinfo` were both cleared before each build (composite `tsc` skips emit outright if the build info survives), the emitted declarations were compared by hash (1 of 55 files moved, `complex.d.ts`, the inline union collapsing to the alias), and reachability was measured with the TypeScript checker rather than grepped, since `export *` propagates a symbol without naming it: 679 reachable exports from `dist/index.d.ts`, 22 from `dist/complex.d.ts`, and `defaultRange` resolving to the identical 14 string-literal members on both sides. Out of scope, per the card's own boundary: the deliberately-looser zod twin at `packages/types/src/zod/complex.zod.ts` (ledgered under `SpecDashboardFields`; belongs to #2231's pile) and `FilterBuilderDateRangePreset`, a different vocabulary rather than a copy of this one. Fixes #4984 Co-authored-by: Claude --- .../date-range-default-range-binding-4984.md | 26 +++++ ...dashboard-range-preset-labels-4984.test.ts | 109 ++++++++++++++++++ packages/types/src/complex.ts | 21 +++- 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 .changeset/date-range-default-range-binding-4984.md create mode 100644 packages/i18n/src/__tests__/dashboard-range-preset-labels-4984.test.ts diff --git a/.changeset/date-range-default-range-binding-4984.md b/.changeset/date-range-default-range-binding-4984.md new file mode 100644 index 000000000..60e729853 --- /dev/null +++ b/.changeset/date-range-default-range-binding-4984.md @@ -0,0 +1,26 @@ +--- +'@object-ui/types': patch +--- + +`DashboardComponentSchema.dateRange.defaultRange` is now bound to +`DateRangeDefaultRange` from `@objectstack/spec/ui` instead of restating it as a +hand-written 14-member union (objectui#4984). + +The union was byte-faithful to the spec — all 14 members, same order — so nothing +a user hits changes today. What was missing is the tie that keeps it faithful: +`resolveDashboardFilterDefs` takes `Pick`, so this union is what typechecks every TS-constructed dashboard, and +a preset the spec ADDS would have been a legal document that objectui's own types +said could not exist — the "narrower than the contract it implements" shape whose +consequence in objectui#4163 was that the bad reads were invisible to `tsc`. + +No gate reported it: `check:spec-symbols` rule 1 matches by NAME and an inline union +on an interface member has no symbol to collide with, while rule 2's claim heuristic +was waved through by the `SpecGlobalFilter` reference a few lines above. Binding makes +the file's existing "Aligned with @objectstack/spec" comment structural rather than +prose. + +The emitted `.d.ts` collapses the inline union to the imported alias; the published +type surface is unchanged — measured with the TypeScript checker over the emitted +declarations (679 reachable exports from `dist/index.d.ts`, 22 from `dist/complex.d.ts`, +and `defaultRange` resolving to the same 14 string-literal members before and after). diff --git a/packages/i18n/src/__tests__/dashboard-range-preset-labels-4984.test.ts b/packages/i18n/src/__tests__/dashboard-range-preset-labels-4984.test.ts new file mode 100644 index 000000000..48cdf006a --- /dev/null +++ b/packages/i18n/src/__tests__/dashboard-range-preset-labels-4984.test.ts @@ -0,0 +1,109 @@ +/** + * 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#4984 item 2 — the LABEL half of the date-range preset fan-out. + * + * objectui#4167 made `@object-ui/core`'s `DATE_RANGE_PRESETS` the spec's own + * array by reference, so a preset `@objectstack/spec` ADDS arrives in the + * dashboard filter dropdown for free. Two things then have to follow it, and + * only one of them was pinned: + * + * - BOUNDS — `packages/core/src/utils/__tests__/dashboard-filters.test.ts` + * ("every offered preset resolves to date-macro bounds") already asserts a + * preset with no entry in the bounds table is caught. That is the half with + * teeth at runtime. + * - LABELS — nothing. `DashboardFilterBar.tsx` builds the key dynamically + * — the key is `dashboard.filters.range.` followed by the member name, and + * the fallback it degrades to is `p.replace(/_/g, ' ')` — so + * `check:i18n-keys` cannot see a template-literal key, and the call site + * degrades SILENTLY: the new preset renders as its own member name with the + * underscores swapped for spaces — `next_week` as "next week", in Chinese, + * Japanese and Arabic alike — with every gate green. + * + * So the one class of change the vocabulary extraction was designed to make + * painless is also the one that would ship untranslated in ten locales. This + * file is that missing pin, and it is deliberately the mirror of the bounds-half + * test: same source of truth (`DATE_RANGE_PRESETS`, not a copy of it), same + * "every offered preset …" shape. + * + * ── Scope: `en` only, on purpose ───────────────────────────────────────────── + * `en` is where the vocabulary/label tie lives — it is the pack every other one + * is backfilled from and the one `fallbackLng: 'en'` degrades to. The 10-locale + * spread is the i18n channel's existing business: `en-zh-key-parity.test.ts` + * carries `zh` off this pin for free, and the remaining eight packs are tracked + * under objectui#2872 part a. + * + * ── This file lives in `@object-ui/i18n`, not in core ──────────────────────── + * The claim spans the vocabulary and the locale packs. `@object-ui/i18n` + * depends on `@object-ui/core`, so it can reach both; core cannot reach the + * packs without inverting that edge. `@objectstack/spec` is deliberately NOT + * imported here — it is not a dependency of this package, and reading the + * vocabulary through `@object-ui/core` is the same reference anyway (pinned by + * `toBe` in the bounds-half file). + * + * ── PREDICTIONS, written before the run ────────────────────────────────────── + * GREEN on `main` today: the vocabulary is byte-faithful — 13 presets, 13 `en` + * labels. That is exactly why a pin here is cheap now, and exactly why a green + * run proves nothing on its own. The discriminating evidence is the mutation + * legs recorded in the PR, which drive each assertion RED on its own: + * - delete one `en` key → "every preset has an `en` label" RED + * - add a label with no preset → "no orphan labels" RED + */ +import { describe, it, expect } from 'vitest'; +import { DATE_RANGE_PRESETS } from '@object-ui/core'; +import { builtInLocales } from '../locales'; + +/** + * The namespace `DashboardFilterBar.tsx` builds its key under. Written out + * because the call site's key is a template literal — no static analysis + * connects the two, which is the whole reason this file exists. + */ +const RANGE_NS = ['dashboard', 'filters', 'range'] as const; + +const rangeLabels = (): Record => { + const node = RANGE_NS.reduce( + (acc, seg) => (acc && typeof acc === 'object' ? (acc as Record)[seg] : undefined), + builtInLocales.en, + ); + if (!node || typeof node !== 'object') { + throw new Error(`en.${RANGE_NS.join('.')} is missing — the label table moved or was renamed`); + } + return node as Record; +}; + +describe('dashboard date-range preset labels (en) track DATE_RANGE_PRESETS', () => { + it('reads a non-empty vocabulary and a non-empty label table', () => { + // Guards the vacuous pass: if either side resolved to nothing, every + // assertion below would iterate zero times and report green. The counts are + // NOT pinned to today's 13 — pinning the number would re-create the very + // hand-maintained copy this file exists to retire. + expect(DATE_RANGE_PRESETS.length).toBeGreaterThan(5); + expect(Object.keys(rangeLabels()).length).toBeGreaterThan(5); + }); + + it('every offered preset has an `en` label', () => { + // A preset with no key here is not a missing translation — it is a dropdown + // item rendered as its own member name (`last_90_days` → "last 90 days") in + // every locale at once, because the call site's `defaultValue` fallback is + // the member name with underscores replaced. + const labels = rangeLabels(); + const missing = DATE_RANGE_PRESETS.filter((p) => typeof labels[p] !== 'string' || !(labels[p] as string).trim()); + expect(missing).toEqual([]); + }); + + it('has no orphan label left behind by a preset the spec removed', () => { + // The other direction of the same tie. `custom` is deliberately not a + // member of DATE_RANGE_PRESETS and is not labelled here either — it has its + // own sibling key, `dashboard.filters.custom`. + const orphans = Object.keys(rangeLabels()).filter( + (k) => !(DATE_RANGE_PRESETS as readonly string[]).includes(k), + ); + expect(orphans).toEqual([]); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 8caac21d6..24af60d12 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -17,6 +17,7 @@ import type { DashboardWidget as SpecDashboardWidget, + DateRangeDefaultRange as SpecDateRangeDefaultRange, GlobalFilter as SpecGlobalFilter, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; @@ -784,12 +785,26 @@ export interface DashboardComponentSchema extends BaseSchema { /** * Date range filter configuration. * Aligned with @objectstack/spec DashboardSchema.dateRange. + * + * `defaultRange` is BOUND to the spec's `DateRangeDefaultRange` rather than + * restated (objectui#4984). It used to be a hand-written 14-member union — + * byte-faithful to the spec, but faithful only until the next spec release: + * a preset the spec ADDS would be a legal document that objectui's own types + * say cannot exist, the same "narrower than the contract it implements" shape + * as objectui#4163's `label`, whose consequence was that the bad reads were + * invisible to `tsc`. No gate could report the drift either — `check:spec-symbols` + * rule 1 matches by NAME and an inline union on an interface member has no + * symbol to collide with, while rule 2's claim heuristic was waved through by + * the `SpecGlobalFilter` reference a few lines up. Binding makes the "Aligned + * with" line above structural instead of prose. + * + * `DATE_RANGE_DEFAULT_RANGES` is `[...DATE_RANGE_PRESETS, 'custom']`, so this + * tracks the same vocabulary `@object-ui/core` re-exports by reference + * (objectui#4167) — one list, reached two ways. */ dateRange?: { field?: string; - defaultRange?: 'today' | 'yesterday' | 'this_week' | 'last_week' | 'this_month' | 'last_month' - | 'this_quarter' | 'last_quarter' | 'this_year' | 'last_year' - | 'last_7_days' | 'last_30_days' | 'last_90_days' | 'custom'; + defaultRange?: SpecDateRangeDefaultRange; allowCustomRange?: boolean; }; /**