From 0c029bf4d0c716e596ef5bea9c537e67aa44c9c2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 16:32:58 -0700 Subject: [PATCH 01/16] docs: plan canonical calendar dates --- ROADMAP.md | 71 +- ...8-18-canonical-calendar-dates-row-model.md | 1249 +++++++++++++++++ ...nonical-calendar-dates-row-model-design.md | 520 +++++++ 3 files changed, 1808 insertions(+), 32 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-18-canonical-calendar-dates-row-model.md create mode 100644 docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md diff --git a/ROADMAP.md b/ROADMAP.md index bfcb57092..8b04b7da5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,11 +1,11 @@ # Pretable roadmap -Last reviewed: 2026-08-10 +Last reviewed: 2026-08-18 Pretable is a batteries-included React data grid built to be the fastest and -best grid for demanding, financial-grade applications. The roadmap uses two -reference workloads: live portfolio-management systems and collaborative -financial planning. +best grid for demanding applications. Financial applications are a proving +ground for performance, precision, dense interaction, and data integrity—not +the product boundary. ## Governing principles @@ -15,58 +15,65 @@ financial planning. - Pretable ships working grid behavior and UI, not interfaces alone. - One canonical derived model governs rendering, focus, selection, editing, clipboard, accessibility, and virtualization. -- The grid remains finance-capable but domain-agnostic; applications own - valuation, formulas, permissions, storage endpoints, and retention policy. +- The grid remains domain-agnostic; applications own valuation, formulas, + permissions, storage endpoints, and retention policy. - Public state and protocols are versioned and migration-aware. ## Current baseline -The aligned public-package baseline is `0.0.14`. The released grid already -includes local sorting and typed filtering, selection, keyboard navigation, -copy and validated bulk paste, typed asynchronous editing, column virtualization -and layout, configurable row grouping and aggregation with an overflow-safe -group panel, number-column alignment with tabular number/date figures, -variable-height rows, and batched streaming transactions. Package changelogs -and generated API reports are the source of truth for shipped behavior. +The latest published aligned-package baseline recorded in the repository is +`0.10.0`. That release includes an incremental typed local row model; sorting, +filtering, grouping, aggregation, distinct values, and transactions; +virtualization and variable-height rows; selection, focus, keyboard navigation, +editing, validated paste, copy, and CSV; native number formatting with money and +accounting presets; cell presentations; stable row identity across entry points; +and server-controlled windowed data with eviction-safe selection, row-height +continuity, and explicit result metadata. Current main additionally contains +unreleased external sort/filter authority suppression. Package changelogs, +pending changesets, and generated API reports distinguish released from +current-main behavior. See the current [core changelog](packages/core/CHANGELOG.md), [React changelog](packages/react/CHANGELOG.md), [core API report](packages/core/core.api.md), [React API report](packages/react/react.api.md), and -[committed comparative benchmark evidence](status/milestones/2026-05-12-comparator-aware-evaluators.hypotheses.json). +[committed comparative scroll evidence](status/milestones/2026-08-16-s2-comparative-pinned.json) +and [interaction evidence](status/milestones/2026-08-16-s2-mount-and-interaction.json). -## Now — harden and measure +## Now — correct the typed data contract -- Close remaining public API/documentation gaps and make stable row identity - consistent across entry points. -- Add deterministic PMS and financial-planning benchmark profiles without - weakening existing 60 Hz, zero-gap, and interaction-continuity gates. +- Ship canonical calendar-date semantics and native date formatting across the + incremental row model, React presentation, editing, copy, and CSV. See the + [planned design](docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md). +- Preserve Pretable's stable scroll and structural efficiency while closing the + measured local sort/filter interaction gap. Add evidence for product work, + not finance-specific workload profiles. +- Keep public APIs, generated reports, migration guidance, and consumer docs + aligned with the breaking typed contract. -## Next — describe and manipulate financial views +## Next — complete reusable analytical workflows -1. Add a financial field schema, nested column bands, field chooser, formatting - descriptors, and view-management surface. +1. Add grand totals and summary rows as domain-neutral companions to grouping + and aggregation. 2. Ship saved-view persistence: a versioned portable view document, migrations, schema reconciliation, browser-local storage, remote store adapter, and shared/personal view layers. 3. Add a typed command foundation with atomic edit/paste batches, inverses, and bounded local undo/redo. Saved views precede command history. -## Later — remote scale, analytics, and collaboration +## Later — remote scale and advanced interaction -1. Add a remote/live row model with bounded caching, typed query plans, - cancellation, partial/stale/error state, and ordered resynchronization. +1. Extend the shipped server-controlled window contract into an optional + remote/live row model with fetching, bounded caching, cancellation, and + ordered resynchronization. 2. Add revisioned mutations with command IDs, base revisions, optimistic and pessimistic execution, conflicts, rollback, retries, and resync. -3. Develop parallel application tracks: - - PMS analytics: incremental grouping/aggregation, totals, pivot, - drill-through, context actions, and export. - - Financial planning: range editing, fill, row creation/reordering, formula - and provenance surfaces, and time/scenario comparison columns. +3. Add pivoting, range editing, fill, row creation/reordering, and extensible + context actions as general grid capabilities. 4. Add provider-backed durable audit/version history, history UI, revert-as-new-command, redaction, retention hooks, and collaborative updates. -5. Converge both tracks with shared/personal configuration, capability-aware UI, - tree data, localization, and accessibility hardening. +5. Continue tree data, localization, accessibility, and capability-aware UI + hardening without embedding application-domain policy. ## Not planned diff --git a/docs/superpowers/plans/2026-08-18-canonical-calendar-dates-row-model.md b/docs/superpowers/plans/2026-08-18-canonical-calendar-dates-row-model.md new file mode 100644 index 000000000..64c91d9f4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-canonical-calendar-dates-row-model.md @@ -0,0 +1,1249 @@ +# Canonical Calendar Dates on the Incremental Row Model Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `type: "date"` a strict `YYYY-MM-DD | null` calendar-date domain and add cached, locale-aware native date formatting across the incremental row model, editing, aggregates, rendering, clipboard, CSV, SSR, and public documentation. + +**Architecture:** A new zero-dependency `@pretable-internal/calendar-date` package owns strict validation, comparison, UTC presentation conversion, and bounded arithmetic. `@pretable-internal/row-model` owns typed column/query semantics and lowers date extrema to private associative aggregators. `@pretable/react` owns editing and one cached native presentation pipeline shared by cells, groups, clipboard, CSV, and hydration; core and React expose only the intended validator and option type. + +**Tech Stack:** TypeScript 6, React 19, RFC 3339 full-date strings, ECMA-402 `Intl.DateTimeFormat`, Vitest, Testing Library, jsdom, React server rendering/hydration, fast-check, API Extractor, Next.js MDX documentation, Playwright, pnpm, Changesets. + +**Design:** [`docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md`](../specs/2026-08-18-canonical-calendar-dates-row-model-design.md) + +--- + +## Delivery rules + +- Use `superpowers:test-driven-development` for every behavior change. +- Use `superpowers:systematic-debugging` for any unexpected failure; do not + weaken a date assertion to accommodate legacy coercion. +- Commit after every task and run the named focused checks before committing. +- Do not cherry-pick the old `blove/calendar-date-formatting` implementation. + It targeted a deleted engine. Behavioral cases may be consulted and manually + re-expressed against current APIs. +- Do not add a Temporal/polyfill/date dependency, public coercion helper, + public date-value brand, `datetime` column type, locale parser, or + compatibility mode. +- Do not manually edit package changelogs. Changesets and the release process + own them. +- Do not build sibling packages from a package script. Each package owns one + builder; workspace dependency edges determine root build order. + +## File map + +### Shared calendar primitive + +- Create `packages/calendar-date/package.json` — private internal package + manifest with one-package build/test/lint/typecheck scripts and `fast-check` + as a test-only dependency. +- Create `packages/calendar-date/tsconfig.json` and + `packages/calendar-date/tsconfig.typecheck.json` — build declarations from + `src/index.ts` and typecheck tests. +- Create `packages/calendar-date/src/calendar-date.ts` — strict parser, + validator, comparison, UTC conversion, min/max constants, and bounded + day/month arithmetic. +- Create `packages/calendar-date/src/index.ts` — internal package exports. +- Create `packages/calendar-date/src/__tests__/calendar-date.test.ts` — exact + shape, Gregorian boundaries, low years, comparison, UTC, and arithmetic. +- Modify `packages/core/package.json`, `packages/core/tsup.config.ts`, and + `pnpm-lock.yaml` — bundle the private package into core. +- Modify `packages/core/src/public_api.ts` — expose only + `isValidDateValue` publicly. +- Create `packages/core/src/__tests__/calendar-date.test.ts` — public guard and + no-private-export contract. + +### Strict incremental row-model semantics + +- Modify `packages/row-model/package.json` and `pnpm-lock.yaml` — add the direct + private calendar dependency. +- Modify `packages/row-model/src/column-types.ts` — strict string date value and + operand types; remove `Date` group identity. +- Modify `packages/row-model/src/compiled-query.ts` — strict filter evaluation, + valid-first date comparison, sibling parity, and semantic invalid-operand + fail-closed behavior. +- Modify `packages/row-model/src/errors.ts`, + `packages/row-model/src/types.ts`, and + `packages/row-model/src/distinct-values.ts` — remove `Date` from stable local + group/distinct identity and its runtime encoding. +- Modify row-model tests under `packages/row-model/src/__tests__/` for compiled + queries, flat queries, grouping, distinct values, transitions, external + authority, and type contracts. +- Modify `type-tests/core/columns.types.ts`, + `type-tests/core/local-row-model.types.ts`, + `type-tests/core/query-and-aggregate.types.ts`, and the generated-scale + fixtures `type-tests/performance/columns-{100,500}.ts` — migrate date values + and operands to canonical strings while retaining type-performance scale. + +### Type-aware date extrema + +- Create `packages/row-model/src/calendar-date-aggregates.ts` — private + associative calendar `min`/`max` aggregators and runtime lowering. +- Create `packages/row-model/src/__tests__/calendar-date-aggregates.test.ts` — + valid-only extrema, empty output, merge laws, and immutability. +- Modify `packages/row-model/src/column-types.ts` — make built-in aggregate + availability and output depend on both value and column type. +- Modify `packages/row-model/src/compiled-query.ts` — lower only date + `min`/`max` before aggregate leaves reach the persistent aggregate tree. +- Modify `packages/row-model/src/transaction-draft.ts` — recognize the two + private lowered calendar aggregators and retain built-in structural no-op + detection for unrelated updates. +- Modify `packages/react/src/types.ts` and affected core/React type fixtures — + thread the new `TType` generic without widening every `min` output. +- Modify grouping, transition, aggregate-law, and type tests to prove canonical + date extrema and numeric/custom non-regression. + +### Strict React editing and filtering + +- Modify `packages/react/package.json`, React tsconfig path maps, and + `pnpm-lock.yaml` — add the direct private calendar dependency without adding + row-model runtime duplication. +- Rewrite `packages/react/src/editors/date-utils.ts` — UI-only calendar matrix, + labels, and viewer-today behavior composed from shared primitives. +- Modify `packages/react/src/editors/DateCellEditor.tsx` — strict seeds, + controlled draft synchronization, bounded navigation, placeholders, and + provenance-aware blur. +- Modify `packages/react/src/editors/type-parsing.ts` — canonical string/null + built-in parsing only. +- Modify `packages/react/src/use-cell-edit-controller.ts` — carry private + type-to-replace provenance from the explicit key entry into the surface begin + wrapper without changing public edit state. +- Modify `packages/react/src/filter-menu/filter-operators.ts` — invalid, + incomplete, or cleared date drafts remove the menu-owned filter. +- Modify `packages/react/src/pretable-surface.tsx` — key type-to-replace + provenance to a private monotonic edit-session token shared by every begin + and cancel wrapper. +- Rewrite/extend focused React editor, parser, filter-menu, editing-surface, and + paste tests. + +### Public native date presentation + +- Modify `packages/row-model/src/column-types.ts` — public strict + `PretableDateFormatOptions` and `dateFormat` on typed definitions/options. +- Modify `packages/core/src/types.ts`, `packages/core/src/public_api.ts`, and + core type tests — public option type and validator only. +- Modify `packages/react/src/types.ts`, `packages/react/src/use-pretable.ts`, and + `packages/react/src/public_api.ts` — React column paths, model merge, legacy + column, and re-exports. +- Create `packages/react/src/date-formatters.ts` — strict own-key validation and + UTC-locked native formatter reconciliation. +- Create `packages/react/src/__tests__/date-formatters.test.ts` — compile/runtime + safety, low years, locale, error context, and cache behavior. +- Create `type-tests/core/date-format.types.ts` and + `type-tests/react/date-format.types.tsx` — public option/helper/legacy/model + type coverage. + +### Rendering, groups, clipboard, CSV, and SSR + +- Refactor `packages/react/src/value-formatting.ts` — one number/date registry + and precedence resolver. +- Modify `packages/react/src/pretable-surface.tsx` — compile once after + authoritative columns and reuse for mounted rendering/export. +- Modify `packages/react/src/group-row.tsx` — date aggregate presentation. +- Modify `packages/react/src/copy.ts` and `packages/react/src/csv.ts` — the same + resolver and registry for standalone and mounted export. +- Create `packages/react/src/__tests__/date-formatting-surface.test.tsx` and + `packages/react/src/__tests__/column-helper-date-format.test.tsx`. +- Extend value-formatting, grouping, copy, CSV, indexed presentation, external + authority, and SSR/hydration tests. + +### Consumer documentation and release evidence + +- Create `apps/website/content/docs/grid/date-formatting.mdx` and + `apps/website/app/docs/__tests__/date-formatting.types.tsx`. +- Modify grid API, editing, filtering, sorting, grouping, clipboard, export, + cell-renderer, cell-presentation, and surface documentation plus navigation. +- Modify `apps/website/app/api/docs/rows/dataset.ts` and its unit test so the + server-data demo implements the same canonical contract. +- Modify documentation API/fence tests and `apps/website/e2e/docs.spec.ts`. +- Modify `README.md`, generated API reports, `ROADMAP.md`, and this design's + lifecycle metadata only as required to describe planned/released truth. +- Create one minor fixed-group changeset with explicit **Breaking** migration + prose. + +## Task 1: Add the private calendar package and public validator + +**Files:** + +- Create: `packages/calendar-date/package.json` +- Create: `packages/calendar-date/tsconfig.json` +- Create: `packages/calendar-date/tsconfig.typecheck.json` +- Create: `packages/calendar-date/src/calendar-date.ts` +- Create: `packages/calendar-date/src/index.ts` +- Create: `packages/calendar-date/src/__tests__/calendar-date.test.ts` +- Modify: `packages/core/package.json:35-40` +- Modify: `packages/core/tsup.config.ts:11-13` +- Modify: `packages/core/src/public_api.ts:9-27` +- Create: `packages/core/src/__tests__/calendar-date.test.ts` +- Modify: `pnpm-lock.yaml` + +- [ ] **Step 1: Create the package skeleton and write failing tests** + +Create the manifest and tsconfigs first so pnpm can select the new package. Add +`fast-check` as a dev dependency. Create `src/index.ts` exporting the wished-for +module, then write the tests while `src/calendar-date.ts` is still absent. + +Create table-driven tests for this internal API: + +```ts +import { + MAX_DATE_VALUE, + MIN_DATE_VALUE, + addDateValueDays, + addDateValueMonths, + compareDateValues, + dateValueToUtcMs, + isValidDateValue, + parseDateValue, +} from "../calendar-date"; + +expect(MIN_DATE_VALUE).toBe("0000-01-01"); +expect(MAX_DATE_VALUE).toBe("9999-12-31"); +expect(isValidDateValue("2024-02-29")).toBe(true); +expect(isValidDateValue("2026-02-30")).toBe(false); +expect(parseDateValue("0000-02-29")).toEqual({ year: 0, month: 2, day: 29 }); +expect(compareDateValues("2026-01-02", "2026-02-01")).toBeLessThan(0); +expect(new Date(dateValueToUtcMs("0050-01-01")).toISOString()).toBe( + "0050-01-01T00:00:00.000Z", +); +expect(addDateValueDays("0000-01-01", -1)).toBe("0000-01-01"); +expect(addDateValueDays("9999-12-31", 1)).toBe("9999-12-31"); +expect(addDateValueMonths("2024-01-31", 1)).toBe("2024-02-29"); +``` + +The rejection table includes `null`, `undefined`, `""`, padded values, loose +dates, overflows, date-times with/without offsets, `Date`, finite/infinite +numbers, arrays, objects, and hostile proxies. Pin Gregorian century rules and +years `0000`, `0001`, `0050`, `0099`, `0100`, and `9999`. Read the source and +assert it contains no `Date.parse`. Add fast-check properties for parse/format +round trips and Gregorian validity across the entire supported domain, +comparator antisymmetry/transitivity, and bounded day/month arithmetic including +endpoint clamping without ever leaving `0000-01-01` through `9999-12-31`. + +- [ ] **Step 2: Write the failing public-core test** + +Import `isValidDateValue` from `../index`, prove type narrowing, and assert that +`parseDateValue`, arithmetic helpers, and constants are absent from the public +module type/runtime surface. + +- [ ] **Step 3: Run RED** + +```bash +pnpm --filter @pretable-internal/calendar-date test +pnpm --filter @pretable/core exec vitest run src/__tests__/calendar-date.test.ts +``` + +Expected: FAIL because the package and public export do not exist. + +- [ ] **Step 4: Create the package and primitive** + +Complete the skeleton using the same private-package manifest/tsconfig pattern +as `packages/layout-core`; keep runtime dependencies empty. + +Implement exact-shape parsing with `^(\d{4})-(\d{2})-(\d{2})$`, explicit +Gregorian month lengths, and these signatures: + +```ts +export interface CalendarDateParts { + readonly year: number; + readonly month: number; + readonly day: number; +} + +export const MIN_DATE_VALUE = "0000-01-01"; +export const MAX_DATE_VALUE = "9999-12-31"; +export function parseDateValue(value: unknown): CalendarDateParts | null; +/** @public */ +export function isValidDateValue(value: unknown): value is string; +export function dateValueToUtcMs(value: string): number; +export function compareDateValues(left: string, right: string): number; +export function addDateValueDays(value: string, days: number): string; +export function addDateValueMonths(value: string, months: number): string; +``` + +`dateValueToUtcMs` returns `NaN` for invalid runtime input. For years below +100, construct year + 400 and subtract `146_097 * 86_400_000`; do not use +`Date.UTC(year, ...)` directly. Arithmetic clamps to supported endpoints and +month movement clamps the day to the destination month. + +The validator carries `@public` because core re-exports it into an +API-Extractor-controlled public bundle. All other private exports omit public +release tags. + +- [ ] **Step 5: Wire the private/public package boundaries** + +Add `@pretable-internal/calendar-date: "workspace:*"` to core dev dependencies, +add it to core's `noExternal`, refresh the lockfile, and re-export only: + +```ts +export { isValidDateValue } from "@pretable-internal/calendar-date"; +``` + +Do not add the private package to the root TypeScript reference list: recursive +workspace scripts and the dependency edge own its build ordering. + +- [ ] **Step 6: Run GREEN and package-boundary checks** + +```bash +pnpm --filter @pretable-internal/calendar-date test +pnpm --filter @pretable-internal/calendar-date typecheck +pnpm --filter @pretable-internal/calendar-date lint +pnpm --filter @pretable/core test +pnpm --filter @pretable/core build +pnpm --filter @pretable/core typecheck +node --test scripts/__tests__/workspace-scripts-own-one-package.test.mjs scripts/__tests__/public-api-forgotten-exports.test.mjs +``` + +Expected: all pass; the public bundle contains the validator but no private +calendar helper exports. + +- [ ] **Step 7: Commit** + +```bash +git add packages/calendar-date packages/core/package.json packages/core/tsup.config.ts packages/core/src/public_api.ts packages/core/src/__tests__/calendar-date.test.ts pnpm-lock.yaml +git commit -m "feat(core): define canonical calendar dates" +``` + +## Task 2: Make typed queries and row ordering canonical + +**Files:** + +- Modify: `packages/row-model/package.json:24-32` +- Modify: `packages/row-model/src/column-types.ts:10-19,44-56,168-181,450-532` +- Modify: `packages/row-model/src/compiled-query.ts:1-9,622-757,930-960,1130-1327,1520-1568` +- Modify: `packages/row-model/src/errors.ts:131-171` +- Modify: `packages/row-model/src/types.ts:328-346` +- Modify: `packages/row-model/src/distinct-values.ts:250-305` +- Modify tests: `packages/row-model/src/__tests__/{compiled-query,flat-query,grouping,distinct-values,transitions,external-filter-authority,types}.test.ts` +- Modify: `type-tests/core/columns.types.ts` +- Modify: `type-tests/core/local-row-model.types.ts` +- Modify: `type-tests/core/query-and-aggregate.types.ts` +- Modify: `type-tests/performance/columns-100.ts` +- Modify: `type-tests/performance/columns-500.ts` +- Modify: `pnpm-lock.yaml` + +- [ ] **Step 1: Write failing public type tests** + +Change date rows to `string | null` and pin these outcomes: + +```ts +interface DatedRow { + id: number; + asOf: string | null; + instant: Date; +} + +const dated = createColumnHelper(); +dated.accessor("asOf", { type: "date" }); +// @ts-expect-error Date instances are not built-in calendar dates +dated.accessor("instant", { type: "date" }); + +type _DateOperand = Expect< + Equal, string> +>; +``` + +Prove direct and computed accessors cannot escape the rule. Remove `Date` from +`PretableGroupKey` and `PretableDistinctColumnIdOf`. Replace every date value +and operand in the 100/500-column performance fixtures with canonical strings; +replace `.toISOString()` formatting with direct string formatting while keeping +the same tuple widths and query counts. + +- [ ] **Step 2: Write failing strict filter and ordering tests** + +In `compiled-query.test.ts`, cover all date operators with canonical strings. +Assert: + +- `Date`, numbers, arrays, and objects are structured validation errors at the + exact `query.filters[i].value` path; +- a wrong-length `dateBetween` array is a structured validation error; +- a string with invalid calendar semantics is accepted as controlled state but + evaluates every row false; +- `dateBetween` evaluates every row false when either string bound is invalid; +- valid cell strings compare lexically/chronologically without `Date` objects; +- invalid cell strings, empty strings, `null`, and `undefined` match no date + comparison operator. + +Add row and sibling-group ordering cases where canonical dates sort first and +chronologically, while every non-date remains in one terminal rank for both +`asc` and `desc`, ignoring the date column's `nulls` setting. Equal dates and +equal terminal-rank values must fall through to later keys, then source order. +Pin custom comparator precedence. + +- [ ] **Step 3: Write failing transition and authority tests** + +Cover `text -> date` and `date -> text` derivation transitions with active sort, +filter, grouping, and distinct-value work. Assert exactly one semantic +invalidation and no unrelated index discard using current work counters. + +Under external filter/sort authority, publish canonical date query state while +retaining upstream membership and order. Do not add a finance-specific +benchmark. + +- [ ] **Step 4: Run RED** + +```bash +pnpm --filter @pretable-internal/row-model exec vitest run src/__tests__/compiled-query.test.ts src/__tests__/flat-query.test.ts src/__tests__/grouping.test.ts src/__tests__/distinct-values.test.ts src/__tests__/transitions.test.ts src/__tests__/external-filter-authority.test.ts src/__tests__/types.test.ts +pnpm typecheck:public +``` + +Expected: FAIL on Date-valued types/coercion, generic string ordering, and Date +group/distinct identity. + +- [ ] **Step 5: Tighten the public types and local identity contract** + +Add the calendar package to row-model dev dependencies. Change +`PretableColumnTypeFor` so only a wholly string/null value can select +`"date"`; all non-string branches exclude `"date"`. Change the date operand to +`string`, remove `Date` from grouping/distinct unions, and make runtime +`isPretableGroupKey` reject every object. Update the group-key error message and +remove Date-specific distinct encoding. + +Keep arbitrary custom column comparators and aggregators capable of holding +application-owned objects; do not globally remove Date cloning from generic +owned option snapshots. + +- [ ] **Step 6: Replace coercive query semantics** + +Import `isValidDateValue` and `compareDateValues`. Delete `toDayMs`, +`ISO_DATETIME_RE`, native date parsing, and Date-specific filter snapshot logic. + +Date operand validation becomes shape-only: + +```ts +const values = filter.operator === "dateBetween" ? filter.value : [filter.value]; +if ( + !Array.isArray(values) || + (filter.operator === "dateBetween" && values.length !== 2) || + values.some((value) => typeof value !== "string") +) { + fail("date operand must be a string", path, column.id); +} +``` + +Do not reject a string merely because its calendar semantics are invalid; the +evaluator checks `isValidDateValue` for the cell and every operand and returns +false when any required value is noncanonical. + +In `compareValues`, preserve custom comparator precedence, then handle dates +before generic null handling: + +```ts +if (column.type === "date" && column.compare === undefined) { + const leftValid = isValidDateValue(left); + const rightValid = isValidDateValue(right); + if (leftValid !== rightValid) return leftValid ? -1 : 1; + if (!leftValid) return 0; + const result = compareDateValues(left, right); + return ordering.direction === "desc" ? -result : result; +} +``` + +The same `compareValues` path already serves sibling groups; do not duplicate +group comparison. + +- [ ] **Step 7: Run GREEN, full row-model, and type-performance gates** + +```bash +pnpm --filter @pretable-internal/row-model test +pnpm --filter @pretable-internal/row-model typecheck +pnpm --filter @pretable-internal/row-model lint +pnpm typecheck:public +pnpm typecheck:performance +``` + +Expected: all pass with the same type-performance budget class; date operations +contain no `Date.parse`, timestamp, or local-time conversion. + +- [ ] **Step 8: Commit** + +```bash +git add packages/row-model type-tests/core type-tests/performance pnpm-lock.yaml +git commit -m "feat(core): enforce canonical date queries" +``` + +## Task 3: Add type-safe date extrema without widening strings + +**Files:** + +- Create: `packages/row-model/src/calendar-date-aggregates.ts` +- Create: `packages/row-model/src/__tests__/calendar-date-aggregates.test.ts` +- Modify: `packages/row-model/src/column-types.ts:44-56,108-166,198-308,419-448,594-652` +- Modify: `packages/row-model/src/compiled-query.ts:38-68,228-235,622-668,1487-1502` +- Modify: `packages/row-model/src/transaction-draft.ts:380-420` +- Modify: `packages/row-model/src/__tests__/{compiled-query,grouping,transitions,transactions,aggregator-law,types}.test.ts` +- Modify: `packages/react/src/types.ts:1-12,375-550` +- Modify: `packages/react/src/__tests__/cross-emission-type-identity.types.ts` +- Modify: `type-tests/core/query-and-aggregate.types.ts` +- Modify: `type-tests/react/model-inference.types.tsx` + +- [ ] **Step 1: Write failing aggregate type tests** + +Pin the public generic shape from the design: + +```ts +type PretableBuiltinAggregate = + | "count" + | (TType extends "number" + ? NonNullable extends number + ? "sum" | "avg" | "min" | "max" + : never + : TType extends "date" + ? NonNullable extends string + ? "min" | "max" + : never + : never); +``` + +Assert number `min` output is `number | null`, date `min`/`max` output is +`string | null`, `count` output is `number | null`, and custom finalizers keep +their exact output. Add `@ts-expect-error` cases for date `sum`/`avg`, text +`min`/`max`, and Date-valued extrema. Prove `formatAggregate` receives the exact +correlated output in both core and React helpers. + +- [ ] **Step 2: Write failing aggregate runtime and law tests** + +Test private `calendarDateMin`/`calendarDateMax` aggregators against canonical, +invalid, null, and empty input. Empty/all-invalid returns `null`. Test every +partition and merge order used by `aggregator-law.test.ts`; accumulators and +finalized outputs must remain detached and immutable. + +Add grouped row-model tests for date min/max under insert, update, remove, +filter, expansion, and type transition. Numeric built-ins and custom aggregate +snapshots must be unchanged. Add work-counter and aggregate-root identity +assertions proving an unrelated row update does not churn a lowered date +extremum when its value, source order, and sort-key dependencies are unchanged. + +- [ ] **Step 3: Run RED** + +```bash +pnpm --filter @pretable-internal/row-model exec vitest run src/__tests__/calendar-date-aggregates.test.ts src/__tests__/compiled-query.test.ts src/__tests__/grouping.test.ts src/__tests__/transitions.test.ts src/__tests__/transactions.test.ts src/__tests__/aggregator-law.test.ts src/__tests__/types.test.ts +pnpm typecheck:public +``` + +Expected: FAIL because date extrema are rejected and every built-in extremum is +typed numeric. + +- [ ] **Step 4: Thread column type through aggregate types** + +Change `PretableAggregateSpec` and `PretableAggregateOutputOf` to accept +`TType`, then thread it through `PretableColumnDefinition`, options/helper +overloads, callback context/input, `ColumnAggregateValueOf`, +`PretableAggregatesFor`, compatible aggregate specs, derivations, and React +factory types. Use: + +```ts +export type PretableAggregateOutputOf< + TAggregate, + TType extends PretableColumnType, +> = TAggregate extends { + readonly finalize: (accumulator: never) => infer TOutput; +} + ? TOutput + : TAggregate extends "min" | "max" + ? TType extends "date" + ? string | null + : number | null + : TAggregate extends "sum" | "avg" | "count" + ? number | null + : never; +``` + +Do not introduce `string | number | null` as a universal extremum output. Keep +the custom structural-finalize inference and cross-emission type identity tests. + +- [ ] **Step 5: Lower date extrema before the aggregate tree** + +Create two frozen private `PretableAggregator` instances whose accumulator is a +canonical string or `null`. `accumulate` ignores noncanonical values; `merge` +compares two canonical accumulator strings; `finalize` returns the accumulator. + +Expose one internal resolver: + +```ts +export function lowerCalendarDateAggregate( + columnType: string, + aggregate: string | PretableAggregator, +): string | PretableAggregator { + if (columnType !== "date") return aggregate; + if (aggregate === "min") return calendarDateMin; + if (aggregate === "max") return calendarDateMax; + return aggregate; +} +``` + +Call it while constructing each runtime compiled aggregate leaf. Public +derivation snapshots retain the original `"min"`/`"max"` token. Do not change +`aggregateTreeBuiltinAggregators`, its numeric overloads, or arbitrary string +aggregate support. + +Keep a module-private identity predicate for the two frozen calendar +aggregators; do not export it from the row-model package entry point. In +`transaction-draft.ts`, route only those two identities through the same +value/source-order/sort-key structural comparison used by built-in string +aggregators. Every application-provided object aggregator retains the existing +row/value/dependency identity rule. This preserves incremental no-op detection +without making private lowering observable in public derivation snapshots. + +Update compiled-query aggregate validation so `sum`/`avg` remain number-only, +`min`/`max` are legal for number or date columns, and `count` remains legal for +every column. No other string aggregate becomes valid. + +- [ ] **Step 6: Run GREEN and full affected type/runtime gates** + +```bash +pnpm --filter @pretable-internal/row-model test +pnpm --filter @pretable-internal/row-model typecheck +pnpm --filter @pretable/core build +pnpm --filter @pretable/react typecheck +pnpm typecheck:public +pnpm typecheck:performance +``` + +Expected: all pass; date extrema are strings, numeric extrema remain numbers, +and the persistent aggregate tree API is unchanged. + +- [ ] **Step 7: Commit** + +```bash +git add packages/row-model packages/react/src/types.ts packages/react/src/__tests__/cross-emission-type-identity.types.ts type-tests/core/query-and-aggregate.types.ts type-tests/react/model-inference.types.tsx +git commit -m "feat(core): aggregate canonical date extrema" +``` + +## Task 4: Make React date input strict, bounded, and session-safe + +**Files:** + +- Modify: `packages/react/package.json:40-49` +- Modify: `packages/react/tsconfig.json:3-12` +- Modify: `packages/react/tsconfig.build.json:3-16` +- Modify: `packages/react/tsconfig.typecheck.json:5-17` +- Rewrite: `packages/react/src/editors/date-utils.ts` +- Modify: `packages/react/src/editors/DateCellEditor.tsx:1-221` +- Modify: `packages/react/src/editors/type-parsing.ts` +- Modify: `packages/react/src/use-cell-edit-controller.ts:8-115` +- Modify: `packages/react/src/filter-menu/filter-operators.ts:130-190` +- Modify: `packages/react/src/pretable-surface.tsx:3199-3214,6230-6291` +- Modify tests: `packages/react/src/__tests__/{date-utils,date-cell-editor,type-parsing,use-cell-edit-controller,filter-operators,filter-menu,filter-menu-row-model-boundary,filter-menu-surface,paste-surface,pretable-surface-editing}.test.ts(x)` +- Modify: `pnpm-lock.yaml` + +- [ ] **Step 1: Write failing parser, menu, and paste tests** + +Assert the built-in parser accepts an exact canonical string without +modification, maps only an exact user-cleared empty draft to `null`, and rejects +`Date`, epoch, date-time, loose, overflow, padded, and whitespace values. No +built-in edit or paste path trims. Paste accepts only canonical/exact-empty +cells and reports every rejected shape with `reason: "invalid"` and the +canonical guidance message. + +For the menu, complete canonical input emits a date filter. Incomplete, +cleared, or invalid input emits `null` and removes the currently applied filter. +The row-model-boundary test separately proves an application-controlled invalid +string remains active and zero-matches. + +- [ ] **Step 2: Write failing editor and session-provenance tests** + +Cover: + +- canonical raw input is seeded unchanged; +- without `formatEditValue`, raw `Date`, epoch, date-time, padded, empty-string, + whitespace, and `undefined` seeds remain visible as raw text and untouched + blur cancels without calling the parser or row-change callback; +- untouched canonical string and canonical `null` retain their value; +- a custom parser runs on blur only after an actual user change; +- controlled canonical rerenders synchronize cursor, selection, displayed + month, active descendant, and boundary controls; +- controlled invalid rerenders clear selection without replacing the useful + cursor or letting Enter substitute it; +- Arrow/Page navigation marks the draft user-modified and commits on blur; +- navigation/buttons clamp at `0000-01-01` and `9999-12-31`; +- out-of-range filler slots are disabled null placeholders; +- a type-to-replace edit followed in one React batch by public + `cancelEdit(); beginEdit(...)`, or by direct edit replacement, cannot leak + provenance into the new session; +- an immediately resolved async `editable` gate preserves the typing session; +- controlled-row reconciliation that closes an edit through the pending-row + layout-effect path also clears the active/typed session tokens. + +- [ ] **Step 3: Run RED** + +```bash +pnpm --filter @pretable/react exec vitest run --environment jsdom src/__tests__/date-utils.test.ts src/__tests__/date-cell-editor.test.tsx src/__tests__/type-parsing.test.ts src/__tests__/use-cell-edit-controller.test.ts src/__tests__/filter-operators.test.ts src/__tests__/filter-menu.test.tsx src/__tests__/filter-menu-row-model-boundary.test.ts src/__tests__/filter-menu-surface.test.tsx src/__tests__/paste-surface.test.tsx src/__tests__/pretable-surface-editing.test.tsx +``` + +Expected: FAIL on coercion, boundary overflow, untouched blur mutation, and the +free boolean edit provenance. + +- [ ] **Step 4: Wire React to the private package** + +Add the calendar package to React dev dependencies and all three React tsconfig +path maps (`src` for local tests, `dist` for build/typecheck). Refresh the +lockfile. The existing `/^@pretable-internal\//` tsup rule bundles it; do not add +row-model as a React runtime import. + +- [ ] **Step 5: Rewrite UI utilities over shared primitives** + +Delete `toIsoDate`, `parseIsoDate`, ISO date-time parsing, and `Date.parse`. +Retain only React/UI policy: viewer-local `todayIso`, English month labels, and a +Monday-first six-week matrix. Represent boundary cells as: + +```ts +export interface CalendarDay { + readonly iso: string | null; + readonly day: number | null; + readonly inMonth: boolean; + readonly disabled: boolean; +} +``` + +Only positions outside the supported domain have `iso: null`. All valid filler +days remain selectable. + +- [ ] **Step 6: Implement provenance-aware editor state** + +Use one atomic render-time-synchronized state object, not effect-driven prop +state: + +```ts +interface DateEditorState { + readonly observedDraft: unknown; + readonly cursor: string; + readonly selected: string | null; + readonly userModified: boolean; + readonly userDraft: unknown; +} +``` + +Guard synchronization with `Object.is(state.observedDraft, input.draft)`. A +changed canonical controlled draft retargets cursor and selection; a changed +invalid draft clears selection but preserves the last useful cursor. `onChange` +and successful navigation atomically set `userModified`/`userDraft` before +calling `input.setDraft`. + +Blur commits exact canonical/null seeds, user-modified canonical/empty drafts, +or a user-modified custom-parser draft. Untouched noncanonical seeds cancel. +Boundary navigation no-ops do not create provenance. + +- [ ] **Step 7: Key typing provenance to edit identity** + +Replace the free `seededFromTyping: boolean` with a private session record: + +```ts +interface EditSessionState { + readonly activeToken: number | null; + readonly typedToken: number | null; +} + +const editSessionSequenceRef = useRef(0); +const [editSession, setEditSession] = useState({ + activeToken: null, + typedToken: null, +}); +``` + +Create one private `beginEditWithSession(input, seededFromTyping)` wrapper. It +increments the sequence, installs that value as `activeToken` and, only for the +explicit printable-key entry path, as `typedToken`, and then calls +`indexedGrid.beginEdit`. Route controller begins and the public surface-grid +facade through that wrapper; every non-typing begin sets `typedToken` to `null`. +Route every cancel and successful commit wrapper through a private +session-ending helper that increments the sequence, clears both tokens, and +then cancels the engine edit. A direct begin while another edit is active +therefore replaces the active token before React renders. + +Extend only the private `CellEditController.begin` signature with an optional +entry-provenance argument and forward it through the controller's private grid +`beginEdit` payload. The printable-key handler calls +`editController.begin(addr, key, { seededFromTyping: true })`; Enter, F2, +boolean editing, and all other controller calls omit it. The facade consumes +that field in `beginEditWithSession` and never forwards it into public engine +state. Add controller-unit assertions for exact forwarding and default-false +behavior. + +Replace every direct `indexedGrid.cancelEdit()` in the surface—including the +pending controlled-row reconciliation layout effect—with the session-ending +helper. Keep the public `surfaceGrid.beginEdit` signature unchanged and route +it through `beginEditWithSession(input, false)`. Search the surface after the +change to prove no begin/cancel bypass remains. + +Draft and status updates do not change the token, so immutable engine edit-state +replacements and immediately resolved async `editable` gates retain provenance. +Pass `seededFromTyping={snapshot.editing !== null && +editSession.activeToken !== null && editSession.activeToken === +editSession.typedToken}`. Do not read a just-issued begin back through the +facade snapshot, compare engine edit objects, add a public edit field, or depend +on observing an intermediate no-edit render. + +- [ ] **Step 8: Run GREEN and full React checks** + +```bash +pnpm --filter @pretable/react exec vitest run --environment jsdom src/__tests__/date-utils.test.ts src/__tests__/date-cell-editor.test.tsx src/__tests__/type-parsing.test.ts src/__tests__/use-cell-edit-controller.test.ts src/__tests__/filter-operators.test.ts src/__tests__/filter-menu.test.tsx src/__tests__/filter-menu-row-model-boundary.test.ts src/__tests__/filter-menu-surface.test.tsx src/__tests__/paste-surface.test.tsx src/__tests__/pretable-surface-editing.test.tsx +pnpm --filter @pretable/react test +pnpm --filter @pretable/react typecheck +pnpm --filter @pretable/react lint +``` + +Expected: all pass with no new warning and no `react-hooks/set-state-in-effect` +suppression. + +- [ ] **Step 9: Commit** + +```bash +git add packages/react/package.json packages/react/tsconfig.json packages/react/tsconfig.build.json packages/react/tsconfig.typecheck.json packages/react/src/editors packages/react/src/filter-menu packages/react/src/use-cell-edit-controller.ts packages/react/src/pretable-surface.tsx packages/react/src/__tests__ pnpm-lock.yaml +git commit -m "feat(react): make date input canonical" +``` + +## Task 5: Add the public date-format contract and compiler + +**Files:** + +- Modify: `packages/row-model/src/column-types.ts:97-166,198-308` +- Modify: `packages/core/src/types.ts:53-116` +- Modify: `packages/core/src/public_api.ts:28-142` +- Modify: `packages/react/src/types.ts:204-550,580-620` +- Modify: `packages/react/src/use-pretable.ts:26-89` +- Modify: `packages/react/src/public_api.ts:145-165` +- Create: `packages/react/src/date-formatters.ts` +- Create: `packages/react/src/__tests__/date-formatters.test.ts` +- Create: `type-tests/core/date-format.types.ts` +- Create: `type-tests/react/date-format.types.tsx` +- Modify: `packages/react/src/__tests__/column-helper-number-format.test.tsx` +- Modify: `packages/react/src/__tests__/indexed-presentation.test.tsx` + +- [ ] **Step 1: Write failing public type tests** + +Declare valid granular and `dateStyle` options with +`satisfies PretableDateFormatOptions`. Add `@ts-expect-error` cases for `hour`, +`timeZone`, `timeStyle`, `fractionalSecondDigits`, and assignment from a broadly +typed `Intl.DateTimeFormatOptions` variable. Assert `PretableDateFormatKey` is +not public. + +Prove `dateFormat` works on direct/computed core helpers, React-augmented +helpers, hand-declared `PretableColumn`, and explicit-model presentation input +under the same schema-authoritative merge policy as `numberFormat`. Import both +the option type and validator from `@pretable/react`. + +- [ ] **Step 2: Write failing compiler/cache tests** + +Cover: + +- `en-US` and `en-GB` output for `dateStyle` and granular fields; +- UTC anchoring under a non-UTC process time zone; +- allowed `calendar` and `numberingSystem` options; +- year `0000` with explicit era (native locale output, not literal `0000`); +- contextual `[pretable] invalid dateFormat for column "due"` errors with + retained `cause`; +- rejection of enumerable/non-enumerable unknown or forbidden own string keys, + all symbol keys, and forbidden keys whose value is `undefined`; +- native-invalid combinations such as `dateStyle` plus a granular component; +- one formatter per stable column/options/locale; only changed option identity + rebuilds one; locale identity rebuilds all; removal/reorder stays coherent. + +- [ ] **Step 3: Run RED** + +```bash +pnpm --filter @pretable/core build +pnpm --filter @pretable/react exec vitest run --environment jsdom src/__tests__/date-formatters.test.ts src/__tests__/column-helper-number-format.test.tsx src/__tests__/indexed-presentation.test.tsx +pnpm typecheck:public +``` + +Expected: FAIL because the option type, column field, compiler, and React +re-exports do not exist. + +- [ ] **Step 4: Add the strict public option type and column field** + +Keep the key alias internal and export: + +```ts +type PretableDateFormatKey = + | "localeMatcher" + | "calendar" + | "numberingSystem" + | "dateStyle" + | "weekday" + | "era" + | "year" + | "month" + | "day" + | "formatMatcher"; + +export type PretableDateFormatOptions = Pick< + Intl.DateTimeFormatOptions, + PretableDateFormatKey +> & + Partial< + Record< + Exclude, + never + > + >; +``` + +Add `dateFormat?: PretableDateFormatOptions` beside `numberFormat` in typed +definitions/options, React presentation/factory/legacy columns, and model +schema merging. Re-export the type and validator from core and React. TSDoc +states that formatting accepts canonical strings only and never affects +derivation/editing. + +- [ ] **Step 5: Implement strict formatter reconciliation** + +Create an internal module with: + +```ts +export type DateFormatterRegistry = ReadonlyMap; + +export interface DateFormatterCacheState { + readonly locale: Intl.LocalesArgument | undefined; + readonly optionsByColumnId: ReadonlyMap; + readonly formatters: DateFormatterRegistry; +} + +export function reconcileDateFormatters( + previous: DateFormatterCacheState | undefined, + columns: readonly PretableColumn[], + locale?: Intl.LocalesArgument, +): DateFormatterCacheState; +``` + +Validate every `Reflect.ownKeys(options)` entry against the exact string +allowlist before constructing +`new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" })`. Reject all +symbols. Never strip invalid keys. Reuse by `Object.is(locale)` and options +reference identity, keyed by column ID. + +- [ ] **Step 6: Run GREEN and public API type checks** + +```bash +pnpm --filter @pretable/react exec vitest run --environment jsdom src/__tests__/date-formatters.test.ts src/__tests__/column-helper-number-format.test.tsx src/__tests__/indexed-presentation.test.tsx +pnpm --filter @pretable/core typecheck +pnpm --filter @pretable/core build +pnpm --filter @pretable/react typecheck +pnpm typecheck:public +``` + +Expected: all pass; React consumers import both public date symbols from one +entry point and no private helper leaks. + +- [ ] **Step 7: Commit** + +```bash +git add packages/row-model/src/column-types.ts packages/core/src/types.ts packages/core/src/public_api.ts packages/react/src/types.ts packages/react/src/use-pretable.ts packages/react/src/public_api.ts packages/react/src/date-formatters.ts packages/react/src/__tests__ type-tests/core/date-format.types.ts type-tests/react/date-format.types.tsx +git commit -m "feat: add native date format options" +``` + +## Task 6: Integrate one native formatter pipeline across every channel + +**Files:** + +- Modify: `packages/react/src/value-formatting.ts:1-159` +- Modify: `packages/react/src/pretable-surface.tsx:350-380,1808-1849,4740-4770,5720-5900` +- Modify: `packages/react/src/group-row.tsx:1-220` +- Modify: `packages/react/src/copy.ts:1-390` +- Modify: `packages/react/src/csv.ts:1-590` +- Modify: `packages/react/src/__tests__/value-formatting.test.ts` +- Create: `packages/react/src/__tests__/date-formatting-surface.test.tsx` +- Create: `packages/react/src/__tests__/column-helper-date-format.test.tsx` +- Modify: `packages/react/src/__tests__/{group-row-render,copy,csv,csv-export-surface,indexed-presentation,external-filter-authority,external-sort-authority,paste-surface}.test.ts(x)` + +- [ ] **Step 1: Write failing pure precedence and cache tests** + +Pin data precedence `format -> canonical dateFormat -> compatible numberFormat +-> fallback` and aggregate precedence `formatAggregate -> canonical dateFormat +-> compatible numberFormat -> fallback`. + +Invalid/non-string date values use each channel's existing fallback. A numeric +date-column `count` may use `numberFormat` but never `dateFormat`. Canonical date +min/max/custom aggregate strings may use `dateFormat`. Spy on the UTC converter +and ensure one conversion/parse per native date format operation. + +- [ ] **Step 2: Write failing surface, authority, and SSR tests** + +Cover localized canonical display, `dateFormat` on a non-date column, invalid +raw fallback, locale/options updates, construction budget, raw custom-renderer +`value` plus localized `formattedValue`, and callback precedence. + +Prove rows mode and explicit model mode. With external sort/filter authority, +presentation applies locally while upstream order/membership and controlled +query state remain untouched. Use `renderToString` plus hydration with an +explicit locale. Assert timezone stability and document/test the equivalent-ICU +precondition rather than promising cross-ICU byte equality. + +Change only a column's `dateFormat` options and assert the row-model snapshot, +derived-row identities, and processing work counters remain unchanged while +the rendered presentation updates. This is the regression for the design's +presentation-only invalidation guarantee. + +- [ ] **Step 3: Write failing group/copy/CSV tests** + +Cover date min/max inheritance, custom canonical aggregates, numeric count, +TSV and HTML, CSV data/group rows, formula-safety raw-vouch behavior, standalone +locale, mounted registry reuse, invalid values, column reorder/removal, and +formatted-copy/noncanonical-paste rejection. + +- [ ] **Step 4: Run RED** + +```bash +pnpm --filter @pretable/react exec vitest run --environment jsdom src/__tests__/value-formatting.test.ts src/__tests__/date-formatting-surface.test.tsx src/__tests__/column-helper-date-format.test.tsx src/__tests__/group-row-render.test.tsx src/__tests__/copy.test.ts src/__tests__/csv.test.ts src/__tests__/csv-export-surface.test.tsx src/__tests__/indexed-presentation.test.tsx src/__tests__/external-filter-authority.test.tsx src/__tests__/external-sort-authority.test.tsx src/__tests__/paste-surface.test.tsx +``` + +Expected: FAIL because only number registries reach consumers. + +- [ ] **Step 5: Generalize the existing cache owner** + +Refactor `value-formatting.ts` to expose one internal registry: + +```ts +export interface ValueFormatterRegistry { + readonly numbers: ReadonlyMap; + readonly dates: ReadonlyMap; +} + +export interface ValueFormatterCache { + resolve( + columns: readonly PretableColumn[], + locale?: Intl.LocalesArgument, + ): ValueFormatterRegistry; +} + +export function createValueFormatterCache(): ValueFormatterCache; +export function compileValueFormatters( + columns: readonly PretableColumn[], + locale?: Intl.LocalesArgument, +): ValueFormatterRegistry; +``` + +Compose number and date reconciliation into one coherent state. Format a +canonical string by converting it exactly once with `dateValueToUtcMs` and +passing that finite result to the UTC-locked formatter. Do not call the +validator and converter separately on the cell hot path. + +- [ ] **Step 6: Thread the registry through all consumers** + +Instantiate `createValueFormatterCache()` once per mounted surface. Immediately +after `authoritativeColumns`, resolve that persistent cache in a memo with exact +`[authoritativeColumns, locale]` dependencies. Pass the exact returned registry +to surface rendering, group rows, mounted copy, and mounted CSV. Replace +`numberFormatters` arguments/props with `valueFormatters` throughout. + +Mounted copy reuses the surface registry. Public standalone `serializeRanges` +and `serializeCsv` use `compileValueFormatters` once per invocation. Preserve +raw values delivered to row-model accessors, callbacks, renderers, edit/paste, +CSV formula-safety vouching, and each channel's existing fallback. + +- [ ] **Step 7: Run GREEN and full React checks** + +```bash +pnpm --filter @pretable/react exec vitest run --environment jsdom src/__tests__/value-formatting.test.ts src/__tests__/date-formatting-surface.test.tsx src/__tests__/column-helper-date-format.test.tsx src/__tests__/group-row-render.test.tsx src/__tests__/copy.test.ts src/__tests__/csv.test.ts src/__tests__/csv-export-surface.test.tsx src/__tests__/indexed-presentation.test.tsx src/__tests__/external-filter-authority.test.tsx src/__tests__/external-sort-authority.test.tsx src/__tests__/paste-surface.test.tsx +pnpm --filter @pretable/react test +pnpm --filter @pretable/react typecheck +pnpm --filter @pretable/react lint +``` + +Expected: all pass; no formatter construction occurs per cell and copy/CSV +match display precedence. + +- [ ] **Step 8: Commit** + +```bash +git add packages/react/src/value-formatting.ts packages/react/src/pretable-surface.tsx packages/react/src/group-row.tsx packages/react/src/copy.ts packages/react/src/csv.ts packages/react/src/__tests__ +git commit -m "feat(react): format calendar dates natively" +``` + +## Task 7: Publish the canonical contract and release evidence + +**Files:** + +- Create: `apps/website/content/docs/grid/date-formatting.mdx` +- Create: `apps/website/app/docs/__tests__/date-formatting.types.tsx` +- Modify: `apps/website/app/docs/_nav.ts:20-50` +- Modify: `apps/website/content/docs/grid/api-reference.mdx` +- Modify: `apps/website/content/docs/grid/editing.mdx` +- Modify: `apps/website/content/docs/grid/filtering.mdx` +- Modify: `apps/website/content/docs/grid/sorting.mdx` +- Modify: `apps/website/content/docs/grid/grouping.mdx` +- Modify: `apps/website/content/docs/grid/clipboard.mdx` +- Modify: `apps/website/content/docs/grid/export.mdx` +- Modify: `apps/website/content/docs/grid/cell-renderers.mdx` +- Modify: `apps/website/content/docs/grid/cell-presentations.mdx` +- Modify: `apps/website/content/docs/grid/pretable-surface.mdx` +- Modify: `apps/website/app/api/docs/rows/dataset.ts:154-212,226-298,337-369` +- Modify: `apps/website/app/api/docs/rows/__tests__/dataset.test.ts` +- Modify: `apps/website/lib/docs/__tests__/docs-api-surface.test.ts` +- Modify: `apps/website/e2e/docs.spec.ts` +- Modify: `README.md` +- Modify generated: `packages/core/core.api.md` +- Modify generated: `packages/react/react.api.md` +- Create: `.changeset/.md` +- Modify: `docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md` +- Modify: `ROADMAP.md` + +- [ ] **Step 1: Write failing docs and server-fixture tests** + +Register a typed fixture for every date-formatting code fence. Extend docs API +surface tests and Playwright to require the route, nav entry, canonical +`YYYY-MM-DD`, `dateFormat`, `PretableDateFormatOptions`, and +`isValidDateValue`. + +In the server-data dataset tests, require canonical string comparison, invalid +semantic string zero-match, and wrong JavaScript operand type error. Confirm +there is no `Date.parse` or date-time coercion in the demo backend. + +Run RED: + +```bash +pnpm --filter @pretable/app-website exec vitest run lib/docs/__tests__/docs-api-surface.test.ts app/api/docs/rows/__tests__/dataset.test.ts +``` + +Expected: FAIL because the guide/fixture does not exist and the server demo is +coercive. + +- [ ] **Step 2: Write the guide and correct existing claims** + +Document: + +- calendar date versus instant; +- canonical `YYYY-MM-DD | null` storage and public validator; +- typed `createColumnHelper` and explicit-model/rows-mode usage; +- `dateFormat` with `dateStyle` and granular fields; +- exact option allowlist and internal UTC anchor; +- locale/ICU SSR requirements; +- valid-first/non-date-last sorting and the date-specific `nulls` rule; +- strict filters, invalid controlled zero-match, and invalid menu-draft removal; +- `min`, `max`, and numeric `count` aggregate behavior; +- callback/date/number/fallback precedence across display, group, copy, and CSV; +- formatted copy does not round-trip through strict paste; +- migration from Date/epoch/date-time values at the application boundary; +- no unsafe `toISOString().slice(0, 10)` recommendation without a chosen UTC + policy. + +Remove mixed-date claims from editing, filtering, renderer, clipboard, export, +surface, and API docs. Keep application-owned `Date` examples only when clearly +named as instants and paired with explicit projection/hooks. + +- [ ] **Step 3: Align the server-data demo** + +Import `isValidDateValue` from the public core entry point. Remove +`ISO_DATETIME_RE`, `utcDayOf`, `isoDayMs`, and `toDayMs`. Date operand shape +validation accepts strings/2-string ranges; `matchesDate` returns false when a +cell or operand string is noncanonical and otherwise compares canonical strings +directly. Preserve every non-date operator and fixture count. + +- [ ] **Step 4: Add navigation, README, lifecycle, and release intent** + +Place “Date formatting” after “Number formatting”. Add one concise README +capability/guide link. Keep the design `Status: planned` and its plan link until +release. Keep the roadmap item in `Now` and label the link “planned design”. + +Create one changeset: + +```md +--- +"@pretable/core": minor +"@pretable/react": minor +--- + +**Breaking:** Make date columns strict RFC 3339 full-date values and add native, +locale-aware date formatting. Applications must project Date, epoch, date-time, +or localized values to `YYYY-MM-DD | null` to retain built-in date processing. +``` + +Do not list UI or stream-adapter; the fixed release group aligns packages. Do +not use a major changeset because that would publish `1.0.0` prematurely. + +- [ ] **Step 5: Generate and inspect API reports** + +```bash +pnpm api +pnpm api:check +``` + +Expected: reports include `dateFormat`, `PretableDateFormatOptions`, and +`isValidDateValue`; they do not expose `PretableDateFormatKey`, calendar parts, +parser, arithmetic, UTC conversion, or formatter registries. + +- [ ] **Step 6: Run documentation and packaging gates** + +```bash +pnpm --filter @pretable/app-website test +pnpm --filter @pretable/app-website typecheck +pnpm --filter @pretable/core build +pnpm --filter @pretable/react build +pnpm lint:packaging +pnpm exec prettier --check README.md ROADMAP.md .changeset apps/website/content/docs/grid apps/website/app/docs/_nav.ts apps/website/app/docs/__tests__/date-formatting.types.tsx apps/website/app/api/docs/rows apps/website/e2e/docs.spec.ts packages/core/core.api.md packages/react/react.api.md docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md +git diff --check +``` + +The full website suite has one known load-sensitive strict-mode test that once +timed out on a clean main baseline and passed the package rerun plus five +isolated reruns. If it recurs, use systematic debugging and fresh reruns to +distinguish load from a date regression; do not skip or extend the new tests. + +- [ ] **Step 7: Build and test the local docs branch in real browsers** + +Playwright defaults to the deployed site, so start this branch locally: + +```bash +set -e +pnpm --filter @pretable/app-website build +pretable_docs_log=$(mktemp) +pnpm --filter @pretable/app-website exec next start --hostname 127.0.0.1 --port 3107 >"$pretable_docs_log" 2>&1 & +pretable_docs_pid=$! +trap 'kill "$pretable_docs_pid" 2>/dev/null || true' EXIT +for attempt in {1..120}; do + if curl -fsS http://127.0.0.1:3107/docs/grid/date-formatting >/dev/null; then + break + fi + sleep 0.25 +done +curl -fsS http://127.0.0.1:3107/docs/grid/date-formatting >/dev/null +BASE_URL=http://127.0.0.1:3107 pnpm --filter @pretable/app-website exec playwright test e2e/docs.spec.ts --project=chromium --project=webkit +kill "$pretable_docs_pid" +trap - EXIT +``` + +Expected: both browsers pass against the local route. + +- [ ] **Step 8: Commit** + +```bash +git add README.md ROADMAP.md .changeset apps/website packages/core/core.api.md packages/react/react.api.md docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md +git commit -m "docs: publish canonical date contract" +``` + +## Final verification and review + +After all seven tasks pass their task-level spec and quality reviews: + +- [ ] Dispatch one final reviewer against the design, this plan, and the complete + branch diff. +- [ ] Resolve every Critical/Important finding and re-review. +- [ ] Run fresh full gates: + +```bash +pnpm test +pnpm typecheck +pnpm lint +pnpm format +pnpm build +pnpm api:check +pnpm lint:packaging +pnpm publish:preflight +git diff --check origin/main...HEAD +``` + +- [ ] Verify scope and forbidden leftovers: + +```bash +rg -n "toIsoDate|toDayMs|ISO_DATETIME_RE|Date\.parse" packages/calendar-date/src packages/row-model/src packages/react/src/editors apps/website/app/api/docs/rows +rg -n "Cells may be.*Date|epoch milliseconds|ISO datetimes" apps/website/content/docs/grid +git status --short +``` + +Expected: the first two searches return no live-contract leftovers. Generic +clipboard serialization and application-owned instant examples may still use +`Date`; inspect every match rather than deleting unrelated support. The worktree +is clean. + +- [ ] Use `superpowers:requesting-code-review`, then + `superpowers:verification-before-completion`, then + `superpowers:finishing-a-development-branch` and follow the user's requested + PR/merge workflow. diff --git a/docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md b/docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md new file mode 100644 index 000000000..b47cd9edf --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md @@ -0,0 +1,520 @@ +# Canonical calendar dates on the incremental row model — design + +**Date:** 2026-08-18 + +**Status:** planned + +**Supersedes:** + +- the date-specific value, parsing, filtering, and editor decisions in + [`2026-08-05-typed-cell-editors-design.md`](2026-08-05-typed-cell-editors-design.md); +- the `Date.parse` date normalization and filtering rule in + [`2026-06-18-filter-engine-model-design.md`](2026-06-18-filter-engine-model-design.md). + +Their non-date decisions remain authoritative. + +**Implementation plan:** +[`2026-08-18-canonical-calendar-dates-row-model.md`](../plans/2026-08-18-canonical-calendar-dates-row-model.md) + +## Decision summary + +Pretable will treat a `date` column as a calendar-date column, not as a +JavaScript timestamp column. Its built-in domain is the RFC 3339 full-date +string `YYYY-MM-DD` or `null`, with years `0000` through `9999` in the +proleptic Gregorian calendar. + +The implementation will target the current incremental row-model architecture. +It will not merge or restore the superseded grid-core implementation from the +unreleased `blove/calendar-date-formatting` branch. + +A new private, zero-dependency `@pretable-internal/calendar-date` package will +own calendar arithmetic and validation shared by the row model and React. The +row model will own typed query, grouping, sorting, and aggregate semantics. +React will own native presentation, editing, clipboard, CSV, and hydration. + +This is intentionally a breaking correction. Pretable will not preserve +support for `Date` objects, epoch numbers, date-time strings, or permissive +native parsing in built-in date behavior. + +## Why this belongs on the roadmap + +Financial applications exposed the problem, but the feature is not +finance-specific. Portfolio, planning, operations, inventory, and reporting +grids all need a date without a time zone, predictable sorting and filtering, +locale-aware display, safe editing, and stable export. + +The product lesson is broader: Pretable should turn common application-level +correctness work into small, coherent grid primitives. Native money, +accounting, and number formatting established that pattern. Canonical calendar +dates are the next instance. + +## Current problem + +The released `0.10.0` architecture has two incompatible notions of a date: + +- the row model accepts `Date`, epoch numbers, full timestamps, and date + strings through coercive conversion; +- React separately normalizes mixed values for the date editor; +- date sorting can fall through generic null and string comparison; +- filters accept `string | number | Date` operands; +- built-in date extrema are not expressible without widening string aggregates; +- native number formatting has one coherent presentation pipeline, but native + date formatting does not exist. + +That ambiguity is unsafe for date-only values. A birthday, statement date, or +fiscal-period boundary must not move because of the host time zone, implicit +`Date.parse` rules, or a local-midnight conversion. + +## Goals + +1. Define one strict, portable date-only value contract across typed columns, + local derivation, controlled query state, editing, display, copy, and CSV. +2. Share validation, comparison, UTC conversion, and bounded arithmetic without + coupling the row model to React or duplicating date logic. +3. Give date columns valid-first chronological sort and grouping behavior, + strict filtering, and type-safe `min`, `max`, and `count` aggregates. +4. Add locale-aware native date formatting with the same precedence, caching, + explicit-model support, and SSR guarantees as native number formatting. +5. Preserve raw application values. Presentation and editing helpers must not + rewrite row data merely because a grid opened or rendered it. +6. Keep hot paths allocation-conscious and preserve incremental row-model + invalidation guarantees. + +## Non-goals + +- Date-times, instants, time zones, durations, fiscal calendars, or recurrence. +- Compatibility with `Date`, epoch numbers, or noncanonical date strings. +- Finance-domain field schemas, formulas, scenarios, or application workflows. +- A public calendar utility package or public exports for parsing and + arithmetic internals. +- A second row engine or restoration of deleted grid-core processing paths. +- Per-cell formatter construction or hidden data normalization. + +## Canonical value contract + +The built-in date domain is: + +```ts +type PretableDateValue = string | null; +``` + +This notation describes the contract; it does not require a separate public +`PretableDateValue` alias. + +A non-null value is valid only when all of these are true: + +- it is exactly ten ASCII characters in `YYYY-MM-DD` form; +- the year is `0000` through `9999`; +- the month is `01` through `12`; +- the day exists in that month under proleptic Gregorian leap-year rules. + +Whitespace, partial dates, timestamps, locale-formatted text, `Date` objects, +numbers, `undefined`, and invalid calendar combinations are not dates. + +`null` is the canonical empty value. Existing rows may still contain other raw +values at runtime because JavaScript and remote data are not type-safe. The grid +must retain and render those values through its normal fallback path, but it +must not treat them as valid date values. + +Core will publicly export: + +```ts +function isValidDateValue(value: unknown): value is string; +``` + +No public parser, UTC converter, comparator, or arithmetic helper is required. +`@pretable/react` re-exports both `isValidDateValue` and the public date-format +options type so React consumers do not need a second package import. + +## Package ownership + +### Private calendar package + +Create `packages/calendar-date` with package name +`@pretable-internal/calendar-date`. It has no runtime dependencies and is not a +published public entry point. + +It owns: + +- strict parsing and validation; +- canonical classification and comparison; +- conversion to a UTC presentation instant; +- bounded day and month arithmetic for the editor; +- minimum and maximum supported values; +- detached, immutable calendar parts where needed. + +It must not call `Date.parse`, accept coercive inputs, or expose a helper that +silently normalizes invalid values. + +Low years require an explicit UTC construction rule because `Date.UTC` remaps +years `0` through `99` to `1900` through `1999`. The implementation should +construct the equivalent year plus 400 and subtract exactly 146,097 days. +Tests must pin years `0000`, `0001`, `0050`, `0099`, `0100`, and `9999`. + +### Dependency direction + +The allowed dependency graph is: + +```text +@pretable-internal/row-model -> @pretable-internal/calendar-date +@pretable/core -> row-model + calendar-date +@pretable/react -> @pretable/core + calendar-date +``` + +Core bundles its private dependencies and hand-exports only the public +validator and public column types. React bundles the private calendar helper. +React must not import the row-model runtime merely to perform date arithmetic, +and the row model must not depend on React or presentation code. + +Each package retains one builder. Package scripts must build only their own +package; the workspace build graph orders dependencies. + +## Typed column API + +`type: "date"` accepts only `string | null` values in the typed column helper. +`Date` is removed from `PretableGroupKey`, date inference, date filter operands, +and all built-in date-specific types. + +Because this is a breaking release, compile failures are the intended migration +signal for consumers using `Date` or numeric date values. + +`dateFormat` mirrors the established `numberFormat` presentation field: + +```ts +const columns = [ + column.accessor("asOf", { + type: "date", + dateFormat: { year: "numeric", month: "short", day: "2-digit" }, + }), +] as const; +``` + +It is presentation configuration and does not affect query-plan identity, +sorting, filtering, grouping, editing, or raw row-model reads. It must survive +the same schema and explicit-model presentation paths as `numberFormat`. + +`dateFormat` may be present on a non-date column, just as `numberFormat` is +independent of `type`. It formats only a canonical string value; otherwise the +normal fallback applies. This keeps presentation composable without making +column type an implicit renderer switch. + +## Native date formatting + +Define a public `PretableDateFormatOptions` type with this strict allowlist: + +- `localeMatcher` +- `calendar` +- `numberingSystem` +- `dateStyle` +- `weekday` +- `era` +- `year` +- `month` +- `day` +- `formatMatcher` + +Every other `Intl.DateTimeFormatOptions` key is represented as optional +`never`, including `timeZone`, time fields, and time-zone-name fields. Pretable +always formats the internally constructed instant in UTC. + +The type belongs with the public column contract in the row-model column types, +is re-exported by core, and is re-exported again by React. The private calendar +package owns no presentation configuration. + +Runtime validation must inspect `Reflect.ownKeys(options)`. It rejects: + +- enumerable or non-enumerable unknown string keys; +- every symbol key; +- every forbidden native option, even when its value is `undefined`. + +Pretable must not silently strip invalid options before constructing the native +formatter. Errors identify the column and invalid option. + +Native output is deliberately locale- and runtime-dependent. In particular, +some calendars or locales may present year `0000` as an era-based year. The +contract guarantees the correct UTC calendar instant, not a literal `0000` in +localized output. + +## One presentation pipeline + +Generalize the existing number-formatter registry rather than building a +parallel date-only path. For each authoritative column and locale, compile at +most one number formatter and one date formatter. Reuse the same registry for +mounted cell rendering, group aggregates, copy, and CSV. + +Data-cell precedence is: + +1. `format` +2. valid canonical value plus compiled `dateFormat` +3. compatible raw value plus compiled `numberFormat` +4. existing fallback stringification + +Group-aggregate precedence is: + +1. `formatAggregate` +2. valid canonical aggregate plus compiled `dateFormat` +3. compatible numeric aggregate plus compiled `numberFormat` +4. existing aggregate fallback + +`count` remains numeric and may inherit `numberFormat`; it does not inherit +`dateFormat`. Copy and CSV use these same chains verbatim. Custom cell renderers +continue to own their rendered output while export follows the documented value +pipeline. + +Formatter caches are keyed by authoritative column identity, option identity, +and locale identity. Column reorder and removal must not produce a half-coherent +cache. No formatter or options object is constructed in the per-cell hot path. + +## Row-model processing + +### Sorting and grouping + +Date comparison first classifies each raw value: + +- canonical date; +- non-date, including `null`, `undefined`, invalid strings, objects, and + numbers. + +Canonical dates sort chronologically. Non-dates form one terminal rank and +remain last for both ascending and descending date sorts. The public `nulls` +setting does not move this terminal date rank. It continues to govern generic +column null behavior. + +Equal canonical dates and equal terminal-rank values fall through to later sort +keys and then stable source order. Sibling date groups use exactly the same +comparison policy. + +A custom column comparator remains authoritative when supplied. The built-in +calendar policy applies only in its absence. + +### Filtering + +Date filter operands are canonical strings at the type level and are validated +again at runtime. + +Built-in date operators compare canonical calendar values only. Invalid or +empty cells never satisfy a date comparison. A string operand with invalid +calendar semantics remains an active controlled/headless filter that matches +zero rows; the grid does not silently remove application-owned query state. A +runtime operand of the wrong JavaScript type still raises the row model's +structured query-validation error. `dateBetween` matches zero rows when either +string member is noncanonical. In the React filter menu, an incomplete, cleared, +or invalid draft emits `null` and removes the applied filter; it never leaves a +previous valid filter active under invalid visible input. + +When filter or sort authority is external, the row model continues to publish +the query without applying it locally. Date presentation remains local. A +windowed, server-controlled result must never be reordered or refiltered by the +new date semantics. + +### Aggregation + +Make built-in aggregate typing column-type-aware rather than widening string +aggregation globally. The public generic shape becomes: + +```ts +type PretableBuiltinAggregate = + | "count" + | (TType extends "number" + ? NonNullable extends number + ? "sum" | "avg" | "min" | "max" + : never + : TType extends "date" + ? NonNullable extends string + ? "min" | "max" + : never + : never); + +type PretableAggregateOutputOf< + TAggregate, + TType extends PretableColumnType, +> = TAggregate extends { + readonly finalize: (accumulator: never) => infer TOutput; +} + ? TOutput + : TAggregate extends "min" | "max" + ? TType extends "date" + ? string | null + : number | null + : TAggregate extends "sum" | "avg" | "count" + ? number | null + : never; +``` + +The custom-aggregator branch preserves the repository's existing structural +finalize inference. The architectural decision is that built-in output is a +function of both aggregate token and column type. + +Thread `TType` through `PretableAggregateSpec`, `PretableColumnDefinition`, +`PretableColumnOptions`, column-helper overloads, callback contexts, +`PretableAggregateFormatInput`, `PretableCompatibleAggregateSpec`, +`PretableColumnDerivation`, `PretableAggregatesFor`, and the React column factory +and presentation types. Do not replace the existing one-parameter generic with +a union that makes every `"min"` output `string | number | null`. + +The resulting legal built-ins are: + +- number columns: `sum`, `avg`, `min`, `max`, `count`; +- date columns with canonical string values: `min`, `max`, `count`; +- other columns: `count` unless a compatible custom aggregator is supplied. + +Date `min` and `max` return a canonical string or `null`. They ignore +noncanonical and empty values. `count` retains its existing meaning and numeric +output. + +At runtime, query compilation lowers date `min` and `max` to private immutable, +associative calendar aggregators before values reach the aggregate tree. Do not +broaden the tree's numeric built-ins or make arbitrary string `min` and `max` +legal. + +Aggregate output inference must distinguish numeric extrema from date extrema +through the column descriptor/type parameter. Custom aggregator inference must +remain unchanged. + +### Incremental invalidation + +Column `type` already participates in semantic query-plan comparison and must +continue to do so. Transitions into or out of `date` invalidate affected filter, +sort, grouping, distinct-value, and aggregate work exactly once without +discarding unrelated indexes. + +Tests must cover both directions of type transitions with active queries and +group aggregates. Presentation-only `dateFormat` changes must not invalidate the +derived row model. + +## Editing and paste + +The built-in date editor accepts and commits only canonical dates. A user-cleared +draft commits `null`. Paste uses the same strict parser. + +Opening and blurring an untouched noncanonical raw seed must never normalize or +commit it. This includes an empty string, padded strings, whitespace, +`undefined`, and values accepted only by a custom parser. User provenance must +be keyed to a private monotonically increasing edit-session token so draft and +status replacements within one edit retain provenance while batched +cancel/begin transitions cannot leak type-to-replace state into another edit. +The built-in parser never trims; only an exact user-cleared empty draft maps to +`null`. + +Applications with a different model retain the existing escape hatches: +`formatEditValue`, `parseEditValue`, custom editors, custom filter UI, and value +projection. Those hooks can own a noncanonical application model, but they do +not restore built-in date sorting, filtering, grouping, or aggregation. To use +all built-in date processing, rows or `column.value` must project +`YYYY-MM-DD | null`. + +Calendar navigation uses bounded arithmetic, clamps month/year movement, and +never crosses outside `0000-01-01` through `9999-12-31`. Out-of-range days are +disabled placeholders, not wrapped dates. Controlled draft updates must keep +the cursor, selection, active descendant, and month view coherent without +effect-driven state synchronization. + +## Raw-value and SSR guarantees + +- Row storage, accessor output, transactions, callbacks, query snapshots, and + model reads remain raw. +- Rendering or formatting never mutates a row. +- UTC eliminates host-time-zone drift between server and client. Identical text + additionally requires equivalent `Intl` implementation and locale data; apps + spanning different ICU data must use a custom formatter or accept the normal + hydration constraint. +- A canonical date created for presentation must never be interpreted in the + host local time zone. +- Invalid values reach the documented fallback rather than throwing during a + cell render. + +## Performance requirements + +- Parsing and comparison are allocation-conscious and do not construct `Date` + objects for row-model sorting or filtering. +- Presentation converts a canonical string once per format operation; it does + not validate by parsing twice. +- Native formatters are compiled once per effective column/locale combination. +- Date aggregation satisfies the same associativity, snapshot isolation, and + incremental-update laws as existing aggregates. +- The implementation adds focused work-counter assertions for date transitions + and a representative benchmark only if measurement shows a material new hot + path. It does not create finance-specific workload profiles. + +## Migration + +This feature ships as a breaking change. Consumer guidance is direct: + +1. Store or project date-only values as `YYYY-MM-DD | null`. +2. Replace `Date`, epoch, timestamp, and localized-string filter operands with + canonical full-date strings. +3. Use `dateFormat` for native localized display. +4. Use application-owned hooks only when the domain model intentionally differs; + recognize that display/edit hooks alone do not provide built-in processing. + +For example, when the application explicitly chooses UTC calendar semantics and +has already validated an RFC 3339 UTC timestamp, it can project the date prefix +before the value reaches the grid: + +```ts +const asCalendarDate = timestamp.slice(0, 10); +``` + +If an application deliberately starts from a `Date`, it must choose and document +its own calendar-zone policy before projecting the string. Pretable will not +guess whether `new Date("2026-08-11T00:00:00Z")` means the UTC or local calendar +day. + +## Documentation and release surface + +The implementation includes: + +- a date-formatting guide using `createColumnHelper`, `createLocalRowModel`, and + both rows mode and explicit-model presentation; +- API reference for the canonical contract, exact formatting allowlist, + precedence, aggregates, external authority, and migration; +- examples for localized display, strict editing, invalid controlled filters, + date extrema, copy, and CSV; +- generated API reports with only the intended public exports; +- a `minor` changeset for the fixed public-package release group whose summary + begins with explicit **Breaking** migration prose. A major changeset would + prematurely publish `1.0.0` under the current release configuration. + +Consumer docs must describe shipped behavior only. Until release, this design +and the roadmap carry the planned outcome. + +## Verification gates + +Implementation is complete only when all of these are green: + +- private calendar-package unit and property tests; +- row-model compiled-query, flat-query, grouping, aggregation, transition, + external-authority, and type tests; +- React editor, filter menu, parser, formatting, group render, copy, CSV, + indexed rows, explicit-model, SSR, and hydration tests; +- public export and generated API checks; +- package builds, typechecks, lint, formatting, packaging, and the full suite; +- production documentation build and local Chromium/WebKit documentation tests. + +Boundary tests must cover leap years, invalid dates, years `0000` and `9999`, +low-year UTC conversion, strict own-key option validation, valid-first ordering +in both directions, multi-sort ties, group siblings, invalid-filter zero match, +date extrema, type transitions, untouched-invalid blur, session provenance, raw +copy fallback, and external query authority. + +## Delivery sequence + +1. Create and prove the private calendar package. +2. Correct row-model public types and strict query semantics. +3. Add date-aware aggregate typing and runtime lowering. +4. Port strict React editing and filtering onto the shared package. +5. Generalize the presentation registry for native date formatting across + render, group, copy, and CSV. +6. Publish docs, API reports, migration guidance, and the breaking changeset. + +Each step is test-first and independently reviewed. The implementation plan will +name exact files and commands after this design is approved. + +## Historical implementation disposition + +The old calendar-date branch remains useful as behavioral test evidence, not as +an integration base. Its grid-core processing commits conflict with the current +architecture and must not be cherry-picked. React behavior may be manually +ported only after its assumptions are reconciled with current typed columns, +explicit model mode, windowed data, copy/CSV parity, and the incremental row +model. From 36282bdaf3855d44d7bd23a04eb46bae341dee8a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 17:07:39 -0700 Subject: [PATCH 02/16] feat(core): define canonical calendar dates --- packages/calendar-date/package.json | 29 ++ .../src/__tests__/calendar-date.test.ts | 304 ++++++++++++++++++ packages/calendar-date/src/calendar-date.ts | 108 +++++++ packages/calendar-date/src/index.ts | 1 + packages/calendar-date/tsconfig.json | 10 + .../calendar-date/tsconfig.typecheck.json | 12 + packages/core/package.json | 1 + .../core/src/__tests__/calendar-date.test.ts | 46 +++ packages/core/src/public_api.ts | 1 + packages/core/tsup.config.ts | 6 +- pnpm-lock.yaml | 9 + 11 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 packages/calendar-date/package.json create mode 100644 packages/calendar-date/src/__tests__/calendar-date.test.ts create mode 100644 packages/calendar-date/src/calendar-date.ts create mode 100644 packages/calendar-date/src/index.ts create mode 100644 packages/calendar-date/tsconfig.json create mode 100644 packages/calendar-date/tsconfig.typecheck.json create mode 100644 packages/core/src/__tests__/calendar-date.test.ts diff --git a/packages/calendar-date/package.json b/packages/calendar-date/package.json new file mode 100644 index 000000000..61e10bf8d --- /dev/null +++ b/packages/calendar-date/package.json @@ -0,0 +1,29 @@ +{ + "name": "@pretable-internal/calendar-date", + "version": "0.0.0", + "private": true, + "description": "Internal strict calendar-date primitives for Pretable.", + "license": "MIT", + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "lint": "eslint src --ext .ts,.tsx", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -b --pretty false && tsc -p tsconfig.typecheck.json --noEmit --pretty false" + }, + "devDependencies": { + "fast-check": "^4.9.0" + } +} diff --git a/packages/calendar-date/src/__tests__/calendar-date.test.ts b/packages/calendar-date/src/__tests__/calendar-date.test.ts new file mode 100644 index 000000000..cabbb48a2 --- /dev/null +++ b/packages/calendar-date/src/__tests__/calendar-date.test.ts @@ -0,0 +1,304 @@ +import { readFileSync } from "node:fs"; + +import * as fc from "fast-check"; +import { describe, expect, test } from "vitest"; + +import { + MAX_DATE_VALUE, + MIN_DATE_VALUE, + addDateValueDays, + addDateValueMonths, + compareDateValues, + dateValueToUtcMs, + isValidDateValue, + parseDateValue, +} from "../calendar-date"; + +const MS_PER_DAY = 86_400_000; +const DAYS_PER_GREGORIAN_CYCLE = 146_097; + +const isLeapYear = (year: number) => + year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + +const daysInMonth = (year: number, month: number) => { + if (month === 2) return isLeapYear(year) ? 29 : 28; + return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; +}; + +const formatDateValue = (year: number, month: number, day: number) => + `${year.toString().padStart(4, "0")}-${month + .toString() + .padStart(2, "0")}-${day.toString().padStart(2, "0")}`; + +const referenceUtcMs = (year: number, month: number, day: number) => + year < 100 + ? Date.UTC(year + 400, month - 1, day) - + DAYS_PER_GREGORIAN_CYCLE * MS_PER_DAY + : Date.UTC(year, month - 1, day); + +const validDateValueArbitrary = fc + .integer({ min: 0, max: 9_999 }) + .chain((year) => + fc + .integer({ min: 1, max: 12 }) + .chain((month) => + fc + .integer({ min: 1, max: daysInMonth(year, month) }) + .map((day) => formatDateValue(year, month, day)), + ), + ); + +const revokedProxy = (() => { + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + return revocable.proxy; +})(); + +describe("calendar-date constants and validation", () => { + test("defines the complete supported range", () => { + expect(MIN_DATE_VALUE).toBe("0000-01-01"); + expect(MAX_DATE_VALUE).toBe("9999-12-31"); + }); + + test.each([ + ["0000-02-29", { year: 0, month: 2, day: 29 }], + ["0001-01-01", { year: 1, month: 1, day: 1 }], + ["0050-06-15", { year: 50, month: 6, day: 15 }], + ["0099-12-31", { year: 99, month: 12, day: 31 }], + ["0100-03-01", { year: 100, month: 3, day: 1 }], + ["2000-02-29", { year: 2000, month: 2, day: 29 }], + ["2024-02-29", { year: 2024, month: 2, day: 29 }], + ["9999-12-31", { year: 9999, month: 12, day: 31 }], + ] as const)("accepts and parses %s", (value, expected) => { + expect(isValidDateValue(value)).toBe(true); + expect(parseDateValue(value)).toEqual(expected); + }); + + test.each([ + ["null", null], + ["undefined", undefined], + ["empty", ""], + ["leading whitespace", " 2024-02-29"], + ["trailing whitespace", "2024-02-29 "], + ["loose month", "2024-2-29"], + ["loose day", "2024-02-9"], + ["five-digit year", "02024-02-29"], + ["month zero", "2024-00-01"], + ["month overflow", "2024-13-01"], + ["day zero", "2024-01-00"], + ["day overflow", "2024-01-32"], + ["common-year leap day", "2026-02-29"], + ["century non-leap day", "0100-02-29"], + ["April overflow", "2024-04-31"], + ["date-time", "2024-02-29T00:00:00"], + ["UTC date-time", "2024-02-29T00:00:00Z"], + ["offset date-time", "2024-02-29T00:00:00-08:00"], + ["Date instance", new Date("2024-02-29T00:00:00Z")], + ["finite number", 20240229], + ["NaN", Number.NaN], + ["positive infinity", Number.POSITIVE_INFINITY], + ["negative infinity", Number.NEGATIVE_INFINITY], + ["array", ["2024-02-29"]], + ["object", { value: "2024-02-29" }], + ["boxed string", new String("2024-02-29")], + [ + "hostile proxy", + new Proxy( + {}, + { + get() { + throw new Error("must not inspect objects"); + }, + getPrototypeOf() { + throw new Error("must not inspect prototypes"); + }, + ownKeys() { + throw new Error("must not inspect keys"); + }, + }, + ), + ], + ["revoked proxy", revokedProxy], + ] as const)("rejects %s without coercion", (_label, value) => { + expect(() => isValidDateValue(value)).not.toThrow(); + expect(isValidDateValue(value)).toBe(false); + expect(parseDateValue(value)).toBeNull(); + }); + + test("uses explicit parsing rather than Date.parse", () => { + const source = readFileSync( + new URL("../calendar-date.ts", import.meta.url), + "utf8", + ); + + expect(source).not.toContain("Date.parse"); + }); + + test("round-trips parsed dates across the supported domain", () => { + fc.assert( + fc.property(validDateValueArbitrary, (value) => { + const parsed = parseDateValue(value); + expect(parsed).not.toBeNull(); + expect(formatDateValue(parsed!.year, parsed!.month, parsed!.day)).toBe( + value, + ); + }), + { numRuns: 500 }, + ); + }); + + test("applies Gregorian validity to every four-digit year", () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: 9_999 }), + fc.integer({ min: 0, max: 13 }), + fc.integer({ min: 0, max: 32 }), + (year, month, day) => { + const value = formatDateValue(year, month, day); + const expected = + month >= 1 && + month <= 12 && + day >= 1 && + day <= daysInMonth(year, month); + + expect(isValidDateValue(value)).toBe(expected); + }, + ), + { numRuns: 1_000 }, + ); + }); +}); + +describe("calendar-date conversion and comparison", () => { + test.each([ + ["0000-01-01", "0000-01-01T00:00:00.000Z"], + ["0001-01-01", "0001-01-01T00:00:00.000Z"], + ["0050-01-01", "0050-01-01T00:00:00.000Z"], + ["0099-12-31", "0099-12-31T00:00:00.000Z"], + ["0100-01-01", "0100-01-01T00:00:00.000Z"], + ["9999-12-31", "9999-12-31T00:00:00.000Z"], + ] as const)("converts %s to UTC midnight", (value, expected) => { + expect(new Date(dateValueToUtcMs(value)).toISOString()).toBe(expected); + }); + + test("returns NaN instead of coercing invalid runtime input", () => { + for (const value of [ + "2026-02-30", + "2026-01-01T00:00:00Z", + new Date("2026-01-01T00:00:00Z"), + 0, + null, + ]) { + expect(dateValueToUtcMs(value as unknown as string)).toBe(Number.NaN); + } + }); + + test.each([ + ["2026-01-02", "2026-02-01", -1], + ["2026-02-01", "2026-01-02", 1], + ["0050-01-01", "0050-01-01", 0], + ] as const)("compares %s with %s", (left, right, expectedSign) => { + expect(Math.sign(compareDateValues(left, right))).toBe(expectedSign); + }); + + test("keeps comparison antisymmetric and transitive", () => { + fc.assert( + fc.property( + validDateValueArbitrary, + validDateValueArbitrary, + validDateValueArbitrary, + (a, b, c) => { + expect(Math.sign(compareDateValues(a, b))).toBe( + -Math.sign(compareDateValues(b, a)), + ); + if (compareDateValues(a, b) <= 0 && compareDateValues(b, c) <= 0) { + expect(compareDateValues(a, c)).toBeLessThanOrEqual(0); + } + }, + ), + { numRuns: 500 }, + ); + }); +}); + +describe("calendar-date arithmetic", () => { + test.each([ + ["0000-01-01", -1, "0000-01-01"], + ["0000-01-01", 0, "0000-01-01"], + ["0000-02-28", 1, "0000-02-29"], + ["0000-02-29", 1, "0000-03-01"], + ["0099-12-31", 1, "0100-01-01"], + ["2024-02-28", 1, "2024-02-29"], + ["2024-02-29", 1, "2024-03-01"], + ["9999-12-31", 1, "9999-12-31"], + ] as const)("adds %i days to %s", (value, days, expected) => { + expect(addDateValueDays(value, days)).toBe(expected); + }); + + test.each([ + ["0000-01-01", -1, "0000-01-01"], + ["0000-02-15", -2, "0000-01-01"], + ["0000-02-29", 12, "0001-02-28"], + ["0099-12-31", 1, "0100-01-31"], + ["2024-01-31", 1, "2024-02-29"], + ["2024-03-31", -1, "2024-02-29"], + ["2024-12-31", 2, "2025-02-28"], + ["9999-11-15", 2, "9999-12-31"], + ["9999-12-31", 1, "9999-12-31"], + ] as const)("adds %i months to %s", (value, months, expected) => { + expect(addDateValueMonths(value, months)).toBe(expected); + }); + + test("matches bounded UTC day arithmetic without leaving the domain", () => { + const minimumMs = referenceUtcMs(0, 1, 1); + const maximumMs = referenceUtcMs(9_999, 12, 31); + + fc.assert( + fc.property( + validDateValueArbitrary, + fc.integer({ min: -5_000_000, max: 5_000_000 }), + (value, days) => { + const result = addDateValueDays(value, days); + const expectedMs = Math.min( + maximumMs, + Math.max(minimumMs, dateValueToUtcMs(value) + days * MS_PER_DAY), + ); + + expect(isValidDateValue(result)).toBe(true); + expect(dateValueToUtcMs(result)).toBe(expectedMs); + }, + ), + { numRuns: 500 }, + ); + }); + + test("matches bounded calendar-month arithmetic and clamps the day", () => { + fc.assert( + fc.property( + validDateValueArbitrary, + fc.integer({ min: -200_000, max: 200_000 }), + (value, months) => { + const parsed = parseDateValue(value)!; + const sourceMonthIndex = parsed.year * 12 + parsed.month - 1; + const unboundedMonthIndex = sourceMonthIndex + months; + const expected = + unboundedMonthIndex < 0 + ? MIN_DATE_VALUE + : unboundedMonthIndex > 9_999 * 12 + 11 + ? MAX_DATE_VALUE + : (() => { + const year = Math.floor(unboundedMonthIndex / 12); + const month = (unboundedMonthIndex % 12) + 1; + const day = Math.min(parsed.day, daysInMonth(year, month)); + return formatDateValue(year, month, day); + })(); + const result = addDateValueMonths(value, months); + + expect(isValidDateValue(result)).toBe(true); + expect(result).toBe(expected); + }, + ), + { numRuns: 500 }, + ); + }); +}); diff --git a/packages/calendar-date/src/calendar-date.ts b/packages/calendar-date/src/calendar-date.ts new file mode 100644 index 000000000..23f5aeaea --- /dev/null +++ b/packages/calendar-date/src/calendar-date.ts @@ -0,0 +1,108 @@ +export interface CalendarDateParts { + readonly year: number; + readonly month: number; + readonly day: number; +} + +export const MIN_DATE_VALUE = "0000-01-01"; +export const MAX_DATE_VALUE = "9999-12-31"; + +const DATE_VALUE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +const MS_PER_DAY = 86_400_000; +const DAYS_PER_GREGORIAN_CYCLE = 146_097; +const MAX_YEAR = 9_999; +const MONTHS_PER_YEAR = 12; +const MAX_MONTH_INDEX = MAX_YEAR * MONTHS_PER_YEAR + 11; + +const isLeapYear = (year: number) => + year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + +const getDaysInMonth = (year: number, month: number) => { + if (month === 2) return isLeapYear(year) ? 29 : 28; + if (month === 4 || month === 6 || month === 9 || month === 11) return 30; + return 31; +}; + +const formatDateValue = (year: number, month: number, day: number) => + `${year.toString().padStart(4, "0")}-${month + .toString() + .padStart(2, "0")}-${day.toString().padStart(2, "0")}`; + +export function parseDateValue(value: unknown): CalendarDateParts | null { + if (typeof value !== "string") return null; + + const match = DATE_VALUE_PATTERN.exec(value); + if (match === null) return null; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + + if (month < 1 || month > 12) return null; + if (day < 1 || day > getDaysInMonth(year, month)) return null; + + return { year, month, day }; +} + +/** @public */ +export function isValidDateValue(value: unknown): value is string { + return parseDateValue(value) !== null; +} + +export function dateValueToUtcMs(value: string): number { + const parsed = parseDateValue(value); + if (parsed === null) return Number.NaN; + + const { year, month, day } = parsed; + if (year < 100) { + return ( + Date.UTC(year + 400, month - 1, day) - + DAYS_PER_GREGORIAN_CYCLE * MS_PER_DAY + ); + } + + return Date.UTC(year, month - 1, day); +} + +export function compareDateValues(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +export function addDateValueDays(value: string, days: number): string { + const timestamp = dateValueToUtcMs(value); + if (Number.isNaN(timestamp) || Number.isNaN(days)) return value; + + const wholeDays = Math.trunc(days); + const minimumTimestamp = dateValueToUtcMs(MIN_DATE_VALUE); + const maximumTimestamp = dateValueToUtcMs(MAX_DATE_VALUE); + const destinationTimestamp = timestamp + wholeDays * MS_PER_DAY; + + if (destinationTimestamp <= minimumTimestamp) return MIN_DATE_VALUE; + if (destinationTimestamp >= maximumTimestamp) return MAX_DATE_VALUE; + + const destination = new Date(destinationTimestamp); + return formatDateValue( + destination.getUTCFullYear(), + destination.getUTCMonth() + 1, + destination.getUTCDate(), + ); +} + +export function addDateValueMonths(value: string, months: number): string { + const parsed = parseDateValue(value); + if (parsed === null || Number.isNaN(months)) return value; + + const destinationMonthIndex = + parsed.year * MONTHS_PER_YEAR + parsed.month - 1 + Math.trunc(months); + + if (destinationMonthIndex < 0) return MIN_DATE_VALUE; + if (destinationMonthIndex > MAX_MONTH_INDEX) return MAX_DATE_VALUE; + + const year = Math.floor(destinationMonthIndex / MONTHS_PER_YEAR); + const month = (destinationMonthIndex % MONTHS_PER_YEAR) + 1; + const day = Math.min(parsed.day, getDaysInMonth(year, month)); + + return formatDateValue(year, month, day); +} diff --git a/packages/calendar-date/src/index.ts b/packages/calendar-date/src/index.ts new file mode 100644 index 000000000..711d39cce --- /dev/null +++ b/packages/calendar-date/src/index.ts @@ -0,0 +1 @@ +export * from "./calendar-date"; diff --git a/packages/calendar-date/tsconfig.json b/packages/calendar-date/tsconfig.json new file mode 100644 index 000000000..edc1551a7 --- /dev/null +++ b/packages/calendar-date/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "files": ["src/index.ts"], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["src/**/*.test.*", "src/**/__tests__/**"] +} diff --git a/packages/calendar-date/tsconfig.typecheck.json b/packages/calendar-date/tsconfig.typecheck.json new file mode 100644 index 000000000..052e6c060 --- /dev/null +++ b/packages/calendar-date/tsconfig.typecheck.json @@ -0,0 +1,12 @@ +{ + // Typecheck-only project: same settings as the build project, but tests are + // included so fixtures cannot silently drift from the types they exercise. + // Emit stays off here; `tsconfig.json` remains the source of truth for dist. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "src" + }, + "include": ["src"], + "exclude": [] +} diff --git a/packages/core/package.json b/packages/core/package.json index b21597338..f44a8bcad 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ }, "sideEffects": false, "devDependencies": { + "@pretable-internal/calendar-date": "workspace:*", "@pretable-internal/grid-core": "workspace:*", "@pretable-internal/row-model": "workspace:*" }, diff --git a/packages/core/src/__tests__/calendar-date.test.ts b/packages/core/src/__tests__/calendar-date.test.ts new file mode 100644 index 000000000..f30d3959b --- /dev/null +++ b/packages/core/src/__tests__/calendar-date.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; + +import * as core from "../index"; +import { isValidDateValue } from "../index"; + +type CorePublicApi = typeof import("../index"); +type PrivateCalendarDateName = + | "MIN_DATE_VALUE" + | "MAX_DATE_VALUE" + | "parseDateValue" + | "dateValueToUtcMs" + | "compareDateValues" + | "addDateValueDays" + | "addDateValueMonths"; +type AssertNever = T; +type _PrivateCalendarDateExportsStayPrivate = AssertNever< + Extract +>; + +void (undefined as unknown as _PrivateCalendarDateExportsStayPrivate); + +describe("calendar-date public API", () => { + test("exports a validator that narrows unknown values to strings", () => { + const value: unknown = "2024-02-29"; + + if (!isValidDateValue(value)) { + throw new Error("expected a valid date value"); + } + + const narrowed: string = value; + expect(narrowed).toBe("2024-02-29"); + expect(isValidDateValue("2026-02-30")).toBe(false); + }); + + test.each([ + "MIN_DATE_VALUE", + "MAX_DATE_VALUE", + "parseDateValue", + "dateValueToUtcMs", + "compareDateValues", + "addDateValueDays", + "addDateValueMonths", + ] as const)("does not expose private helper %s at runtime", (name) => { + expect(core).not.toHaveProperty(name); + }); +}); diff --git a/packages/core/src/public_api.ts b/packages/core/src/public_api.ts index 68069be17..377af1a8b 100644 --- a/packages/core/src/public_api.ts +++ b/packages/core/src/public_api.ts @@ -11,6 +11,7 @@ export { createColumnHelper } from "./create-column-helper"; export { createLocalRowModel } from "./create-local-row-model"; export { numberFormats } from "./number-formats"; export type { PretableCurrencyFormatOptions } from "./number-formats"; +export { isValidDateValue } from "@pretable-internal/calendar-date"; export { PretableDisposedModelError, PretableInvalidGroupKeyError, diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 3c4058842..c41c1672f 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -9,7 +9,11 @@ export default defineConfig({ js: format === "cjs" ? ".cjs" : ".mjs", }), // Public bundles own one runtime copy of each private engine. - noExternal: ["@pretable-internal/grid-core", "@pretable-internal/row-model"], + noExternal: [ + "@pretable-internal/calendar-date", + "@pretable-internal/grid-core", + "@pretable-internal/row-model", + ], treeshake: true, tsconfig: "tsconfig.build.json", }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 837d94dd3..7c6635d89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,8 +269,17 @@ importers: specifier: workspace:* version: link:../scenario-data + packages/calendar-date: + devDependencies: + fast-check: + specifier: ^4.9.0 + version: 4.9.0 + packages/core: devDependencies: + '@pretable-internal/calendar-date': + specifier: workspace:* + version: link:../calendar-date '@pretable-internal/grid-core': specifier: workspace:* version: link:../grid-core From 386dafd7d4de562dbae2d1e07a1e301ee7b1448f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 17:17:34 -0700 Subject: [PATCH 03/16] perf(core): avoid date validation allocations --- .../src/__tests__/calendar-date.test.ts | 41 ++++++++++++++-- packages/calendar-date/src/calendar-date.ts | 47 +++++++++++++++++-- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/calendar-date/src/__tests__/calendar-date.test.ts b/packages/calendar-date/src/__tests__/calendar-date.test.ts index cabbb48a2..fdbbdf34c 100644 --- a/packages/calendar-date/src/__tests__/calendar-date.test.ts +++ b/packages/calendar-date/src/__tests__/calendar-date.test.ts @@ -16,6 +16,7 @@ import { const MS_PER_DAY = 86_400_000; const DAYS_PER_GREGORIAN_CYCLE = 146_097; +const FAST_CHECK_SEED = 0x5eed_2026; const isLeapYear = (year: number) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); @@ -134,6 +135,36 @@ describe("calendar-date constants and validation", () => { expect(source).not.toContain("Date.parse"); }); + test("validates on a boolean-only path without parser allocations", () => { + const source = readFileSync( + new URL("../calendar-date.ts", import.meta.url), + "utf8", + ); + const validatorStart = source.indexOf("export function isValidDateValue"); + const validatorEnd = source.indexOf( + "export function dateValueToUtcMs", + validatorStart, + ); + const validatorSource = source.slice(validatorStart, validatorEnd); + const originalExec = RegExp.prototype.exec; + let regexExecutions = 0; + + RegExp.prototype.exec = function (value: string) { + regexExecutions += 1; + return originalExec.call(this, value); + }; + + try { + expect(isValidDateValue("2024-02-29")).toBe(true); + expect(isValidDateValue("2026-02-30")).toBe(false); + } finally { + RegExp.prototype.exec = originalExec; + } + + expect(regexExecutions).toBe(0); + expect(validatorSource).not.toContain("parseDateValue("); + }); + test("round-trips parsed dates across the supported domain", () => { fc.assert( fc.property(validDateValueArbitrary, (value) => { @@ -143,7 +174,7 @@ describe("calendar-date constants and validation", () => { value, ); }), - { numRuns: 500 }, + { numRuns: 500, seed: FAST_CHECK_SEED }, ); }); @@ -164,7 +195,7 @@ describe("calendar-date constants and validation", () => { expect(isValidDateValue(value)).toBe(expected); }, ), - { numRuns: 1_000 }, + { numRuns: 1_000, seed: FAST_CHECK_SEED }, ); }); }); @@ -216,7 +247,7 @@ describe("calendar-date conversion and comparison", () => { } }, ), - { numRuns: 500 }, + { numRuns: 500, seed: FAST_CHECK_SEED }, ); }); }); @@ -268,7 +299,7 @@ describe("calendar-date arithmetic", () => { expect(dateValueToUtcMs(result)).toBe(expectedMs); }, ), - { numRuns: 500 }, + { numRuns: 500, seed: FAST_CHECK_SEED }, ); }); @@ -298,7 +329,7 @@ describe("calendar-date arithmetic", () => { expect(result).toBe(expected); }, ), - { numRuns: 500 }, + { numRuns: 500, seed: FAST_CHECK_SEED }, ); }); }); diff --git a/packages/calendar-date/src/calendar-date.ts b/packages/calendar-date/src/calendar-date.ts index 23f5aeaea..4aedea1d8 100644 --- a/packages/calendar-date/src/calendar-date.ts +++ b/packages/calendar-date/src/calendar-date.ts @@ -13,6 +13,9 @@ const DAYS_PER_GREGORIAN_CYCLE = 146_097; const MAX_YEAR = 9_999; const MONTHS_PER_YEAR = 12; const MAX_MONTH_INDEX = MAX_YEAR * MONTHS_PER_YEAR + 11; +const ASCII_ZERO = 48; +const ASCII_NINE = 57; +const ASCII_HYPHEN = 45; const isLeapYear = (year: number) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); @@ -23,6 +26,39 @@ const getDaysInMonth = (year: number, month: number) => { return 31; }; +const isAsciiDigitAt = (value: string, index: number) => { + const code = value.charCodeAt(index); + return code >= ASCII_ZERO && code <= ASCII_NINE; +}; + +const hasDateValueShape = (value: string) => + value.length === 10 && + isAsciiDigitAt(value, 0) && + isAsciiDigitAt(value, 1) && + isAsciiDigitAt(value, 2) && + isAsciiDigitAt(value, 3) && + value.charCodeAt(4) === ASCII_HYPHEN && + isAsciiDigitAt(value, 5) && + isAsciiDigitAt(value, 6) && + value.charCodeAt(7) === ASCII_HYPHEN && + isAsciiDigitAt(value, 8) && + isAsciiDigitAt(value, 9); + +const readTwoDigits = (value: string, offset: number) => + (value.charCodeAt(offset) - ASCII_ZERO) * 10 + + value.charCodeAt(offset + 1) - + ASCII_ZERO; + +const readFourDigits = (value: string) => + (value.charCodeAt(0) - ASCII_ZERO) * 1_000 + + (value.charCodeAt(1) - ASCII_ZERO) * 100 + + (value.charCodeAt(2) - ASCII_ZERO) * 10 + + value.charCodeAt(3) - + ASCII_ZERO; + +const hasValidDateParts = (year: number, month: number, day: number) => + month >= 1 && month <= 12 && day >= 1 && day <= getDaysInMonth(year, month); + const formatDateValue = (year: number, month: number, day: number) => `${year.toString().padStart(4, "0")}-${month .toString() @@ -38,15 +74,20 @@ export function parseDateValue(value: unknown): CalendarDateParts | null { const month = Number(match[2]); const day = Number(match[3]); - if (month < 1 || month > 12) return null; - if (day < 1 || day > getDaysInMonth(year, month)) return null; + if (!hasValidDateParts(year, month, day)) return null; return { year, month, day }; } /** @public */ export function isValidDateValue(value: unknown): value is string { - return parseDateValue(value) !== null; + if (typeof value !== "string" || !hasDateValueShape(value)) return false; + + const year = readFourDigits(value); + const month = readTwoDigits(value, 5); + const day = readTwoDigits(value, 8); + + return hasValidDateParts(year, month, day); } export function dateValueToUtcMs(value: string): number { From 78850724989ee9de7198f8dbe8e72d1a5730f032 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 17:39:55 -0700 Subject: [PATCH 04/16] feat(core): enforce canonical date queries --- packages/row-model/package.json | 1 + .../src/__tests__/compiled-query.test.ts | 326 +++++++++++--- .../src/__tests__/distinct-values.test.ts | 22 +- .../external-filter-authority.test.ts | 32 ++ .../src/__tests__/flat-query.test.ts | 33 ++ .../row-model/src/__tests__/grouping.test.ts | 60 ++- .../src/__tests__/transitions.test.ts | 82 ++++ .../row-model/src/__tests__/types.test.ts | 11 +- packages/row-model/src/column-types.ts | 32 +- packages/row-model/src/compiled-query.ts | 118 ++--- packages/row-model/src/distinct-values.ts | 44 +- packages/row-model/src/errors.ts | 10 +- packages/row-model/src/types.ts | 2 +- pnpm-lock.yaml | 3 + type-tests/core/columns.types.ts | 27 +- type-tests/core/local-row-model.types.ts | 2 +- type-tests/core/query-and-aggregate.types.ts | 22 +- type-tests/performance/columns-100.ts | 82 ++-- type-tests/performance/columns-500.ts | 402 +++++++++--------- 19 files changed, 820 insertions(+), 491 deletions(-) diff --git a/packages/row-model/package.json b/packages/row-model/package.json index ce78a63af..f62ed23f3 100644 --- a/packages/row-model/package.json +++ b/packages/row-model/package.json @@ -28,6 +28,7 @@ "typecheck": "tsc -b --pretty false && tsc -p tsconfig.typecheck.json --noEmit --pretty false" }, "devDependencies": { + "@pretable-internal/calendar-date": "workspace:*", "fast-check": "^4.9.0" } } diff --git a/packages/row-model/src/__tests__/compiled-query.test.ts b/packages/row-model/src/__tests__/compiled-query.test.ts index 929cff79c..022f6a4b2 100644 --- a/packages/row-model/src/__tests__/compiled-query.test.ts +++ b/packages/row-model/src/__tests__/compiled-query.test.ts @@ -294,6 +294,120 @@ describe("compileQuery", () => { ).toBeGreaterThan(0); }); + test.each([ + ["asc", "first", [2, 1, 6, 4, 5, 3]], + ["asc", "last", [2, 1, 6, 4, 5, 3]], + ["desc", "first", [1, 2, 6, 4, 5, 3]], + ["desc", "last", [1, 2, 6, 4, 5, 3]], + ] as const)( + "orders valid calendar dates first for %s/nulls-%s and lets terminal values fall through", + (direction, nulls, expected) => { + interface DatedRow { + id: number; + asOf: string | null; + tie: number; + } + const column = createColumnHelper(); + const columns = [ + column.accessor("asOf", { type: "date" }), + column.accessor("tie", { type: "number" }), + ] as const; + const plan = compileQuery({ + derivations: columns, + query: { + filters: [], + rowGroups: [], + sort: [ + { columnId: "asOf", direction, nulls }, + { columnId: "tie", direction: "asc" }, + ], + }, + }); + const rows = [ + { id: 1, asOf: "2026-08-06", tie: 0 }, + { id: 2, asOf: "2025-12-31", tie: 0 }, + { id: 3, asOf: "2026-02-30", tie: 2 }, + { id: 4, asOf: null, tie: 1 }, + { id: 5, asOf: { date: "2026-08-05" }, tie: 1 }, + { id: 6, asOf: undefined, tie: 0 }, + ]; + const metadata = rows.map((row, sourceOrder) => + plan.evaluate({ + rowId: row.id, + sourceOrder, + row: row as never, + }), + ); + + expect(metadata.sort(plan.compareRows).map((row) => row.rowId)).toEqual( + expected, + ); + }, + ); + + test.each(["asc", "desc"] as const)( + "uses the same terminal calendar-date rank for sibling groups in %s order", + (direction) => { + const column = createColumnHelper<{ id: number; asOf: string | null }>(); + const columns = [column.accessor("asOf", { type: "date" })] as const; + const plan = compileQuery({ + derivations: columns, + query: { + filters: [], + sort: [], + rowGroups: [{ columnId: "asOf", direction, nulls: "first" }], + }, + }); + const evaluate = (id: number, asOf: unknown) => + plan.evaluate({ + rowId: id, + sourceOrder: id, + row: { id, asOf } as never, + }).groupPath[0]; + const early = evaluate(1, "2025-12-31"); + const late = evaluate(2, "2026-08-06"); + const invalid = evaluate(3, "2026-02-30"); + const missing = evaluate(4, null); + + expect(plan.compareGroupKeys(0, late, invalid)).toBeLessThan(0); + expect(plan.compareGroupKeys(0, invalid, missing)).toBe(0); + expect(Math.sign(plan.compareGroupKeys(0, early, late))).toBe( + direction === "asc" ? -1 : 1, + ); + }, + ); + + test("keeps a custom date comparator authoritative over the default terminal rank", () => { + const column = createColumnHelper<{ id: number; asOf: string }>(); + const columns = [ + column.accessor("asOf", { + type: "date", + compare: (left, right) => + left === "invalid" ? -1 : right === "invalid" ? 1 : 0, + }), + ] as const; + const plan = compileQuery({ + derivations: columns, + query: { + filters: [], + rowGroups: [], + sort: [{ columnId: "asOf", direction: "asc" }], + }, + }); + const invalid = plan.evaluate({ + rowId: 1, + sourceOrder: 0, + row: { id: 1, asOf: "invalid" }, + }); + const valid = plan.evaluate({ + rowId: 2, + sourceOrder: 1, + row: { id: 2, asOf: "2026-08-06" }, + }); + + expect(plan.compareRows(invalid, valid)).toBeLessThan(0); + }); + test("applies typed filters and emits both all and filtered aggregate leaves", () => { const { columns } = setup(); const plan = compileQuery({ @@ -489,64 +603,85 @@ describe("compileQuery", () => { expect(Object.isFrozen(label.aggregate)).toBe(true); }); - test("detaches Date operands from input and exposed snapshots", () => { - interface DatedRow { - id: number; - asOf: Date; - } - const column = createColumnHelper(); - const columns = [column.accessor("asOf", { type: "date" })] as const; - const operand = new Date("2026-08-06T00:00:00Z"); - const query = { - filters: [{ columnId: "asOf", operator: "on", value: operand }], - rowGroups: [], - sort: [], - } as const satisfies PretableQueryFor; - const plan = compileQuery({ derivations: columns, query }); - const firstPublicQuery = plan.query; - const exposedFilter = firstPublicQuery.filters[0]; - if (!("value" in exposedFilter)) throw new Error("missing date operand"); - const exposed = exposedFilter.value as Date; + test.each([ + ["on", "2026-08-06", "2026-08-06", true], + ["on", "2026-08-06", "2026-08-07", false], + ["before", "2026-08-06", "2026-08-05", true], + ["after", "2026-08-06", "2026-08-07", true], + ["dateBetween", ["2026-08-01", "2026-08-31"], "2026-08-06", true], + ["dateBetween", ["2026-08-31", "2026-08-01"], "2026-08-06", true], + ] as const)( + "evaluates canonical calendar-date operator %s without coercion", + (operator, value, asOf, expected) => { + interface DatedRow { + id: number; + asOf: string | null; + } + const column = createColumnHelper(); + const columns = [column.accessor("asOf", { type: "date" })] as const; + const plan = compileQuery({ + derivations: columns, + query: { + filters: [{ columnId: "asOf", operator, value }], + rowGroups: [], + sort: [], + } as never, + }); - operand.setUTCDate(7); - exposed.setUTCDate(8); + expect( + plan.evaluate({ rowId: 1, sourceOrder: 0, row: { id: 1, asOf } }) + .filterPasses, + ).toBe(expected); + }, + ); + + test.each([ + ["isEmpty", null, true], + ["isEmpty", "2026-08-06", false], + ["isNotEmpty", null, false], + ["isNotEmpty", "2026-08-06", true], + ] as const)("evaluates calendar-date operator %s", (operator, asOf, expected) => { + const column = createColumnHelper<{ id: number; asOf: string | null }>(); + const columns = [column.accessor("asOf", { type: "date" })] as const; + const plan = compileQuery({ + derivations: columns, + query: { + filters: [{ columnId: "asOf", operator }], + rowGroups: [], + sort: [], + }, + }); expect( - plan.evaluate({ - rowId: 1, - sourceOrder: 0, - row: { id: 1, asOf: new Date("2026-08-06T18:00:00Z") }, - }).filterPasses, - ).toBe(true); - expect(Object.prototype.toString.call(exposed)).toBe("[object Date]"); - expect(exposed.constructor).toBe(Date); - expect(exposed).not.toBe(operand); - expect(exposed.getUTCDate()).toBe(8); - const nextPublicFilter = plan.query.filters[0]; - if (!("value" in nextPublicFilter)) throw new Error("missing date operand"); - expect(nextPublicFilter.value).toBeInstanceOf(Date); - expect((nextPublicFilter.value as Date).getUTCDate()).toBe(6); - expect(plan.query).not.toBe(firstPublicQuery); + plan.evaluate({ rowId: 1, sourceOrder: 0, row: { id: 1, asOf } }) + .filterPasses, + ).toBe(expected); }); test.each([ - ["spoof", Object.create(Date.prototype)], - ["proxy", new Proxy(new Date("2026-08-06T00:00:00Z"), {})], + ["Date", "on", new Date("2026-08-06T00:00:00Z")], + ["number", "on", 0], + ["array", "on", ["2026-08-06"]], + ["object", "on", { date: "2026-08-06" }], + ["short range", "dateBetween", ["2026-08-06"]], + ["long range", "dateBetween", ["2026-08-01", "2026-08-06", "2026-08-31"]], + ["non-array range", "dateBetween", "2026-08-06"], + ["non-string range member", "dateBetween", ["2026-08-01", 0]], ])( - "wraps invalid Date brand reads as structured validation: %s", - (_label, value) => { + "rejects a shape-invalid calendar-date operand with structured context: %s", + (_label, operator, value) => { interface DatedRow { id: number; - asOf: Date; + asOf: string | null; } const column = createColumnHelper(); const columns = [column.accessor("asOf", { type: "date" })] as const; let caught: unknown; try { - compileQuery({ + compileQuery({ derivations: columns, query: { - filters: [{ columnId: "asOf", operator: "on", value }], + filters: [{ columnId: "asOf", operator, value }], rowGroups: [], sort: [], }, @@ -564,6 +699,86 @@ describe("compileQuery", () => { }, ); + test("retains semantic-invalid date strings as controlled state but zero-matches", () => { + interface DatedRow { + id: number; + asOf: string | null; + } + const column = createColumnHelper(); + const columns = [column.accessor("asOf", { type: "date" })] as const; + const query = { + filters: [{ columnId: "asOf", operator: "on", value: "2026-02-30" }], + rowGroups: [], + sort: [], + } as const satisfies PretableQueryFor; + const plan = compileQuery({ derivations: columns, query }); + + expect( + plan.evaluate({ + rowId: 1, + sourceOrder: 0, + row: { id: 1, asOf: "2026-02-28" }, + }).filterPasses, + ).toBe(false); + expect(plan.query.filters).toEqual(query.filters); + }); + + test.each([ + [["2026-02-30", "2026-08-31"]], + [["2026-08-01", "2026-13-01"]], + ] as const)("zero-matches when either dateBetween bound is invalid", (value) => { + const column = createColumnHelper<{ id: number; asOf: string }>(); + const columns = [column.accessor("asOf", { type: "date" })] as const; + const plan = compileQuery({ + derivations: columns, + query: { + filters: [{ columnId: "asOf", operator: "dateBetween", value }], + rowGroups: [], + sort: [], + }, + }); + + expect( + plan.evaluate({ + rowId: 1, + sourceOrder: 0, + row: { id: 1, asOf: "2026-08-06" }, + }).filterPasses, + ).toBe(false); + }); + + test.each([ + null, + undefined, + "", + " ", + "2026-02-30", + "2026-08-06T00:00:00Z", + 0, + new Date("2026-08-06T00:00:00Z"), + [], + {}, + ])("date comparisons reject a non-date cell value %#", (asOf) => { + const column = createColumnHelper<{ id: number; asOf: string | null }>(); + const columns = [column.accessor("asOf", { type: "date" })] as const; + const plan = compileQuery({ + derivations: columns, + query: { + filters: [{ columnId: "asOf", operator: "on", value: "2026-08-06" }], + rowGroups: [], + sort: [], + }, + }); + + expect( + plan.evaluate({ + rowId: 1, + sourceOrder: 0, + row: { id: 1, asOf } as never, + }).filterPasses, + ).toBe(false); + }); + test("treats conjunctive filter order and equivalent aggregator wrappers semantically", () => { const { columns } = setup(); const query = queryFor({ @@ -594,38 +809,11 @@ describe("compileQuery", () => { expect(equivalent).toBe(first); }); - test("invalidates semantic reuse when an active Date operand mutates", () => { - interface DatedRow { - id: number; - asOf: Date; - } - const column = createColumnHelper(); - const columns = [column.accessor("asOf", { type: "date" })] as const; - const operand = new Date("2026-08-06T00:00:00Z"); - const query = { - filters: [{ columnId: "asOf", operator: "on", value: operand }], - rowGroups: [], - sort: [], - } as const satisfies PretableQueryFor; - const first = compileQuery({ derivations: columns, query }); - - operand.setUTCDate(7); - - expect( - compileQuery({ - derivations: columns, - query, - previous: first, - }), - ).not.toBe(first); - }); - test.each([ ["number string", "number", "gte", "2"], ["number NaN", "number", "gte", Number.NaN], ["number range member", "number", "between", [1, "2"]], ["text number", "text", "contains", 2], - ["date loose string", "date", "on", "08/06/2026"], ["enum non-string member", "enum", "isAnyOf", [1]], ["boolean number member", "boolean", "isAnyOf", [1]], ])( diff --git a/packages/row-model/src/__tests__/distinct-values.test.ts b/packages/row-model/src/__tests__/distinct-values.test.ts index bdba28bbe..c6e8888ef 100644 --- a/packages/row-model/src/__tests__/distinct-values.test.ts +++ b/packages/row-model/src/__tests__/distinct-values.test.ts @@ -603,10 +603,10 @@ describe("bounded distinct-value dictionaries", () => { ]); }); - test("returns detached Date values that cannot mutate a retained dictionary", async () => { + test("returns canonical calendar-date strings from retained dictionaries", async () => { interface DatedRow { id: number; - when: Date; + when: string | null; } const datedHelper = createColumnHelper(); const datedColumns = [ @@ -615,8 +615,10 @@ describe("bounded distinct-value dictionaries", () => { const scheduler = new ManualScheduler(); const model = createLocalRowModel({ rows: [ - { id: 1, when: new Date("2026-01-01T00:00:00.000Z") }, - { id: 2, when: new Date("2026-01-02T00:00:00.000Z") }, + { id: 1, when: "2026-01-01" }, + { id: 2, when: "2026-01-02" }, + { id: 3, when: "2026-01-01" }, + { id: 4, when: null }, ], columns: datedColumns, transitionScheduler: scheduler, @@ -627,16 +629,14 @@ describe("bounded distinct-value dictionaries", () => { const first = model.distinctValues("when", { limit: 10 }); scheduler.flushAll(); const firstResult = await first.finished; - firstResult.values[0]!.value.setTime(0); const secondResult = await model.distinctValues("when", { limit: 10 }) .finished; - expect(secondResult.values.map(({ value }) => value.toISOString())).toEqual( - ["2026-01-01T00:00:00.000Z", "2026-01-02T00:00:00.000Z"], - ); - expect(secondResult.values[0]!.value).not.toBe( - firstResult.values[0]!.value, - ); + expect(firstResult.values).toEqual([ + { value: "2026-01-01", count: 2 }, + { value: "2026-01-02", count: 1 }, + ]); + expect(secondResult.values).toEqual(firstResult.values); }); test("shares an in-progress dictionary across independent projections and cancellation", async () => { diff --git a/packages/row-model/src/__tests__/external-filter-authority.test.ts b/packages/row-model/src/__tests__/external-filter-authority.test.ts index 6a14e59a7..6d7b66220 100644 --- a/packages/row-model/src/__tests__/external-filter-authority.test.ts +++ b/packages/row-model/src/__tests__/external-filter-authority.test.ts @@ -67,6 +67,38 @@ function visibleRowIds(model: { } describe("external filter authority", () => { + test("publishes canonical date filters and sorts while preserving upstream membership and order", () => { + interface DatedRow { + id: string; + asOf: string | null; + } + const dated = createColumnHelper(); + const datedColumns = [ + dated.accessor("asOf", { type: "date" }), + ] as const; + const query = { + filters: [ + { columnId: "asOf", operator: "after", value: "2026-01-01" }, + ], + sort: [{ columnId: "asOf", direction: "asc", nulls: "first" }], + rowGroups: [], + } as const satisfies PretableQueryFor; + const model = createLocalRowModel({ + rows: [ + { id: "invalid", asOf: "2026-02-30" }, + { id: "early", asOf: "2025-12-31" }, + { id: "late", asOf: "2026-08-06" }, + ], + columns: datedColumns, + query, + ɵfilterAuthority: "external", + ɵsortAuthority: "external", + }); + + expect(visibleRowIds(model as never)).toEqual(["invalid", "early", "late"]); + expect(model.getState().snapshot.query).toEqual(query); + }); + test("suppresses the engine's application of query.filters", () => { const model = createLocalRowModel({ rows, diff --git a/packages/row-model/src/__tests__/flat-query.test.ts b/packages/row-model/src/__tests__/flat-query.test.ts index a33f9aa2e..3f52a55d9 100644 --- a/packages/row-model/src/__tests__/flat-query.test.ts +++ b/packages/row-model/src/__tests__/flat-query.test.ts @@ -112,6 +112,39 @@ describe("incremental flat queries", () => { ).toEqual([1, 3, 2]); }); + test("filters and orders calendar dates without admitting invalid cell values", async () => { + interface DatedRow { + id: number; + asOf: string | null; + } + const dated = createColumnHelper(); + const datedColumns = [ + dated.accessor("asOf", { type: "date" }), + ] as const; + const model = createLocalRowModel({ + rows: [ + { id: 1, asOf: "2026-08-06" }, + { id: 2, asOf: "2025-12-31" }, + { id: 3, asOf: "2026-02-30" }, + { id: 4, asOf: null }, + ], + columns: datedColumns, + }); + + await model.setQuery({ + filters: [{ columnId: "asOf", operator: "after", value: "2026-01-01" }], + sort: [{ columnId: "asOf", direction: "desc", nulls: "first" }], + rowGroups: [], + }).finished; + + expect( + model + .getState() + .snapshot.range(0, 10) + .flatMap((row) => (row.kind === "data" ? [row.rowId] : [])), + ).toEqual([1]); + }); + test("display-only changes evaluate active dependencies once without moving rank", () => { const score = vi.fn((row: Row) => row.score); const compare = vi.fn((left: number, right: number) => left - right); diff --git a/packages/row-model/src/__tests__/grouping.test.ts b/packages/row-model/src/__tests__/grouping.test.ts index 42b5bc602..6e7bc0a06 100644 --- a/packages/row-model/src/__tests__/grouping.test.ts +++ b/packages/row-model/src/__tests__/grouping.test.ts @@ -373,7 +373,7 @@ describe("incremental grouped row model", () => { ); }); - test("keeps typed primitive and Date group identities collision-proof", () => { + test("keeps typed primitive group identities collision-proof", () => { interface KeyRow { id: number; key: unknown; @@ -382,9 +382,9 @@ describe("incremental grouped row model", () => { const keyColumns = [ keyHelper.accessor( "key", - (row): string | number | bigint | boolean | Date | null | undefined => + (row): string | number | bigint | boolean | null | undefined => row.key as - string | number | bigint | boolean | Date | null | undefined, + string | number | bigint | boolean | null | undefined, { type: "number", compare: (left, right) => String(left).localeCompare(String(right)), @@ -397,7 +397,6 @@ describe("incremental grouped row model", () => { { id: 2, key: 1 }, { id: 3, key: Number.NaN }, { id: 4, key: true }, - { id: 5, key: new Date(0) }, { id: 6, key: null }, { id: 7, key: undefined }, { id: 8, key: -0 }, @@ -411,12 +410,11 @@ describe("incremental grouped row model", () => { }); const groups = model .getState() - // Wide enough for the groups AND their now-expanded children: 11 groups - // over 12 rows is 23 visible, which a 20-row window silently truncated. + // Wide enough for groups and their expanded children. .snapshot.range(0, 100) .filter((row) => row.kind === "group"); - expect(groups).toHaveLength(11); - expect(new Set(groups.map((row) => row.groupId)).size).toBe(11); + expect(groups).toHaveLength(10); + expect(new Set(groups.map((row) => row.groupId)).size).toBe(10); expect( groups.find((row) => row.groupId.endsWith("key=~"))?.childCount, ).toBe(2); @@ -428,7 +426,6 @@ describe("incremental grouped row model", () => { expect.stringMatching(/key=n:Infinity$/), expect.stringMatching(/key=n:-Infinity$/), expect.stringMatching(/key=i:1$/), - expect.stringMatching(/key=d:0$/), ]), ); }); @@ -471,7 +468,7 @@ describe("incremental grouped row model", () => { }, ); - test("recognizes genuine Dates without accepting Date proxies or spoofs", () => { + test("rejects every object group key without triggering proxy traps", () => { const trap = new Error("brand trap"); const hostile = new Proxy( {}, @@ -484,13 +481,54 @@ describe("incremental grouped row model", () => { const dateProxy = new Proxy(new Date(0), {}); const dateSpoof = Object.create(Date.prototype); - expect(isPretableGroupKey(new Date(0))).toBe(true); + expect(isPretableGroupKey(new Date(0))).toBe(false); expect(isPretableGroupKey(dateProxy)).toBe(false); expect(isPretableGroupKey(dateSpoof)).toBe(false); + expect(isPretableGroupKey({})).toBe(false); + expect(isPretableGroupKey([])).toBe(false); expect(() => isPretableGroupKey(hostile)).not.toThrow(); expect(isPretableGroupKey(hostile)).toBe(false); }); + test.each([ + ["asc", ["2025-12-31", "2026-08-06", "2026-02-30", null]], + ["desc", ["2026-08-06", "2025-12-31", "2026-02-30", null]], + ] as const)( + "orders valid calendar-date sibling groups first in %s order", + (direction, expected) => { + interface DatedGroupRow { + id: number; + asOf: string | null; + } + const dated = createColumnHelper(); + const datedColumns = [ + dated.accessor("asOf", { type: "date" }), + ] as const; + const model = createLocalRowModel({ + rows: [ + { id: 1, asOf: "2026-02-30" }, + { id: 2, asOf: "2026-08-06" }, + { id: 3, asOf: null }, + { id: 4, asOf: "2025-12-31" }, + ], + columns: datedColumns, + query: { + filters: [], + sort: [], + rowGroups: [{ columnId: "asOf", direction, nulls: "first" }], + }, + initialExpansion: { kind: "collapsed" }, + }); + + expect( + model + .getState() + .snapshot.range(0, 10) + .flatMap((row) => (row.kind === "group" ? [row.value] : [])), + ).toEqual(expected); + }, + ); + test.each([ ["construction", "set-rows"], ["transaction", "apply-transaction"], diff --git a/packages/row-model/src/__tests__/transitions.test.ts b/packages/row-model/src/__tests__/transitions.test.ts index 6fbc133d7..265855ceb 100644 --- a/packages/row-model/src/__tests__/transitions.test.ts +++ b/packages/row-model/src/__tests__/transitions.test.ts @@ -17,6 +17,8 @@ import { getLocalRowModelActiveTransitionCandidateForTesting, getLocalRowModelRevisionCauseForTesting, } from "../create-local-row-model"; +import { createInstrumentedLocalRowModel } from "../diagnostics"; +import { getDistinctValueDiagnosticsForTesting } from "../distinct-values"; interface Row { id: number; @@ -122,6 +124,86 @@ function createModel(options: { } describe("cooperative query and derivation transitions", () => { + test.each([ + ["text", "date"], + ["date", "text"], + ] as const)( + "invalidates %s -> %s semantics once without discarding an unrelated distinct index", + async (initialType, nextType) => { + interface DatedRow { + id: number; + asOf: string | null; + label: string; + } + const dated = createColumnHelper(); + const datedColumns = [ + dated.accessor("asOf", { type: initialType }), + dated.accessor("label", { type: "text" }), + ] as const; + const scheduler = new ManualScheduler(); + const instrumented = createInstrumentedLocalRowModel({ + rows: [ + { id: 1, asOf: "2026-08-06", label: "one" }, + { id: 2, asOf: "2025-12-31", label: "two" }, + { id: 3, asOf: null, label: "three" }, + ], + columns: datedColumns, + query: { + filters: [{ columnId: "asOf", operator: "isNotEmpty" }], + sort: [{ columnId: "asOf", direction: "desc" }], + rowGroups: [{ columnId: "asOf", direction: "asc" }], + }, + initialExpansion: { kind: "expanded" }, + transitionScheduler: scheduler, + transitionClock: tickingClock(), + transitionBudgetMs: 1, + transitionMaxUnitsPerSlice: 1, + }); + const { model, diagnostics } = instrumented; + const dateDistinct = model.distinctValues("asOf", { + includeBlanks: true, + limit: 10, + }); + const labelDistinct = model.distinctValues("label", { limit: 10 }); + scheduler.flushAll(); + await Promise.all([dateDistinct.finished, labelDistinct.finished]); + expect( + getDistinctValueDiagnosticsForTesting(model).retainedDictionaryCount, + ).toBe(2); + diagnostics.resetWork(); + + const transition = model.setDerivations([ + { ...datedColumns[0], type: nextType }, + datedColumns[1], + ] as never); + scheduler.flushAll(); + await expect(transition.finished).resolves.toBe(1); + + expect(model.getState()).toMatchObject({ + snapshot: { revision: 1 }, + status: { kind: "ready" }, + }); + expect(diagnostics.read().work).toMatchObject({ + rowsEvaluated: 3, + transitionRows: 3, + }); + expect( + getDistinctValueDiagnosticsForTesting(model).retainedDictionaryCount, + ).toBe(1); + const beforeUnrelatedRead = diagnostics.read().work.rowsEvaluated; + const retainedLabel = model.distinctValues("label", { limit: 10 }); + expect(retainedLabel.status).toBe("ready"); + await expect(retainedLabel.finished).resolves.toMatchObject({ + values: [ + { value: "one", count: 1 }, + { value: "three", count: 1 }, + { value: "two", count: 1 }, + ], + }); + expect(diagnostics.read().work.rowsEvaluated).toBe(beforeUnrelatedRead); + }, + ); + test("keeps the default cooperative work budget below the browser gate margin", () => { let tick = 0; let steps = 0; diff --git a/packages/row-model/src/__tests__/types.test.ts b/packages/row-model/src/__tests__/types.test.ts index 3fe84b98b..b5b1e5da8 100644 --- a/packages/row-model/src/__tests__/types.test.ts +++ b/packages/row-model/src/__tests__/types.test.ts @@ -10,6 +10,7 @@ import { type PretableColumnDefinition, type PretableDerivationTransition, type PretableDerivationsFor, + type PretableDistinctColumnIdOf, type PretableExpansionDefault, PretableDisposedModelError, type PretableGroupId, @@ -377,7 +378,15 @@ const query = { type _groupKeyContract = Expect< Equal< PretableGroupKey, - string | number | bigint | boolean | Date | null | undefined + string | number | bigint | boolean | null | undefined + > +>; +type _dateDistinctColumnContract = Expect< + Equal< + PretableDistinctColumnIdOf< + readonly [{ readonly id: "when"; readonly accessor: () => Date }] + >, + never > >; diff --git a/packages/row-model/src/column-types.ts b/packages/row-model/src/column-types.ts index 5c0eec336..51ecfd596 100644 --- a/packages/row-model/src/column-types.ts +++ b/packages/row-model/src/column-types.ts @@ -12,7 +12,7 @@ export type PretableRowId = string | number; * @public */ export type PretableGroupKey = - string | number | bigint | boolean | Date | null | undefined; + string | number | bigint | boolean | null | undefined; /** @public */ export type PretableColumnType = @@ -169,16 +169,16 @@ export interface PretableColumnDefinition< export type PretableColumnTypeFor = [TValue] extends [never] ? never : [NonNullable] extends [never] - ? Exclude + ? Exclude : NonNullable extends number ? "number" : NonNullable extends boolean ? "boolean" - : NonNullable extends Date - ? "date" - : NonNullable extends string - ? "text" | "enum" | "date" - : PretableColumnType; + : NonNullable extends string + ? | "text" + | "enum" + | ([TValue] extends [string | null] ? "date" : never) + : Exclude; /** @public */ export interface PretableColumnCallbackContext< @@ -317,20 +317,8 @@ export function createColumnHelper< id: string, accessorOrOptions: | ((row: TRow) => unknown) - | PretableColumnOptions< - TRow, - string, - unknown, - PretableColumnType, - undefined - >, - maybeOptions?: PretableColumnOptions< - TRow, - string, - unknown, - PretableColumnType, - undefined - >, + | object, + maybeOptions?: object, ) { const isFunctionAccessor = typeof accessorOrOptions === "function"; const accessor = isFunctionAccessor @@ -460,7 +448,7 @@ export type PretableFilterOperandFor< : TType extends "number" ? number : TType extends "date" - ? string | number | Date + ? string : TType extends "boolean" ? boolean : [Extract, string>] extends [never] diff --git a/packages/row-model/src/compiled-query.ts b/packages/row-model/src/compiled-query.ts index 9db3c655e..9609d1d57 100644 --- a/packages/row-model/src/compiled-query.ts +++ b/packages/row-model/src/compiled-query.ts @@ -1,3 +1,8 @@ +import { + compareDateValues, + isValidDateValue, +} from "@pretable-internal/calendar-date"; + import type { ColumnDescriptorOf, ColumnIdOf, @@ -740,19 +745,20 @@ function validateFilterOperand( return; } if (column.type === "date") { - const values = filter.operator === "dateBetween" ? value : [value]; - if ( - !Array.isArray(values) || - (filter.operator === "dateBetween" && values.length !== 2) || - values.some((entry) => Number.isNaN(toDayMs(entry))) - ) { - fail( - filter.operator === "dateBetween" - ? "date range must contain exactly two valid ISO dates, Dates, or epoch values" - : "date operand must be a valid ISO date, Date, or epoch value", - path, - column.id, - ); + if (filter.operator === "dateBetween") { + if ( + !Array.isArray(value) || + value.length !== 2 || + value.some((entry) => typeof entry !== "string") + ) { + fail( + "date range must contain exactly two strings", + path, + column.id, + ); + } + } else if (typeof value !== "string") { + fail("date operand must be a string", path, column.id); } return; } @@ -1136,7 +1142,6 @@ function filterDescriptorKey(filter: RuntimeFilter): string { } function filterValueKey(value: unknown): string { - if (value instanceof Date) return `date:${String(readDateTimestamp(value))}`; if (Array.isArray(value)) return `array:[${value.map(filterValueKey).join(",")}]`; if (typeof value === "number") { @@ -1162,56 +1167,6 @@ function booleanValue(value: unknown): boolean { return Boolean(value); } -const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; -const ISO_DATETIME_RE = - /^(\d{4}-\d{2}-\d{2})[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/i; -const GREGORIAN_400Y_MS = 146_097 * 86_400_000; - -function utcMs(year: number, month: number, day: number): number { - return year >= 0 && year < 100 - ? Date.UTC(year + 400, month, day) - GREGORIAN_400Y_MS - : Date.UTC(year, month, day); -} - -function utcDayOf(value: number): number { - const date = new Date(value); - const year = date.getUTCFullYear(); - if (Number.isNaN(date.getTime()) || year < 0 || year > 9999) - return Number.NaN; - date.setUTCHours(0, 0, 0, 0); - return date.getTime(); -} - -function isoDayMs(value: string): number { - if (!ISO_DATE_RE.test(value)) return Number.NaN; - const [year, month, day] = value.split("-").map(Number); - const result = utcMs(year, month - 1, day); - const roundTrip = new Date(result); - return roundTrip.getUTCFullYear() === year && - roundTrip.getUTCMonth() === month - 1 && - roundTrip.getUTCDate() === day - ? result - : Number.NaN; -} - -/** Deterministic UTC calendar-day policy shared with the frozen legacy oracle. */ -function toDayMs(value: unknown): number { - if (value instanceof Date) { - const timestamp = readDateTimestamp(value); - return timestamp === undefined ? Number.NaN : utcDayOf(timestamp); - } - if (typeof value === "number") return utcDayOf(value); - if (typeof value !== "string") return Number.NaN; - const trimmed = value.trim(); - const dateOnly = isoDayMs(trimmed); - if (!Number.isNaN(dateOnly)) return dateOnly; - const parts = ISO_DATETIME_RE.exec(trimmed); - if (!parts || Number.isNaN(isoDayMs(parts[1]))) return Number.NaN; - return parts[2] - ? utcDayOf(Date.parse(trimmed.replace(" ", "T"))) - : isoDayMs(parts[1]); -} - function evaluateFilter( filter: RuntimeFilter, column: RuntimeColumn, @@ -1239,23 +1194,23 @@ function evaluateFilter( return value <= operand; } case "date": { - const cell = toDayMs(value); - if (Number.isNaN(cell)) return false; + if (!isValidDateValue(value)) return false; if (filter.operator === "dateBetween") { const range = operand as readonly unknown[]; - const a = toDayMs(range[0]); - const b = toDayMs(range[1]); + const first = range[0]; + const second = range[1]; + if (!isValidDateValue(first) || !isValidDateValue(second)) return false; + const lower = compareDateValues(first, second) <= 0 ? first : second; + const upper = lower === first ? second : first; return ( - !Number.isNaN(a) && - !Number.isNaN(b) && - cell >= Math.min(a, b) && - cell <= Math.max(a, b) + compareDateValues(value, lower) >= 0 && + compareDateValues(value, upper) <= 0 ); } - const other = toDayMs(operand); - if (Number.isNaN(other)) return false; - if (filter.operator === "on") return cell === other; - return filter.operator === "before" ? cell < other : cell > other; + if (!isValidDateValue(operand)) return false; + const compared = compareDateValues(value, operand); + if (filter.operator === "on") return compared === 0; + return filter.operator === "before" ? compared < 0 : compared > 0; } case "enum": { if ((operand as readonly unknown[]).length === 0) return true; @@ -1298,6 +1253,17 @@ function compareValues( column: RuntimeColumn, ordering: RuntimeOrdering, ): number { + if (!column.compare && column.type === "date") { + const leftValid = isValidDateValue(left); + const rightValid = isValidDateValue(right); + if (leftValid || rightValid) { + if (!leftValid) return 1; + if (!rightValid) return -1; + const result = compareDateValues(left, right); + return ordering.direction === "desc" ? -result : result; + } + return 0; + } const leftNull = isNullSortValue(left); const rightNull = isNullSortValue(right); if (leftNull || rightNull) { diff --git a/packages/row-model/src/distinct-values.ts b/packages/row-model/src/distinct-values.ts index 9b56c538a..c045c690f 100644 --- a/packages/row-model/src/distinct-values.ts +++ b/packages/row-model/src/distinct-values.ts @@ -240,14 +240,6 @@ function captureQueryOptions( }); } -function dateTimestamp(value: object): number | undefined { - try { - return Date.prototype.getTime.call(value) as number; - } catch { - return undefined; - } -} - function describeValue(value: unknown): ValueDescription { if (value === null) { return { id: "null", blank: true, blankRank: 0 }; @@ -278,18 +270,8 @@ function describeValue(value: unknown): ValueDescription { if (typeof value === "boolean") { return { id: `boolean:${value ? "1" : "0"}`, blank: false, blankRank: 0 }; } - if (typeof value === "object") { - const timestamp = dateTimestamp(value); - if (timestamp !== undefined) { - return { - id: `date:${Number.isNaN(timestamp) ? "nan" : String(timestamp)}`, - blank: Number.isNaN(timestamp), - blankRank: 4, - }; - } - } throw new TypeError( - "Distinct values must be strings, numbers, bigints, booleans, Dates, null, or undefined.", + "Distinct values must be strings, numbers, bigints, booleans, null, or undefined.", ); } @@ -297,10 +279,6 @@ function snapshotDistinctValue(value: unknown): unknown { // Number keys use SameValueZero identity. Canonicalizing their representative // prevents insertion and removal history from leaking through as negative zero. if (typeof value === "number" && value === 0) return 0; - if (value !== null && typeof value === "object") { - const timestamp = dateTimestamp(value); - if (timestamp !== undefined) return new Date(timestamp); - } return value; } @@ -319,18 +297,6 @@ function defaultCompare(left: unknown, right: unknown): number { if (typeof left === "boolean" && typeof right === "boolean") { return left === right ? 0 : left ? 1 : -1; } - if ( - typeof left === "object" && - left !== null && - typeof right === "object" && - right !== null - ) { - const leftTime = dateTimestamp(left); - const rightTime = dateTimestamp(right); - if (leftTime !== undefined && rightTime !== undefined) { - return leftTime === rightTime ? 0 : leftTime < rightTime ? -1 : 1; - } - } return collator.compare(String(left), String(right)); } @@ -378,12 +344,6 @@ function semanticValueKey(value: unknown): string { return frame("a", value.map(semanticValueKey).join("")); } if (value && typeof value === "object") { - const timestamp = dateTimestamp(value); - if (timestamp !== undefined) { - return Number.isNaN(timestamp) - ? frame("dN") - : frame("d", String(timestamp)); - } const keys = Object.keys(value as Record).sort(); return frame( "o", @@ -430,6 +390,7 @@ function filterSemanticKey< return frame( "f", frame("c", runtime.columnId) + + frame("t", byId.get(runtime.columnId)?.type ?? "") + frame("i", String(identityId(byId.get(runtime.columnId)?.accessor))) + frame("p", runtime.operator) + frame("v", semanticValueKey(runtime.value)), @@ -466,6 +427,7 @@ function cacheKey( return frame( "K", frame("c", column.id) + + frame("t", column.type) + frame("p", options.population) + frame("a", String(identityId(column.accessor))) + frame("o", String(identityId(column.compare))) + diff --git a/packages/row-model/src/errors.ts b/packages/row-model/src/errors.ts index 638be5de7..59bcdb325 100644 --- a/packages/row-model/src/errors.ts +++ b/packages/row-model/src/errors.ts @@ -138,7 +138,7 @@ export class PretableInvalidGroupKeyError extends PretableRowModelError { columnId: string, readonly value: unknown, cause: unknown = new TypeError( - "Group keys must be strings, numbers, bigints, booleans, Dates, null, or undefined.", + "Group keys must be strings, numbers, bigints, booleans, null, or undefined.", ), ) { super( @@ -161,13 +161,7 @@ export function isPretableGroupKey(value: unknown): value is PretableGroupKey { ) { return true; } - if (typeof value !== "object") return false; - try { - Date.prototype.getTime.call(value); - return true; - } catch { - return false; - } + return false; } /** @public */ diff --git a/packages/row-model/src/types.ts b/packages/row-model/src/types.ts index 579307077..6a859c3ac 100644 --- a/packages/row-model/src/types.ts +++ b/packages/row-model/src/types.ts @@ -336,7 +336,7 @@ export type PretableDistinctColumnIdOf = readonly accessor: (...args: never[]) => infer TValue; } ? [TValue] extends [ - string | number | bigint | boolean | Date | null | undefined, + string | number | bigint | boolean | null | undefined, ] ? [TValue] extends [never] ? never diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c6635d89..417fabb8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -348,6 +348,9 @@ importers: packages/row-model: devDependencies: + '@pretable-internal/calendar-date': + specifier: workspace:* + version: link:../calendar-date fast-check: specifier: ^4.9.0 version: 4.9.0 diff --git a/type-tests/core/columns.types.ts b/type-tests/core/columns.types.ts index 061b3bef9..d9222f9d4 100644 --- a/type-tests/core/columns.types.ts +++ b/type-tests/core/columns.types.ts @@ -12,7 +12,7 @@ export interface Holding { symbol: string; quantity: number; active: boolean; - openedAt: Date; + openedAt: string | null; } const column = createColumnHelper(); @@ -88,6 +88,31 @@ column.accessor("symbol", { type: "text", aggregate: "sum" }); // @ts-expect-error a number-valued accessor cannot declare a text column column.accessor("quantity", { type: "text" }); +interface DatedRow { + id: number; + asOf: string | null; + instant: Date; + optionalAsOf?: string; + undefinedAsOf: string | undefined; +} +const dated = createColumnHelper(); +dated.accessor("asOf", { type: "date" }); +dated.accessor("computedAsOf", (row) => row.asOf, { type: "date" }); +// @ts-expect-error Date instances are not built-in calendar dates +dated.accessor("instant", { type: "date" }); +dated.accessor("computedInstant", (row) => row.instant, { + // @ts-expect-error computed accessors cannot escape the calendar-date domain + type: "date", +}); +dated.accessor("optionalAsOf", { + // @ts-expect-error undefined is outside the built-in calendar-date domain + type: "date", +}); +dated.accessor("computedUndefinedAsOf", (row) => row.undefinedAsOf, { + // @ts-expect-error computed accessors cannot admit undefined date values + type: "date", +}); + interface NumericEligibilityRow { nullableNumber: number | null; nullOnly: null; diff --git a/type-tests/core/local-row-model.types.ts b/type-tests/core/local-row-model.types.ts index e81a6eb36..3183ad838 100644 --- a/type-tests/core/local-row-model.types.ts +++ b/type-tests/core/local-row-model.types.ts @@ -15,7 +15,7 @@ const holdings: readonly Holding[] = [ symbol: "PRE", quantity: 10, active: true, - openedAt: new Date(0), + openedAt: "1970-01-01", }, ]; diff --git a/type-tests/core/query-and-aggregate.types.ts b/type-tests/core/query-and-aggregate.types.ts index 284857204..c5ef11ac8 100644 --- a/type-tests/core/query-and-aggregate.types.ts +++ b/type-tests/core/query-and-aggregate.types.ts @@ -17,7 +17,7 @@ const query: PretableQueryFor = { { columnId: "openedAt", operator: "dateBetween", - value: [new Date(0), new Date()], + value: ["1970-01-01", "2026-08-18"], }, ], sort: [{ columnId: "quantity", direction: "desc", nulls: "last" }], @@ -67,7 +67,7 @@ interface FilterOperandRow { unknownText: unknown; nullableText: string | null; nullableNumber: number | null; - nullableDate: Date | null; + nullableDate: string | null; nullableEnum: "draft" | "published" | null; opaqueEnum: object; nullableBoolean: boolean | null; @@ -89,7 +89,7 @@ type _ObjectTextOperand = Expect< Equal, string> >; type _NullableDateOperand = Expect< - Equal, string | number | Date> + Equal, string> >; type _NullableEnumOperand = Expect< Equal< @@ -109,12 +109,12 @@ const validFilterOperands: PretableQueryFor = { { columnId: "nullableNumber", operator: "gte", value: 1 }, { columnId: "nullableNumber", operator: "between", value: [1, 2] }, { columnId: "nullableDate", operator: "on", value: "2026-08-10" }, - { columnId: "nullableDate", operator: "before", value: 0 }, - { columnId: "nullableDate", operator: "after", value: new Date(0) }, + { columnId: "nullableDate", operator: "before", value: "2026-08-11" }, + { columnId: "nullableDate", operator: "after", value: "2026-08-09" }, { columnId: "nullableDate", operator: "dateBetween", - value: ["2026-08-01", new Date(0)], + value: ["2026-08-01", "2026-08-31"], }, { columnId: "nullableEnum", operator: "isAnyOf", value: ["draft"] }, { columnId: "opaqueEnum", operator: "isNoneOf", value: ["hidden"] }, @@ -182,11 +182,19 @@ acceptFilterOperand({ }); // @ts-expect-error nullable date columns reject null scalar operands acceptFilterOperand({ columnId: "nullableDate", operator: "on", value: null }); +// @ts-expect-error calendar-date operands reject epoch numbers +acceptFilterOperand({ columnId: "nullableDate", operator: "on", value: 0 }); +acceptFilterOperand({ + columnId: "nullableDate", + operator: "on", + // @ts-expect-error calendar-date operands reject Date instances + value: new Date(0), +}); acceptFilterOperand({ columnId: "nullableDate", operator: "dateBetween", // @ts-expect-error nullable date ranges reject null endpoints - value: [0, null], + value: ["2026-08-01", null], }); acceptFilterOperand({ columnId: "nullableEnum", diff --git a/type-tests/performance/columns-100.ts b/type-tests/performance/columns-100.ts index 5c3650f4e..9889b779d 100644 --- a/type-tests/performance/columns-100.ts +++ b/type-tests/performance/columns-100.ts @@ -13,102 +13,102 @@ interface WideRow100 { readonly col_000: number; readonly col_001: string; readonly col_002: boolean; - readonly col_003: Date; + readonly col_003: string; readonly col_004: "alpha" | "beta"; readonly col_005: number; readonly col_006: string; readonly col_007: boolean; - readonly col_008: Date; + readonly col_008: string; readonly col_009: "alpha" | "beta"; readonly col_010: number; readonly col_011: string; readonly col_012: boolean; - readonly col_013: Date; + readonly col_013: string; readonly col_014: "alpha" | "beta"; readonly col_015: number; readonly col_016: string; readonly col_017: boolean; - readonly col_018: Date; + readonly col_018: string; readonly col_019: "alpha" | "beta"; readonly col_020: number; readonly col_021: string; readonly col_022: boolean; - readonly col_023: Date; + readonly col_023: string; readonly col_024: "alpha" | "beta"; readonly col_025: number; readonly col_026: string; readonly col_027: boolean; - readonly col_028: Date; + readonly col_028: string; readonly col_029: "alpha" | "beta"; readonly col_030: number; readonly col_031: string; readonly col_032: boolean; - readonly col_033: Date; + readonly col_033: string; readonly col_034: "alpha" | "beta"; readonly col_035: number; readonly col_036: string; readonly col_037: boolean; - readonly col_038: Date; + readonly col_038: string; readonly col_039: "alpha" | "beta"; readonly col_040: number; readonly col_041: string; readonly col_042: boolean; - readonly col_043: Date; + readonly col_043: string; readonly col_044: "alpha" | "beta"; readonly col_045: number; readonly col_046: string; readonly col_047: boolean; - readonly col_048: Date; + readonly col_048: string; readonly col_049: "alpha" | "beta"; readonly col_050: number; readonly col_051: string; readonly col_052: boolean; - readonly col_053: Date; + readonly col_053: string; readonly col_054: "alpha" | "beta"; readonly col_055: number; readonly col_056: string; readonly col_057: boolean; - readonly col_058: Date; + readonly col_058: string; readonly col_059: "alpha" | "beta"; readonly col_060: number; readonly col_061: string; readonly col_062: boolean; - readonly col_063: Date; + readonly col_063: string; readonly col_064: "alpha" | "beta"; readonly col_065: number; readonly col_066: string; readonly col_067: boolean; - readonly col_068: Date; + readonly col_068: string; readonly col_069: "alpha" | "beta"; readonly col_070: number; readonly col_071: string; readonly col_072: boolean; - readonly col_073: Date; + readonly col_073: string; readonly col_074: "alpha" | "beta"; readonly col_075: number; readonly col_076: string; readonly col_077: boolean; - readonly col_078: Date; + readonly col_078: string; readonly col_079: "alpha" | "beta"; readonly col_080: number; readonly col_081: string; readonly col_082: boolean; - readonly col_083: Date; + readonly col_083: string; readonly col_084: "alpha" | "beta"; readonly col_085: number; readonly col_086: string; readonly col_087: boolean; - readonly col_088: Date; + readonly col_088: string; readonly col_089: "alpha" | "beta"; readonly col_090: number; readonly col_091: string; readonly col_092: boolean; - readonly col_093: Date; + readonly col_093: string; readonly col_094: "alpha" | "beta"; readonly col_095: number; readonly col_096: string; readonly col_097: boolean; - readonly col_098: Date; + readonly col_098: string; readonly col_099: "alpha" | "beta"; } @@ -145,7 +145,7 @@ export const columns100 = [ }), column100.accessor("derived_003", (row) => row.col_003, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_004", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_005", (row) => row.col_005, { @@ -165,7 +165,7 @@ export const columns100 = [ }), column100.accessor("col_008", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_009", (row) => row.col_009, { type: "enum", @@ -188,7 +188,7 @@ export const columns100 = [ }), column100.accessor("derived_013", (row) => row.col_013, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_014", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_015", (row) => row.col_015, { @@ -208,7 +208,7 @@ export const columns100 = [ }), column100.accessor("col_018", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_019", (row) => row.col_019, { type: "enum", @@ -231,7 +231,7 @@ export const columns100 = [ }), column100.accessor("derived_023", (row) => row.col_023, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_024", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_025", (row) => row.col_025, { @@ -251,7 +251,7 @@ export const columns100 = [ }), column100.accessor("col_028", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_029", (row) => row.col_029, { type: "enum", @@ -274,7 +274,7 @@ export const columns100 = [ }), column100.accessor("derived_033", (row) => row.col_033, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_034", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_035", (row) => row.col_035, { @@ -294,7 +294,7 @@ export const columns100 = [ }), column100.accessor("col_038", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_039", (row) => row.col_039, { type: "enum", @@ -317,7 +317,7 @@ export const columns100 = [ }), column100.accessor("derived_043", (row) => row.col_043, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_044", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_045", (row) => row.col_045, { @@ -337,7 +337,7 @@ export const columns100 = [ }), column100.accessor("col_048", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_049", (row) => row.col_049, { type: "enum", @@ -360,7 +360,7 @@ export const columns100 = [ }), column100.accessor("derived_053", (row) => row.col_053, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_054", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_055", (row) => row.col_055, { @@ -380,7 +380,7 @@ export const columns100 = [ }), column100.accessor("col_058", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_059", (row) => row.col_059, { type: "enum", @@ -403,7 +403,7 @@ export const columns100 = [ }), column100.accessor("derived_063", (row) => row.col_063, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_064", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_065", (row) => row.col_065, { @@ -423,7 +423,7 @@ export const columns100 = [ }), column100.accessor("col_068", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_069", (row) => row.col_069, { type: "enum", @@ -446,7 +446,7 @@ export const columns100 = [ }), column100.accessor("derived_073", (row) => row.col_073, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_074", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_075", (row) => row.col_075, { @@ -466,7 +466,7 @@ export const columns100 = [ }), column100.accessor("col_078", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_079", (row) => row.col_079, { type: "enum", @@ -489,7 +489,7 @@ export const columns100 = [ }), column100.accessor("derived_083", (row) => row.col_083, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_084", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_085", (row) => row.col_085, { @@ -509,7 +509,7 @@ export const columns100 = [ }), column100.accessor("col_088", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_089", (row) => row.col_089, { type: "enum", @@ -532,7 +532,7 @@ export const columns100 = [ }), column100.accessor("derived_093", (row) => row.col_093, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("col_094", { type: "enum", format: ({ value }) => value }), column100.accessor("derived_095", (row) => row.col_095, { @@ -552,7 +552,7 @@ export const columns100 = [ }), column100.accessor("col_098", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column100.accessor("derived_099", (row) => row.col_099, { type: "enum", @@ -574,7 +574,7 @@ const query100: PretableQueryFor = { { columnId: "derived_003", operator: "dateBetween", - value: [0, new Date(0)], + value: ["1970-01-01", "1970-01-02"], }, { columnId: "col_004", operator: "isAnyOf", value: ["alpha"] }, { columnId: "derived_095", operator: "gte", value: 0 }, diff --git a/type-tests/performance/columns-500.ts b/type-tests/performance/columns-500.ts index 27026ad4c..62e136131 100644 --- a/type-tests/performance/columns-500.ts +++ b/type-tests/performance/columns-500.ts @@ -13,502 +13,502 @@ interface WideRow500 { readonly col_000: number; readonly col_001: string; readonly col_002: boolean; - readonly col_003: Date; + readonly col_003: string; readonly col_004: "alpha" | "beta"; readonly col_005: number; readonly col_006: string; readonly col_007: boolean; - readonly col_008: Date; + readonly col_008: string; readonly col_009: "alpha" | "beta"; readonly col_010: number; readonly col_011: string; readonly col_012: boolean; - readonly col_013: Date; + readonly col_013: string; readonly col_014: "alpha" | "beta"; readonly col_015: number; readonly col_016: string; readonly col_017: boolean; - readonly col_018: Date; + readonly col_018: string; readonly col_019: "alpha" | "beta"; readonly col_020: number; readonly col_021: string; readonly col_022: boolean; - readonly col_023: Date; + readonly col_023: string; readonly col_024: "alpha" | "beta"; readonly col_025: number; readonly col_026: string; readonly col_027: boolean; - readonly col_028: Date; + readonly col_028: string; readonly col_029: "alpha" | "beta"; readonly col_030: number; readonly col_031: string; readonly col_032: boolean; - readonly col_033: Date; + readonly col_033: string; readonly col_034: "alpha" | "beta"; readonly col_035: number; readonly col_036: string; readonly col_037: boolean; - readonly col_038: Date; + readonly col_038: string; readonly col_039: "alpha" | "beta"; readonly col_040: number; readonly col_041: string; readonly col_042: boolean; - readonly col_043: Date; + readonly col_043: string; readonly col_044: "alpha" | "beta"; readonly col_045: number; readonly col_046: string; readonly col_047: boolean; - readonly col_048: Date; + readonly col_048: string; readonly col_049: "alpha" | "beta"; readonly col_050: number; readonly col_051: string; readonly col_052: boolean; - readonly col_053: Date; + readonly col_053: string; readonly col_054: "alpha" | "beta"; readonly col_055: number; readonly col_056: string; readonly col_057: boolean; - readonly col_058: Date; + readonly col_058: string; readonly col_059: "alpha" | "beta"; readonly col_060: number; readonly col_061: string; readonly col_062: boolean; - readonly col_063: Date; + readonly col_063: string; readonly col_064: "alpha" | "beta"; readonly col_065: number; readonly col_066: string; readonly col_067: boolean; - readonly col_068: Date; + readonly col_068: string; readonly col_069: "alpha" | "beta"; readonly col_070: number; readonly col_071: string; readonly col_072: boolean; - readonly col_073: Date; + readonly col_073: string; readonly col_074: "alpha" | "beta"; readonly col_075: number; readonly col_076: string; readonly col_077: boolean; - readonly col_078: Date; + readonly col_078: string; readonly col_079: "alpha" | "beta"; readonly col_080: number; readonly col_081: string; readonly col_082: boolean; - readonly col_083: Date; + readonly col_083: string; readonly col_084: "alpha" | "beta"; readonly col_085: number; readonly col_086: string; readonly col_087: boolean; - readonly col_088: Date; + readonly col_088: string; readonly col_089: "alpha" | "beta"; readonly col_090: number; readonly col_091: string; readonly col_092: boolean; - readonly col_093: Date; + readonly col_093: string; readonly col_094: "alpha" | "beta"; readonly col_095: number; readonly col_096: string; readonly col_097: boolean; - readonly col_098: Date; + readonly col_098: string; readonly col_099: "alpha" | "beta"; readonly col_100: number; readonly col_101: string; readonly col_102: boolean; - readonly col_103: Date; + readonly col_103: string; readonly col_104: "alpha" | "beta"; readonly col_105: number; readonly col_106: string; readonly col_107: boolean; - readonly col_108: Date; + readonly col_108: string; readonly col_109: "alpha" | "beta"; readonly col_110: number; readonly col_111: string; readonly col_112: boolean; - readonly col_113: Date; + readonly col_113: string; readonly col_114: "alpha" | "beta"; readonly col_115: number; readonly col_116: string; readonly col_117: boolean; - readonly col_118: Date; + readonly col_118: string; readonly col_119: "alpha" | "beta"; readonly col_120: number; readonly col_121: string; readonly col_122: boolean; - readonly col_123: Date; + readonly col_123: string; readonly col_124: "alpha" | "beta"; readonly col_125: number; readonly col_126: string; readonly col_127: boolean; - readonly col_128: Date; + readonly col_128: string; readonly col_129: "alpha" | "beta"; readonly col_130: number; readonly col_131: string; readonly col_132: boolean; - readonly col_133: Date; + readonly col_133: string; readonly col_134: "alpha" | "beta"; readonly col_135: number; readonly col_136: string; readonly col_137: boolean; - readonly col_138: Date; + readonly col_138: string; readonly col_139: "alpha" | "beta"; readonly col_140: number; readonly col_141: string; readonly col_142: boolean; - readonly col_143: Date; + readonly col_143: string; readonly col_144: "alpha" | "beta"; readonly col_145: number; readonly col_146: string; readonly col_147: boolean; - readonly col_148: Date; + readonly col_148: string; readonly col_149: "alpha" | "beta"; readonly col_150: number; readonly col_151: string; readonly col_152: boolean; - readonly col_153: Date; + readonly col_153: string; readonly col_154: "alpha" | "beta"; readonly col_155: number; readonly col_156: string; readonly col_157: boolean; - readonly col_158: Date; + readonly col_158: string; readonly col_159: "alpha" | "beta"; readonly col_160: number; readonly col_161: string; readonly col_162: boolean; - readonly col_163: Date; + readonly col_163: string; readonly col_164: "alpha" | "beta"; readonly col_165: number; readonly col_166: string; readonly col_167: boolean; - readonly col_168: Date; + readonly col_168: string; readonly col_169: "alpha" | "beta"; readonly col_170: number; readonly col_171: string; readonly col_172: boolean; - readonly col_173: Date; + readonly col_173: string; readonly col_174: "alpha" | "beta"; readonly col_175: number; readonly col_176: string; readonly col_177: boolean; - readonly col_178: Date; + readonly col_178: string; readonly col_179: "alpha" | "beta"; readonly col_180: number; readonly col_181: string; readonly col_182: boolean; - readonly col_183: Date; + readonly col_183: string; readonly col_184: "alpha" | "beta"; readonly col_185: number; readonly col_186: string; readonly col_187: boolean; - readonly col_188: Date; + readonly col_188: string; readonly col_189: "alpha" | "beta"; readonly col_190: number; readonly col_191: string; readonly col_192: boolean; - readonly col_193: Date; + readonly col_193: string; readonly col_194: "alpha" | "beta"; readonly col_195: number; readonly col_196: string; readonly col_197: boolean; - readonly col_198: Date; + readonly col_198: string; readonly col_199: "alpha" | "beta"; readonly col_200: number; readonly col_201: string; readonly col_202: boolean; - readonly col_203: Date; + readonly col_203: string; readonly col_204: "alpha" | "beta"; readonly col_205: number; readonly col_206: string; readonly col_207: boolean; - readonly col_208: Date; + readonly col_208: string; readonly col_209: "alpha" | "beta"; readonly col_210: number; readonly col_211: string; readonly col_212: boolean; - readonly col_213: Date; + readonly col_213: string; readonly col_214: "alpha" | "beta"; readonly col_215: number; readonly col_216: string; readonly col_217: boolean; - readonly col_218: Date; + readonly col_218: string; readonly col_219: "alpha" | "beta"; readonly col_220: number; readonly col_221: string; readonly col_222: boolean; - readonly col_223: Date; + readonly col_223: string; readonly col_224: "alpha" | "beta"; readonly col_225: number; readonly col_226: string; readonly col_227: boolean; - readonly col_228: Date; + readonly col_228: string; readonly col_229: "alpha" | "beta"; readonly col_230: number; readonly col_231: string; readonly col_232: boolean; - readonly col_233: Date; + readonly col_233: string; readonly col_234: "alpha" | "beta"; readonly col_235: number; readonly col_236: string; readonly col_237: boolean; - readonly col_238: Date; + readonly col_238: string; readonly col_239: "alpha" | "beta"; readonly col_240: number; readonly col_241: string; readonly col_242: boolean; - readonly col_243: Date; + readonly col_243: string; readonly col_244: "alpha" | "beta"; readonly col_245: number; readonly col_246: string; readonly col_247: boolean; - readonly col_248: Date; + readonly col_248: string; readonly col_249: "alpha" | "beta"; readonly col_250: number; readonly col_251: string; readonly col_252: boolean; - readonly col_253: Date; + readonly col_253: string; readonly col_254: "alpha" | "beta"; readonly col_255: number; readonly col_256: string; readonly col_257: boolean; - readonly col_258: Date; + readonly col_258: string; readonly col_259: "alpha" | "beta"; readonly col_260: number; readonly col_261: string; readonly col_262: boolean; - readonly col_263: Date; + readonly col_263: string; readonly col_264: "alpha" | "beta"; readonly col_265: number; readonly col_266: string; readonly col_267: boolean; - readonly col_268: Date; + readonly col_268: string; readonly col_269: "alpha" | "beta"; readonly col_270: number; readonly col_271: string; readonly col_272: boolean; - readonly col_273: Date; + readonly col_273: string; readonly col_274: "alpha" | "beta"; readonly col_275: number; readonly col_276: string; readonly col_277: boolean; - readonly col_278: Date; + readonly col_278: string; readonly col_279: "alpha" | "beta"; readonly col_280: number; readonly col_281: string; readonly col_282: boolean; - readonly col_283: Date; + readonly col_283: string; readonly col_284: "alpha" | "beta"; readonly col_285: number; readonly col_286: string; readonly col_287: boolean; - readonly col_288: Date; + readonly col_288: string; readonly col_289: "alpha" | "beta"; readonly col_290: number; readonly col_291: string; readonly col_292: boolean; - readonly col_293: Date; + readonly col_293: string; readonly col_294: "alpha" | "beta"; readonly col_295: number; readonly col_296: string; readonly col_297: boolean; - readonly col_298: Date; + readonly col_298: string; readonly col_299: "alpha" | "beta"; readonly col_300: number; readonly col_301: string; readonly col_302: boolean; - readonly col_303: Date; + readonly col_303: string; readonly col_304: "alpha" | "beta"; readonly col_305: number; readonly col_306: string; readonly col_307: boolean; - readonly col_308: Date; + readonly col_308: string; readonly col_309: "alpha" | "beta"; readonly col_310: number; readonly col_311: string; readonly col_312: boolean; - readonly col_313: Date; + readonly col_313: string; readonly col_314: "alpha" | "beta"; readonly col_315: number; readonly col_316: string; readonly col_317: boolean; - readonly col_318: Date; + readonly col_318: string; readonly col_319: "alpha" | "beta"; readonly col_320: number; readonly col_321: string; readonly col_322: boolean; - readonly col_323: Date; + readonly col_323: string; readonly col_324: "alpha" | "beta"; readonly col_325: number; readonly col_326: string; readonly col_327: boolean; - readonly col_328: Date; + readonly col_328: string; readonly col_329: "alpha" | "beta"; readonly col_330: number; readonly col_331: string; readonly col_332: boolean; - readonly col_333: Date; + readonly col_333: string; readonly col_334: "alpha" | "beta"; readonly col_335: number; readonly col_336: string; readonly col_337: boolean; - readonly col_338: Date; + readonly col_338: string; readonly col_339: "alpha" | "beta"; readonly col_340: number; readonly col_341: string; readonly col_342: boolean; - readonly col_343: Date; + readonly col_343: string; readonly col_344: "alpha" | "beta"; readonly col_345: number; readonly col_346: string; readonly col_347: boolean; - readonly col_348: Date; + readonly col_348: string; readonly col_349: "alpha" | "beta"; readonly col_350: number; readonly col_351: string; readonly col_352: boolean; - readonly col_353: Date; + readonly col_353: string; readonly col_354: "alpha" | "beta"; readonly col_355: number; readonly col_356: string; readonly col_357: boolean; - readonly col_358: Date; + readonly col_358: string; readonly col_359: "alpha" | "beta"; readonly col_360: number; readonly col_361: string; readonly col_362: boolean; - readonly col_363: Date; + readonly col_363: string; readonly col_364: "alpha" | "beta"; readonly col_365: number; readonly col_366: string; readonly col_367: boolean; - readonly col_368: Date; + readonly col_368: string; readonly col_369: "alpha" | "beta"; readonly col_370: number; readonly col_371: string; readonly col_372: boolean; - readonly col_373: Date; + readonly col_373: string; readonly col_374: "alpha" | "beta"; readonly col_375: number; readonly col_376: string; readonly col_377: boolean; - readonly col_378: Date; + readonly col_378: string; readonly col_379: "alpha" | "beta"; readonly col_380: number; readonly col_381: string; readonly col_382: boolean; - readonly col_383: Date; + readonly col_383: string; readonly col_384: "alpha" | "beta"; readonly col_385: number; readonly col_386: string; readonly col_387: boolean; - readonly col_388: Date; + readonly col_388: string; readonly col_389: "alpha" | "beta"; readonly col_390: number; readonly col_391: string; readonly col_392: boolean; - readonly col_393: Date; + readonly col_393: string; readonly col_394: "alpha" | "beta"; readonly col_395: number; readonly col_396: string; readonly col_397: boolean; - readonly col_398: Date; + readonly col_398: string; readonly col_399: "alpha" | "beta"; readonly col_400: number; readonly col_401: string; readonly col_402: boolean; - readonly col_403: Date; + readonly col_403: string; readonly col_404: "alpha" | "beta"; readonly col_405: number; readonly col_406: string; readonly col_407: boolean; - readonly col_408: Date; + readonly col_408: string; readonly col_409: "alpha" | "beta"; readonly col_410: number; readonly col_411: string; readonly col_412: boolean; - readonly col_413: Date; + readonly col_413: string; readonly col_414: "alpha" | "beta"; readonly col_415: number; readonly col_416: string; readonly col_417: boolean; - readonly col_418: Date; + readonly col_418: string; readonly col_419: "alpha" | "beta"; readonly col_420: number; readonly col_421: string; readonly col_422: boolean; - readonly col_423: Date; + readonly col_423: string; readonly col_424: "alpha" | "beta"; readonly col_425: number; readonly col_426: string; readonly col_427: boolean; - readonly col_428: Date; + readonly col_428: string; readonly col_429: "alpha" | "beta"; readonly col_430: number; readonly col_431: string; readonly col_432: boolean; - readonly col_433: Date; + readonly col_433: string; readonly col_434: "alpha" | "beta"; readonly col_435: number; readonly col_436: string; readonly col_437: boolean; - readonly col_438: Date; + readonly col_438: string; readonly col_439: "alpha" | "beta"; readonly col_440: number; readonly col_441: string; readonly col_442: boolean; - readonly col_443: Date; + readonly col_443: string; readonly col_444: "alpha" | "beta"; readonly col_445: number; readonly col_446: string; readonly col_447: boolean; - readonly col_448: Date; + readonly col_448: string; readonly col_449: "alpha" | "beta"; readonly col_450: number; readonly col_451: string; readonly col_452: boolean; - readonly col_453: Date; + readonly col_453: string; readonly col_454: "alpha" | "beta"; readonly col_455: number; readonly col_456: string; readonly col_457: boolean; - readonly col_458: Date; + readonly col_458: string; readonly col_459: "alpha" | "beta"; readonly col_460: number; readonly col_461: string; readonly col_462: boolean; - readonly col_463: Date; + readonly col_463: string; readonly col_464: "alpha" | "beta"; readonly col_465: number; readonly col_466: string; readonly col_467: boolean; - readonly col_468: Date; + readonly col_468: string; readonly col_469: "alpha" | "beta"; readonly col_470: number; readonly col_471: string; readonly col_472: boolean; - readonly col_473: Date; + readonly col_473: string; readonly col_474: "alpha" | "beta"; readonly col_475: number; readonly col_476: string; readonly col_477: boolean; - readonly col_478: Date; + readonly col_478: string; readonly col_479: "alpha" | "beta"; readonly col_480: number; readonly col_481: string; readonly col_482: boolean; - readonly col_483: Date; + readonly col_483: string; readonly col_484: "alpha" | "beta"; readonly col_485: number; readonly col_486: string; readonly col_487: boolean; - readonly col_488: Date; + readonly col_488: string; readonly col_489: "alpha" | "beta"; readonly col_490: number; readonly col_491: string; readonly col_492: boolean; - readonly col_493: Date; + readonly col_493: string; readonly col_494: "alpha" | "beta"; readonly col_495: number; readonly col_496: string; readonly col_497: boolean; - readonly col_498: Date; + readonly col_498: string; readonly col_499: "alpha" | "beta"; } @@ -545,7 +545,7 @@ export const columns500 = [ }), column500.accessor("derived_003", (row) => row.col_003, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_004", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_005", (row) => row.col_005, { @@ -565,7 +565,7 @@ export const columns500 = [ }), column500.accessor("col_008", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_009", (row) => row.col_009, { type: "enum", @@ -588,7 +588,7 @@ export const columns500 = [ }), column500.accessor("derived_013", (row) => row.col_013, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_014", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_015", (row) => row.col_015, { @@ -608,7 +608,7 @@ export const columns500 = [ }), column500.accessor("col_018", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_019", (row) => row.col_019, { type: "enum", @@ -631,7 +631,7 @@ export const columns500 = [ }), column500.accessor("derived_023", (row) => row.col_023, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_024", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_025", (row) => row.col_025, { @@ -651,7 +651,7 @@ export const columns500 = [ }), column500.accessor("col_028", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_029", (row) => row.col_029, { type: "enum", @@ -674,7 +674,7 @@ export const columns500 = [ }), column500.accessor("derived_033", (row) => row.col_033, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_034", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_035", (row) => row.col_035, { @@ -694,7 +694,7 @@ export const columns500 = [ }), column500.accessor("col_038", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_039", (row) => row.col_039, { type: "enum", @@ -717,7 +717,7 @@ export const columns500 = [ }), column500.accessor("derived_043", (row) => row.col_043, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_044", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_045", (row) => row.col_045, { @@ -737,7 +737,7 @@ export const columns500 = [ }), column500.accessor("col_048", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_049", (row) => row.col_049, { type: "enum", @@ -760,7 +760,7 @@ export const columns500 = [ }), column500.accessor("derived_053", (row) => row.col_053, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_054", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_055", (row) => row.col_055, { @@ -780,7 +780,7 @@ export const columns500 = [ }), column500.accessor("col_058", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_059", (row) => row.col_059, { type: "enum", @@ -803,7 +803,7 @@ export const columns500 = [ }), column500.accessor("derived_063", (row) => row.col_063, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_064", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_065", (row) => row.col_065, { @@ -823,7 +823,7 @@ export const columns500 = [ }), column500.accessor("col_068", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_069", (row) => row.col_069, { type: "enum", @@ -846,7 +846,7 @@ export const columns500 = [ }), column500.accessor("derived_073", (row) => row.col_073, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_074", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_075", (row) => row.col_075, { @@ -866,7 +866,7 @@ export const columns500 = [ }), column500.accessor("col_078", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_079", (row) => row.col_079, { type: "enum", @@ -889,7 +889,7 @@ export const columns500 = [ }), column500.accessor("derived_083", (row) => row.col_083, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_084", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_085", (row) => row.col_085, { @@ -909,7 +909,7 @@ export const columns500 = [ }), column500.accessor("col_088", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_089", (row) => row.col_089, { type: "enum", @@ -932,7 +932,7 @@ export const columns500 = [ }), column500.accessor("derived_093", (row) => row.col_093, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_094", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_095", (row) => row.col_095, { @@ -952,7 +952,7 @@ export const columns500 = [ }), column500.accessor("col_098", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_099", (row) => row.col_099, { type: "enum", @@ -975,7 +975,7 @@ export const columns500 = [ }), column500.accessor("derived_103", (row) => row.col_103, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_104", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_105", (row) => row.col_105, { @@ -995,7 +995,7 @@ export const columns500 = [ }), column500.accessor("col_108", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_109", (row) => row.col_109, { type: "enum", @@ -1018,7 +1018,7 @@ export const columns500 = [ }), column500.accessor("derived_113", (row) => row.col_113, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_114", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_115", (row) => row.col_115, { @@ -1038,7 +1038,7 @@ export const columns500 = [ }), column500.accessor("col_118", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_119", (row) => row.col_119, { type: "enum", @@ -1061,7 +1061,7 @@ export const columns500 = [ }), column500.accessor("derived_123", (row) => row.col_123, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_124", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_125", (row) => row.col_125, { @@ -1081,7 +1081,7 @@ export const columns500 = [ }), column500.accessor("col_128", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_129", (row) => row.col_129, { type: "enum", @@ -1104,7 +1104,7 @@ export const columns500 = [ }), column500.accessor("derived_133", (row) => row.col_133, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_134", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_135", (row) => row.col_135, { @@ -1124,7 +1124,7 @@ export const columns500 = [ }), column500.accessor("col_138", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_139", (row) => row.col_139, { type: "enum", @@ -1147,7 +1147,7 @@ export const columns500 = [ }), column500.accessor("derived_143", (row) => row.col_143, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_144", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_145", (row) => row.col_145, { @@ -1167,7 +1167,7 @@ export const columns500 = [ }), column500.accessor("col_148", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_149", (row) => row.col_149, { type: "enum", @@ -1190,7 +1190,7 @@ export const columns500 = [ }), column500.accessor("derived_153", (row) => row.col_153, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_154", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_155", (row) => row.col_155, { @@ -1210,7 +1210,7 @@ export const columns500 = [ }), column500.accessor("col_158", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_159", (row) => row.col_159, { type: "enum", @@ -1233,7 +1233,7 @@ export const columns500 = [ }), column500.accessor("derived_163", (row) => row.col_163, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_164", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_165", (row) => row.col_165, { @@ -1253,7 +1253,7 @@ export const columns500 = [ }), column500.accessor("col_168", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_169", (row) => row.col_169, { type: "enum", @@ -1276,7 +1276,7 @@ export const columns500 = [ }), column500.accessor("derived_173", (row) => row.col_173, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_174", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_175", (row) => row.col_175, { @@ -1296,7 +1296,7 @@ export const columns500 = [ }), column500.accessor("col_178", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_179", (row) => row.col_179, { type: "enum", @@ -1319,7 +1319,7 @@ export const columns500 = [ }), column500.accessor("derived_183", (row) => row.col_183, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_184", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_185", (row) => row.col_185, { @@ -1339,7 +1339,7 @@ export const columns500 = [ }), column500.accessor("col_188", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_189", (row) => row.col_189, { type: "enum", @@ -1362,7 +1362,7 @@ export const columns500 = [ }), column500.accessor("derived_193", (row) => row.col_193, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_194", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_195", (row) => row.col_195, { @@ -1382,7 +1382,7 @@ export const columns500 = [ }), column500.accessor("col_198", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_199", (row) => row.col_199, { type: "enum", @@ -1405,7 +1405,7 @@ export const columns500 = [ }), column500.accessor("derived_203", (row) => row.col_203, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_204", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_205", (row) => row.col_205, { @@ -1425,7 +1425,7 @@ export const columns500 = [ }), column500.accessor("col_208", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_209", (row) => row.col_209, { type: "enum", @@ -1448,7 +1448,7 @@ export const columns500 = [ }), column500.accessor("derived_213", (row) => row.col_213, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_214", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_215", (row) => row.col_215, { @@ -1468,7 +1468,7 @@ export const columns500 = [ }), column500.accessor("col_218", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_219", (row) => row.col_219, { type: "enum", @@ -1491,7 +1491,7 @@ export const columns500 = [ }), column500.accessor("derived_223", (row) => row.col_223, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_224", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_225", (row) => row.col_225, { @@ -1511,7 +1511,7 @@ export const columns500 = [ }), column500.accessor("col_228", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_229", (row) => row.col_229, { type: "enum", @@ -1534,7 +1534,7 @@ export const columns500 = [ }), column500.accessor("derived_233", (row) => row.col_233, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_234", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_235", (row) => row.col_235, { @@ -1554,7 +1554,7 @@ export const columns500 = [ }), column500.accessor("col_238", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_239", (row) => row.col_239, { type: "enum", @@ -1577,7 +1577,7 @@ export const columns500 = [ }), column500.accessor("derived_243", (row) => row.col_243, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_244", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_245", (row) => row.col_245, { @@ -1597,7 +1597,7 @@ export const columns500 = [ }), column500.accessor("col_248", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_249", (row) => row.col_249, { type: "enum", @@ -1620,7 +1620,7 @@ export const columns500 = [ }), column500.accessor("derived_253", (row) => row.col_253, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_254", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_255", (row) => row.col_255, { @@ -1640,7 +1640,7 @@ export const columns500 = [ }), column500.accessor("col_258", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_259", (row) => row.col_259, { type: "enum", @@ -1663,7 +1663,7 @@ export const columns500 = [ }), column500.accessor("derived_263", (row) => row.col_263, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_264", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_265", (row) => row.col_265, { @@ -1683,7 +1683,7 @@ export const columns500 = [ }), column500.accessor("col_268", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_269", (row) => row.col_269, { type: "enum", @@ -1706,7 +1706,7 @@ export const columns500 = [ }), column500.accessor("derived_273", (row) => row.col_273, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_274", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_275", (row) => row.col_275, { @@ -1726,7 +1726,7 @@ export const columns500 = [ }), column500.accessor("col_278", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_279", (row) => row.col_279, { type: "enum", @@ -1749,7 +1749,7 @@ export const columns500 = [ }), column500.accessor("derived_283", (row) => row.col_283, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_284", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_285", (row) => row.col_285, { @@ -1769,7 +1769,7 @@ export const columns500 = [ }), column500.accessor("col_288", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_289", (row) => row.col_289, { type: "enum", @@ -1792,7 +1792,7 @@ export const columns500 = [ }), column500.accessor("derived_293", (row) => row.col_293, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_294", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_295", (row) => row.col_295, { @@ -1812,7 +1812,7 @@ export const columns500 = [ }), column500.accessor("col_298", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_299", (row) => row.col_299, { type: "enum", @@ -1835,7 +1835,7 @@ export const columns500 = [ }), column500.accessor("derived_303", (row) => row.col_303, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_304", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_305", (row) => row.col_305, { @@ -1855,7 +1855,7 @@ export const columns500 = [ }), column500.accessor("col_308", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_309", (row) => row.col_309, { type: "enum", @@ -1878,7 +1878,7 @@ export const columns500 = [ }), column500.accessor("derived_313", (row) => row.col_313, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_314", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_315", (row) => row.col_315, { @@ -1898,7 +1898,7 @@ export const columns500 = [ }), column500.accessor("col_318", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_319", (row) => row.col_319, { type: "enum", @@ -1921,7 +1921,7 @@ export const columns500 = [ }), column500.accessor("derived_323", (row) => row.col_323, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_324", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_325", (row) => row.col_325, { @@ -1941,7 +1941,7 @@ export const columns500 = [ }), column500.accessor("col_328", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_329", (row) => row.col_329, { type: "enum", @@ -1964,7 +1964,7 @@ export const columns500 = [ }), column500.accessor("derived_333", (row) => row.col_333, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_334", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_335", (row) => row.col_335, { @@ -1984,7 +1984,7 @@ export const columns500 = [ }), column500.accessor("col_338", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_339", (row) => row.col_339, { type: "enum", @@ -2007,7 +2007,7 @@ export const columns500 = [ }), column500.accessor("derived_343", (row) => row.col_343, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_344", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_345", (row) => row.col_345, { @@ -2027,7 +2027,7 @@ export const columns500 = [ }), column500.accessor("col_348", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_349", (row) => row.col_349, { type: "enum", @@ -2050,7 +2050,7 @@ export const columns500 = [ }), column500.accessor("derived_353", (row) => row.col_353, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_354", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_355", (row) => row.col_355, { @@ -2070,7 +2070,7 @@ export const columns500 = [ }), column500.accessor("col_358", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_359", (row) => row.col_359, { type: "enum", @@ -2093,7 +2093,7 @@ export const columns500 = [ }), column500.accessor("derived_363", (row) => row.col_363, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_364", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_365", (row) => row.col_365, { @@ -2113,7 +2113,7 @@ export const columns500 = [ }), column500.accessor("col_368", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_369", (row) => row.col_369, { type: "enum", @@ -2136,7 +2136,7 @@ export const columns500 = [ }), column500.accessor("derived_373", (row) => row.col_373, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_374", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_375", (row) => row.col_375, { @@ -2156,7 +2156,7 @@ export const columns500 = [ }), column500.accessor("col_378", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_379", (row) => row.col_379, { type: "enum", @@ -2179,7 +2179,7 @@ export const columns500 = [ }), column500.accessor("derived_383", (row) => row.col_383, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_384", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_385", (row) => row.col_385, { @@ -2199,7 +2199,7 @@ export const columns500 = [ }), column500.accessor("col_388", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_389", (row) => row.col_389, { type: "enum", @@ -2222,7 +2222,7 @@ export const columns500 = [ }), column500.accessor("derived_393", (row) => row.col_393, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_394", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_395", (row) => row.col_395, { @@ -2242,7 +2242,7 @@ export const columns500 = [ }), column500.accessor("col_398", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_399", (row) => row.col_399, { type: "enum", @@ -2265,7 +2265,7 @@ export const columns500 = [ }), column500.accessor("derived_403", (row) => row.col_403, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_404", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_405", (row) => row.col_405, { @@ -2285,7 +2285,7 @@ export const columns500 = [ }), column500.accessor("col_408", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_409", (row) => row.col_409, { type: "enum", @@ -2308,7 +2308,7 @@ export const columns500 = [ }), column500.accessor("derived_413", (row) => row.col_413, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_414", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_415", (row) => row.col_415, { @@ -2328,7 +2328,7 @@ export const columns500 = [ }), column500.accessor("col_418", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_419", (row) => row.col_419, { type: "enum", @@ -2351,7 +2351,7 @@ export const columns500 = [ }), column500.accessor("derived_423", (row) => row.col_423, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_424", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_425", (row) => row.col_425, { @@ -2371,7 +2371,7 @@ export const columns500 = [ }), column500.accessor("col_428", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_429", (row) => row.col_429, { type: "enum", @@ -2394,7 +2394,7 @@ export const columns500 = [ }), column500.accessor("derived_433", (row) => row.col_433, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_434", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_435", (row) => row.col_435, { @@ -2414,7 +2414,7 @@ export const columns500 = [ }), column500.accessor("col_438", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_439", (row) => row.col_439, { type: "enum", @@ -2437,7 +2437,7 @@ export const columns500 = [ }), column500.accessor("derived_443", (row) => row.col_443, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_444", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_445", (row) => row.col_445, { @@ -2457,7 +2457,7 @@ export const columns500 = [ }), column500.accessor("col_448", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_449", (row) => row.col_449, { type: "enum", @@ -2480,7 +2480,7 @@ export const columns500 = [ }), column500.accessor("derived_453", (row) => row.col_453, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_454", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_455", (row) => row.col_455, { @@ -2500,7 +2500,7 @@ export const columns500 = [ }), column500.accessor("col_458", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_459", (row) => row.col_459, { type: "enum", @@ -2523,7 +2523,7 @@ export const columns500 = [ }), column500.accessor("derived_463", (row) => row.col_463, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_464", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_465", (row) => row.col_465, { @@ -2543,7 +2543,7 @@ export const columns500 = [ }), column500.accessor("col_468", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_469", (row) => row.col_469, { type: "enum", @@ -2566,7 +2566,7 @@ export const columns500 = [ }), column500.accessor("derived_473", (row) => row.col_473, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_474", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_475", (row) => row.col_475, { @@ -2586,7 +2586,7 @@ export const columns500 = [ }), column500.accessor("col_478", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_479", (row) => row.col_479, { type: "enum", @@ -2609,7 +2609,7 @@ export const columns500 = [ }), column500.accessor("derived_483", (row) => row.col_483, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_484", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_485", (row) => row.col_485, { @@ -2629,7 +2629,7 @@ export const columns500 = [ }), column500.accessor("col_488", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_489", (row) => row.col_489, { type: "enum", @@ -2652,7 +2652,7 @@ export const columns500 = [ }), column500.accessor("derived_493", (row) => row.col_493, { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("col_494", { type: "enum", format: ({ value }) => value }), column500.accessor("derived_495", (row) => row.col_495, { @@ -2672,7 +2672,7 @@ export const columns500 = [ }), column500.accessor("col_498", { type: "date", - format: ({ value }) => value.toISOString(), + format: ({ value }) => value, }), column500.accessor("derived_499", (row) => row.col_499, { type: "enum", @@ -2694,7 +2694,7 @@ const query500: PretableQueryFor = { { columnId: "derived_003", operator: "dateBetween", - value: [0, new Date(0)], + value: ["1970-01-01", "1970-01-02"], }, { columnId: "col_004", operator: "isAnyOf", value: ["alpha"] }, { columnId: "derived_495", operator: "gte", value: 0 }, From d23f8c34ee65f9d20aad9320f9d8dcbbede46b56 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 17:48:27 -0700 Subject: [PATCH 05/16] fix(core): reject Date group keys --- .../src/__tests__/compiled-query.test.ts | 86 ++++++++++--------- .../row-model/src/__tests__/grouping.test.ts | 9 +- packages/row-model/src/group-index.ts | 22 ++--- 3 files changed, 56 insertions(+), 61 deletions(-) diff --git a/packages/row-model/src/__tests__/compiled-query.test.ts b/packages/row-model/src/__tests__/compiled-query.test.ts index 022f6a4b2..8d9b5b195 100644 --- a/packages/row-model/src/__tests__/compiled-query.test.ts +++ b/packages/row-model/src/__tests__/compiled-query.test.ts @@ -295,10 +295,10 @@ describe("compileQuery", () => { }); test.each([ - ["asc", "first", [2, 1, 6, 4, 5, 3]], - ["asc", "last", [2, 1, 6, 4, 5, 3]], - ["desc", "first", [1, 2, 6, 4, 5, 3]], - ["desc", "last", [1, 2, 6, 4, 5, 3]], + ["asc", "first", [2, 7, 1, 8, 6, 4, 5, 3]], + ["asc", "last", [2, 7, 1, 8, 6, 4, 5, 3]], + ["desc", "first", [7, 1, 8, 2, 6, 4, 5, 3]], + ["desc", "last", [7, 1, 8, 2, 6, 4, 5, 3]], ] as const)( "orders valid calendar dates first for %s/nulls-%s and lets terminal values fall through", (direction, nulls, expected) => { @@ -330,6 +330,8 @@ describe("compileQuery", () => { { id: 4, asOf: null, tie: 1 }, { id: 5, asOf: { date: "2026-08-05" }, tie: 1 }, { id: 6, asOf: undefined, tie: 0 }, + { id: 7, asOf: "2026-08-06", tie: -1 }, + { id: 8, asOf: "2026-08-06", tie: 0 }, ]; const metadata = rows.map((row, sourceOrder) => plan.evaluate({ @@ -640,23 +642,26 @@ describe("compileQuery", () => { ["isEmpty", "2026-08-06", false], ["isNotEmpty", null, false], ["isNotEmpty", "2026-08-06", true], - ] as const)("evaluates calendar-date operator %s", (operator, asOf, expected) => { - const column = createColumnHelper<{ id: number; asOf: string | null }>(); - const columns = [column.accessor("asOf", { type: "date" })] as const; - const plan = compileQuery({ - derivations: columns, - query: { - filters: [{ columnId: "asOf", operator }], - rowGroups: [], - sort: [], - }, - }); + ] as const)( + "evaluates calendar-date operator %s", + (operator, asOf, expected) => { + const column = createColumnHelper<{ id: number; asOf: string | null }>(); + const columns = [column.accessor("asOf", { type: "date" })] as const; + const plan = compileQuery({ + derivations: columns, + query: { + filters: [{ columnId: "asOf", operator }], + rowGroups: [], + sort: [], + }, + }); - expect( - plan.evaluate({ rowId: 1, sourceOrder: 0, row: { id: 1, asOf } }) - .filterPasses, - ).toBe(expected); - }); + expect( + plan.evaluate({ rowId: 1, sourceOrder: 0, row: { id: 1, asOf } }) + .filterPasses, + ).toBe(expected); + }, + ); test.each([ ["Date", "on", new Date("2026-08-06T00:00:00Z")], @@ -726,26 +731,29 @@ describe("compileQuery", () => { test.each([ [["2026-02-30", "2026-08-31"]], [["2026-08-01", "2026-13-01"]], - ] as const)("zero-matches when either dateBetween bound is invalid", (value) => { - const column = createColumnHelper<{ id: number; asOf: string }>(); - const columns = [column.accessor("asOf", { type: "date" })] as const; - const plan = compileQuery({ - derivations: columns, - query: { - filters: [{ columnId: "asOf", operator: "dateBetween", value }], - rowGroups: [], - sort: [], - }, - }); + ] as const)( + "zero-matches when either dateBetween bound is invalid", + (value) => { + const column = createColumnHelper<{ id: number; asOf: string }>(); + const columns = [column.accessor("asOf", { type: "date" })] as const; + const plan = compileQuery({ + derivations: columns, + query: { + filters: [{ columnId: "asOf", operator: "dateBetween", value }], + rowGroups: [], + sort: [], + }, + }); - expect( - plan.evaluate({ - rowId: 1, - sourceOrder: 0, - row: { id: 1, asOf: "2026-08-06" }, - }).filterPasses, - ).toBe(false); - }); + expect( + plan.evaluate({ + rowId: 1, + sourceOrder: 0, + row: { id: 1, asOf: "2026-08-06" }, + }).filterPasses, + ).toBe(false); + }, + ); test.each([ null, diff --git a/packages/row-model/src/__tests__/grouping.test.ts b/packages/row-model/src/__tests__/grouping.test.ts index 6e7bc0a06..a313deff0 100644 --- a/packages/row-model/src/__tests__/grouping.test.ts +++ b/packages/row-model/src/__tests__/grouping.test.ts @@ -383,8 +383,7 @@ describe("incremental grouped row model", () => { keyHelper.accessor( "key", (row): string | number | bigint | boolean | null | undefined => - row.key as - string | number | bigint | boolean | null | undefined, + row.key as string | number | bigint | boolean | null | undefined, { type: "number", compare: (left, right) => String(left).localeCompare(String(right)), @@ -431,6 +430,8 @@ describe("incremental grouped row model", () => { }); test.each([ + ["Date", new Date(0)], + ["invalid Date", new Date(Number.NaN)], ["object", { label: "same stringification" }], ["symbol", Symbol("unsupported")], ["function", () => "unsupported"], @@ -501,9 +502,7 @@ describe("incremental grouped row model", () => { asOf: string | null; } const dated = createColumnHelper(); - const datedColumns = [ - dated.accessor("asOf", { type: "date" }), - ] as const; + const datedColumns = [dated.accessor("asOf", { type: "date" })] as const; const model = createLocalRowModel({ rows: [ { id: 1, asOf: "2026-02-30" }, diff --git a/packages/row-model/src/group-index.ts b/packages/row-model/src/group-index.ts index 9dad5a4cf..6952dd279 100644 --- a/packages/row-model/src/group-index.ts +++ b/packages/row-model/src/group-index.ts @@ -258,28 +258,16 @@ export function encodeGroupValue( if (typeof value === "bigint") return `i:${String(value)}`; if (typeof value === "object") { try { - const time = Date.prototype.getTime.call(value); - return Number.isNaN(time) ? "d:Invalid" : `d:${String(time)}`; - } catch (brandCause) { - // A guarded prototype walk preserves a hostile Proxy's exact trap as - // context without ever accepting Date proxies or prototype spoofs. - try { - void (value instanceof Date); - } catch (cause) { - throw new PretableInvalidGroupKeyError( - context?.operation ?? "set-query", - context?.rowId, - context?.columnId ?? "", - value, - cause, - ); - } + // Preserve a hostile Proxy's exact prototype trap as error context while + // rejecting every object, including genuine Dates and Date spoofs. + void (value instanceof Date); + } catch (cause) { throw new PretableInvalidGroupKeyError( context?.operation ?? "set-query", context?.rowId, context?.columnId ?? "", value, - brandCause, + cause, ); } } From 4af08604e6f7671c87972b6cd46609941fb2a761 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 18:02:09 -0700 Subject: [PATCH 06/16] fix(core): close date type and group-key escapes --- .../row-model/src/__tests__/grouping.test.ts | 8 +++- packages/row-model/src/column-types.ts | 39 +++++++++++-------- packages/row-model/src/group-index.ts | 26 +------------ type-tests/core/columns.types.ts | 16 ++++++++ 4 files changed, 45 insertions(+), 44 deletions(-) diff --git a/packages/row-model/src/__tests__/grouping.test.ts b/packages/row-model/src/__tests__/grouping.test.ts index a313deff0..6870ead08 100644 --- a/packages/row-model/src/__tests__/grouping.test.ts +++ b/packages/row-model/src/__tests__/grouping.test.ts @@ -533,17 +533,19 @@ describe("incremental grouped row model", () => { ["transaction", "apply-transaction"], ["query transition", "set-query"], ] as const)( - "wraps a hostile Proxy group key during %s", + "rejects a hostile Proxy group key without prototype inspection during %s", async (scenario, operation) => { interface HostileKeyRow { id: number; key: unknown; } const trap = new Error(`${scenario} getPrototypeOf exploded`); + let prototypeReads = 0; const key = new Proxy( {}, { getPrototypeOf: () => { + prototypeReads += 1; throw trap; }, }, @@ -605,7 +607,9 @@ describe("incremental grouped row model", () => { columnId: "key", }); expect((caught as { readonly value?: unknown }).value).toBe(key); - expect((caught as Error).cause).toBe(trap); + expect(prototypeReads).toBe(0); + expect((caught as Error).cause).toBeInstanceOf(TypeError); + expect((caught as Error).cause).not.toBe(trap); if (scenario !== "construction") { if (scenario === "query transition") { expect(listener).toHaveBeenCalledTimes(1); diff --git a/packages/row-model/src/column-types.ts b/packages/row-model/src/column-types.ts index 51ecfd596..a716f1201 100644 --- a/packages/row-model/src/column-types.ts +++ b/packages/row-model/src/column-types.ts @@ -165,20 +165,25 @@ export interface PretableColumnDefinition< }; } +type IsAny = 0 extends 1 & TValue ? true : false; + /** @public */ -export type PretableColumnTypeFor = [TValue] extends [never] - ? never - : [NonNullable] extends [never] - ? Exclude - : NonNullable extends number - ? "number" - : NonNullable extends boolean - ? "boolean" - : NonNullable extends string - ? | "text" - | "enum" - | ([TValue] extends [string | null] ? "date" : never) - : Exclude; +export type PretableColumnTypeFor = + IsAny extends true + ? Exclude + : [TValue] extends [never] + ? never + : [NonNullable] extends [never] + ? Exclude + : NonNullable extends number + ? "number" + : NonNullable extends boolean + ? "boolean" + : NonNullable extends string + ? | "text" + | "enum" + | ([TValue] extends [string | null] ? "date" : never) + : Exclude; /** @public */ export interface PretableColumnCallbackContext< @@ -244,7 +249,9 @@ export interface PretableColumnHelper { | ([TValue] extends [PretableUninferredColumnValue] ? [ReturnType] extends [never] ? never - : PretableColumnType + : IsAny> extends true + ? Exclude + : PretableColumnType : never), TValue = PretableUninferredColumnValue, const TAggregate extends @@ -315,9 +322,7 @@ export function createColumnHelper< return { accessor( id: string, - accessorOrOptions: - | ((row: TRow) => unknown) - | object, + accessorOrOptions: ((row: TRow) => unknown) | object, maybeOptions?: object, ) { const isFunctionAccessor = typeof accessorOrOptions === "function"; diff --git a/packages/row-model/src/group-index.ts b/packages/row-model/src/group-index.ts index 6952dd279..ab88c64dc 100644 --- a/packages/row-model/src/group-index.ts +++ b/packages/row-model/src/group-index.ts @@ -256,21 +256,6 @@ export function encodeGroupValue( } if (typeof value === "boolean") return `b:${String(value)}`; if (typeof value === "bigint") return `i:${String(value)}`; - if (typeof value === "object") { - try { - // Preserve a hostile Proxy's exact prototype trap as error context while - // rejecting every object, including genuine Dates and Date spoofs. - void (value instanceof Date); - } catch (cause) { - throw new PretableInvalidGroupKeyError( - context?.operation ?? "set-query", - context?.rowId, - context?.columnId ?? "", - value, - cause, - ); - } - } throw new PretableInvalidGroupKeyError( context?.operation ?? "set-query", context?.rowId, @@ -290,15 +275,6 @@ export function makeGroupId( .join("/")}` as PretableGroupId; } -function sameValue(left: unknown, right: unknown): boolean { - return ( - Object.is(left, right) || - (left instanceof Date && - right instanceof Date && - Object.is(left.getTime(), right.getTime())) - ); -} - function combineCounts(left: PolicyCounts, right: PolicyCounts): PolicyCounts { const length = Math.max(left.throughDepth.length, right.throughDepth.length); const throughDepth = Array.from( @@ -1145,7 +1121,7 @@ function makePublicGroup( previous.expanded === expanded && previous.childCount === source.childCount && previous.aggregates === source.aggregates && - sameValue(previous.value, source.value) + Object.is(previous.value, source.value) ) return previous; return Object.freeze({ ...source, expanded }) as PretableGroupRow; diff --git a/type-tests/core/columns.types.ts b/type-tests/core/columns.types.ts index d9222f9d4..c74cb926e 100644 --- a/type-tests/core/columns.types.ts +++ b/type-tests/core/columns.types.ts @@ -4,6 +4,8 @@ import { type ColumnValueOf, type PretableAggregator, type PretableColumnAccessorKind, + type PretableColumnType, + type PretableColumnTypeFor, } from "@pretable/core"; import type { Equal, Expect } from "../shared/assert"; @@ -119,6 +121,7 @@ interface NumericEligibilityRow { undefinedOnly: undefined; neverOnly: never; unknownOnly: unknown; + anyOnly: any; numberOrText: number | string; } const numericEligibilityColumn = createColumnHelper(); @@ -154,6 +157,14 @@ numericEligibilityColumn.accessor("numberOrText", { // @ts-expect-error a number|string value is not wholly numeric aggregate: "sum", }); +numericEligibilityColumn.accessor("anyOnly", { + // @ts-expect-error any-valued fields cannot opt into built-in calendar dates + type: "date", +}); +numericEligibilityColumn.accessor("computedAnyDate", (row) => row.anyOnly, { + // @ts-expect-error computed any values cannot opt into calendar dates + type: "date", +}); numericEligibilityColumn.accessor("computedNullOnly", (row) => row.nullOnly, { // @ts-expect-error computed null-only values are not numeric columns type: "number", @@ -170,6 +181,10 @@ numericEligibilityColumn.accessor( aggregate: "avg", }, ); + +type _AnyColumnTypes = Expect< + Equal, Exclude> +>; numericEligibilityColumn.accessor( "computedNeverNumeric", (row) => row.neverOnly, @@ -210,3 +225,4 @@ void (null as unknown as _Quantity); void (null as unknown as _Computed); void (null as unknown as _DirectAccessorKind); void (null as unknown as _ComputedAccessorKind); +void (null as unknown as _AnyColumnTypes); From 03586cc900fe71dfb2631ba27662225e62e45b24 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 18:08:05 -0700 Subject: [PATCH 07/16] style(core): format date query changes --- .../src/__tests__/external-filter-authority.test.ts | 8 ++------ packages/row-model/src/__tests__/flat-query.test.ts | 4 +--- packages/row-model/src/__tests__/types.test.ts | 5 +---- packages/row-model/src/compiled-query.ts | 6 +----- packages/row-model/src/types.ts | 4 +--- 5 files changed, 6 insertions(+), 21 deletions(-) diff --git a/packages/row-model/src/__tests__/external-filter-authority.test.ts b/packages/row-model/src/__tests__/external-filter-authority.test.ts index 6d7b66220..f8519846a 100644 --- a/packages/row-model/src/__tests__/external-filter-authority.test.ts +++ b/packages/row-model/src/__tests__/external-filter-authority.test.ts @@ -73,13 +73,9 @@ describe("external filter authority", () => { asOf: string | null; } const dated = createColumnHelper(); - const datedColumns = [ - dated.accessor("asOf", { type: "date" }), - ] as const; + const datedColumns = [dated.accessor("asOf", { type: "date" })] as const; const query = { - filters: [ - { columnId: "asOf", operator: "after", value: "2026-01-01" }, - ], + filters: [{ columnId: "asOf", operator: "after", value: "2026-01-01" }], sort: [{ columnId: "asOf", direction: "asc", nulls: "first" }], rowGroups: [], } as const satisfies PretableQueryFor; diff --git a/packages/row-model/src/__tests__/flat-query.test.ts b/packages/row-model/src/__tests__/flat-query.test.ts index 3f52a55d9..0af10403c 100644 --- a/packages/row-model/src/__tests__/flat-query.test.ts +++ b/packages/row-model/src/__tests__/flat-query.test.ts @@ -118,9 +118,7 @@ describe("incremental flat queries", () => { asOf: string | null; } const dated = createColumnHelper(); - const datedColumns = [ - dated.accessor("asOf", { type: "date" }), - ] as const; + const datedColumns = [dated.accessor("asOf", { type: "date" })] as const; const model = createLocalRowModel({ rows: [ { id: 1, asOf: "2026-08-06" }, diff --git a/packages/row-model/src/__tests__/types.test.ts b/packages/row-model/src/__tests__/types.test.ts index b5b1e5da8..1a3a0dc1f 100644 --- a/packages/row-model/src/__tests__/types.test.ts +++ b/packages/row-model/src/__tests__/types.test.ts @@ -376,10 +376,7 @@ const query = { } as const satisfies PretableQueryFor; type _groupKeyContract = Expect< - Equal< - PretableGroupKey, - string | number | bigint | boolean | null | undefined - > + Equal >; type _dateDistinctColumnContract = Expect< Equal< diff --git a/packages/row-model/src/compiled-query.ts b/packages/row-model/src/compiled-query.ts index 9609d1d57..d02cd7e2a 100644 --- a/packages/row-model/src/compiled-query.ts +++ b/packages/row-model/src/compiled-query.ts @@ -751,11 +751,7 @@ function validateFilterOperand( value.length !== 2 || value.some((entry) => typeof entry !== "string") ) { - fail( - "date range must contain exactly two strings", - path, - column.id, - ); + fail("date range must contain exactly two strings", path, column.id); } } else if (typeof value !== "string") { fail("date operand must be a string", path, column.id); diff --git a/packages/row-model/src/types.ts b/packages/row-model/src/types.ts index 6a859c3ac..a6c36deb0 100644 --- a/packages/row-model/src/types.ts +++ b/packages/row-model/src/types.ts @@ -335,9 +335,7 @@ export type PretableDistinctColumnIdOf = readonly id: infer TColumnId extends string; readonly accessor: (...args: never[]) => infer TValue; } - ? [TValue] extends [ - string | number | bigint | boolean | null | undefined, - ] + ? [TValue] extends [string | number | bigint | boolean | null | undefined] ? [TValue] extends [never] ? never : TColumnId From 20c03c4ab5be4ef6fc65640ac4d13dbee0367e55 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 18:29:01 -0700 Subject: [PATCH 08/16] feat(core): aggregate canonical date extrema --- .../cross-emission-type-identity.types.ts | 9 ++ packages/react/src/types.ts | 16 +- .../src/__tests__/aggregator-law.test.ts | 51 ++++++ .../calendar-date-aggregates.test.ts | 132 ++++++++++++++++ .../src/__tests__/compiled-query.test.ts | 104 +++++++++++++ .../row-model/src/__tests__/grouping.test.ts | 93 +++++++++++ .../src/__tests__/transactions.test.ts | 43 ++++++ .../src/__tests__/transitions.test.ts | 50 ++++++ .../row-model/src/__tests__/types.test.ts | 69 +++++++++ .../row-model/src/calendar-date-aggregates.ts | 70 +++++++++ packages/row-model/src/column-types.ts | 94 +++++++---- packages/row-model/src/compiled-query.ts | 31 +++- packages/row-model/src/transaction-draft.ts | 6 +- type-tests/core/query-and-aggregate.types.ts | 146 ++++++++++++++++++ type-tests/react/model-inference.types.tsx | 54 +++++++ 15 files changed, 929 insertions(+), 39 deletions(-) create mode 100644 packages/row-model/src/__tests__/calendar-date-aggregates.test.ts create mode 100644 packages/row-model/src/calendar-date-aggregates.ts diff --git a/packages/react/src/__tests__/cross-emission-type-identity.types.ts b/packages/react/src/__tests__/cross-emission-type-identity.types.ts index 745e0adc3..9da57a1f5 100644 --- a/packages/react/src/__tests__/cross-emission-type-identity.types.ts +++ b/packages/react/src/__tests__/cross-emission-type-identity.types.ts @@ -34,6 +34,7 @@ * reach. */ import type { + PretableAggregateOutputOf as EngineAggregateOutputOf, PretableColumnAccessorKind as EngineColumnAccessorKind, PretableColumnDefinition as EngineColumnDefinition, PretableGroupId as EngineGroupId, @@ -43,6 +44,7 @@ import type { } from "@pretable-internal/row-model"; import type { ColumnsOf as CoreColumnsOf, + PretableAggregateOutputOf as CoreAggregateOutputOf, PretableColumnAccessorKind as CoreColumnAccessorKind, PretableColumnDefinition as CoreColumnDefinition, PretableGridUiCore as CoreGridUiCore, @@ -160,6 +162,12 @@ type _ColumnDefinition = Expect< EngineColumnDefinition > >; +type _DateAggregateOutput = Expect< + Equal< + CoreAggregateOutputOf<"min", "date">, + EngineAggregateOutputOf<"min", "date"> + > +>; // Referenced so none of the aliases above can be deleted without a compile // error — an unreferenced `type _X = Expect<...>` is still checked, but this @@ -170,6 +178,7 @@ export type CrossEmissionAssertions = [ _ColumnBrand, _ColumnSentinel, _ColumnDefinition, + _DateAggregateOutput, _GridUiCoreBrand, _RowModelRowOf, _RowModelRowIdOf, diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 2fe412d4e..c380c2431 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -382,7 +382,7 @@ export type PretableColumnFactoryOptions< TId extends string, TValue, TType extends PretableReactColumnTypeFor, - TAggregate extends PretableAggregateSpec | undefined, + TAggregate extends PretableAggregateSpec | undefined, > = { readonly type: TType; readonly compare?: (left: TValue, right: TValue) => number; @@ -404,7 +404,7 @@ export type PretableColumnFactoryOptions< >; }) => string; readonly formatAggregate?: (input: { - readonly value: PretableAggregateOutputOf; + readonly value: PretableAggregateOutputOf; readonly column: PretableReactColumnContext< TRow, TId, @@ -426,7 +426,7 @@ export type PretableReactColumnDefinition< TId extends string, TValue, TType extends PretableReactColumnTypeFor, - TAggregate extends PretableAggregateSpec | undefined, + TAggregate extends PretableAggregateSpec | undefined, TDirect extends boolean, > = Omit< PretableColumnDefinition, @@ -448,7 +448,7 @@ declare module "@pretable/core" { const TKey extends Extract, const TType extends PretableReactColumnTypeFor, const TAggregate extends - PretableAggregateSpec | undefined = undefined, + PretableAggregateSpec | undefined = undefined, >( key: TKey, options: PretableColumnFactoryOptions< @@ -473,8 +473,8 @@ declare module "@pretable/core" { const TId extends string, const TValue, const TType extends PretableReactColumnTypeFor, - const TAggregate extends PretableAggregateSpec | undefined = - undefined, + const TAggregate extends + PretableAggregateSpec | undefined = undefined, >( id: TId, accessor: (row: TRow) => TValue, @@ -528,8 +528,8 @@ declare module "@pretable/core" { const TId extends string, const TValue, const TType extends PretableReactColumnTypeFor, - const TAggregate extends PretableAggregateSpec | undefined = - undefined, + const TAggregate extends + PretableAggregateSpec | undefined = undefined, >( id: TId, accessor: (row: TRow) => TValue, diff --git a/packages/row-model/src/__tests__/aggregator-law.test.ts b/packages/row-model/src/__tests__/aggregator-law.test.ts index 3587f3a9c..1d93a7014 100644 --- a/packages/row-model/src/__tests__/aggregator-law.test.ts +++ b/packages/row-model/src/__tests__/aggregator-law.test.ts @@ -6,6 +6,7 @@ import { type AggregatorLawDiagnostic, type PretableAggregator, } from ".."; +import { lowerCalendarDateAggregate } from "../calendar-date-aggregates"; interface Row { readonly id: number; @@ -16,6 +17,56 @@ afterEach(() => { }); describe("aggregator law validation", () => { + test.each([ + ["min", "2024-02-29"], + ["max", "2027-01-01"], + ] as const)( + "accepts private calendar-date %s across the shared partition law harness", + (kind, expected) => { + const diagnostics: AggregatorLawDiagnostic[] = []; + const validator = createAggregatorLawValidator({ + sink: (diagnostic) => diagnostics.push(diagnostic), + }); + const aggregator = lowerCalendarDateAggregate( + "date", + kind, + ) as PretableAggregator; + let tree = createAggregateTree< + number, + Row, + unknown, + number, + string | null, + string | null + >({ + columnId: "asOf", + aggregator, + lawValidator: validator, + compare: (left, right) => left.dependency - right.dependency, + }); + const values: readonly unknown[] = [ + null, + "2026-08-18", + "2025-02-29", + "2024-02-29", + undefined, + "2027-01-01", + ]; + + values.forEach((value, id) => { + tree = tree.insertOrReplace({ + id, + row: { id }, + value, + dependency: id, + }); + }); + + expect(tree.finalize()).toBe(expected); + expect(diagnostics).toEqual([]); + }, + ); + test("compares common keyed and set-like structured outputs by value", () => { expect( defaultAggregatorOutputEquality( diff --git a/packages/row-model/src/__tests__/calendar-date-aggregates.test.ts b/packages/row-model/src/__tests__/calendar-date-aggregates.test.ts new file mode 100644 index 000000000..0099ad1c0 --- /dev/null +++ b/packages/row-model/src/__tests__/calendar-date-aggregates.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "vitest"; + +import type { PretableAggregator } from "../column-types"; +import { lowerCalendarDateAggregate } from "../calendar-date-aggregates"; + +type RuntimeCalendarDateAggregator = PretableAggregator< + object, + unknown, + string | null, + string | null +>; + +function dateAggregator(kind: "min" | "max"): RuntimeCalendarDateAggregator { + return lowerCalendarDateAggregate( + "date", + kind, + ) as RuntimeCalendarDateAggregator; +} + +function fold( + aggregator: RuntimeCalendarDateAggregator, + values: readonly unknown[], +): string | null { + return aggregator.finalize( + values.reduce( + (accumulator, value) => + aggregator.accumulate(accumulator, value, Object.freeze({})), + aggregator.init(), + ), + ); +} + +describe("calendar-date aggregate lowering", () => { + test.each([ + ["min", "2024-01-01"], + ["max", "2026-12-31"], + ] as const)( + "%s selects canonical extrema and ignores invalid or empty values", + (kind, expected) => { + const aggregator = dateAggregator(kind); + + expect( + fold(aggregator, [ + null, + undefined, + "", + "2025-02-29", + "2025-2-01", + "not-a-date", + new Date("2020-01-01T00:00:00.000Z"), + "2026-12-31", + "2024-01-01", + "2025-06-15", + ]), + ).toBe(expected); + expect(fold(aggregator, [])).toBeNull(); + expect(fold(aggregator, [null, "2025-02-29", 0])).toBeNull(); + }, + ); + + test.each(["min", "max"] as const)( + "%s is associative across every partition and merge order", + (kind) => { + const aggregator = dateAggregator(kind); + const partitions = [ + ["2025-07-04", "invalid"], + [null, "2024-02-29"], + ["2026-01-01", undefined], + ] as const; + const accumulated = partitions.map((partition) => + partition.reduce( + (accumulator, value) => + aggregator.accumulate(accumulator, value, Object.freeze({})), + aggregator.init(), + ), + ); + const expected = fold(aggregator, partitions.flat()); + const orders = [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ] as const; + + for (const order of orders) { + const leftAssociated = aggregator.merge( + aggregator.merge(accumulated[order[0]]!, accumulated[order[1]]!), + accumulated[order[2]]!, + ); + const rightAssociated = aggregator.merge( + accumulated[order[0]]!, + aggregator.merge(accumulated[order[1]]!, accumulated[order[2]]!), + ); + expect(aggregator.finalize(leftAssociated)).toBe(expected); + expect(aggregator.finalize(rightAssociated)).toBe(expected); + } + }, + ); + + test("keeps private built-ins frozen and accumulator operations immutable", () => { + const min = dateAggregator("min"); + const max = dateAggregator("max"); + const initial = min.init(); + const first = min.accumulate(initial, "2026-08-18", Object.freeze({})); + const second = min.accumulate(first, "2025-08-18", Object.freeze({})); + + expect(Object.isFrozen(min)).toBe(true); + expect(Object.isFrozen(max)).toBe(true); + expect(initial).toBeNull(); + expect(first).toBe("2026-08-18"); + expect(second).toBe("2025-08-18"); + expect(min.finalize(second)).toBe(second); + expect(dateAggregator("min")).toBe(min); + expect(dateAggregator("max")).toBe(max); + }); + + test("does not lower non-date or non-extremum aggregates", () => { + const custom: PretableAggregator = + Object.freeze({ + init: () => ({}), + accumulate: (accumulator: unknown) => accumulator, + merge: (left: unknown) => left, + finalize: () => null, + }); + + expect(lowerCalendarDateAggregate("number", "min")).toBe("min"); + expect(lowerCalendarDateAggregate("date", "count")).toBe("count"); + expect(lowerCalendarDateAggregate("date", custom)).toBe(custom); + }); +}); diff --git a/packages/row-model/src/__tests__/compiled-query.test.ts b/packages/row-model/src/__tests__/compiled-query.test.ts index 8d9b5b195..d19bbcecf 100644 --- a/packages/row-model/src/__tests__/compiled-query.test.ts +++ b/packages/row-model/src/__tests__/compiled-query.test.ts @@ -479,6 +479,110 @@ describe("compileQuery", () => { expect(typed).toHaveLength(2); }); + test("lowers date extrema only in compiled leaves and preserves public tokens", () => { + interface EventRow { + readonly id: number; + readonly startedOn: string | null; + readonly endedOn: string | null; + } + const column = createColumnHelper(); + const columns = [ + column.accessor("startedOn", { + type: "date", + aggregate: "min", + }), + column.accessor("endedOn", { + type: "date", + aggregate: "max", + }), + ] as const; + const plan = compileQuery({ + derivations: columns, + query: { filters: [], rowGroups: [], sort: [] }, + }); + + expect(plan.derivations.map((derivation) => derivation.aggregate)).toEqual([ + "min", + "max", + ]); + const first = plan.evaluate({ + rowId: 1, + sourceOrder: 0, + row: { id: 1, startedOn: "2026-08-18", endedOn: "2026-08-18" }, + }); + const second = plan.evaluate({ + rowId: 2, + sourceOrder: 1, + row: { id: 2, startedOn: "2025-01-01", endedOn: "2027-01-01" }, + }); + const min = first.aggregateLeaves[0]!.aggregate as PretableAggregator< + object, + unknown, + string | null, + string | null + >; + const max = first.aggregateLeaves[1]!.aggregate as typeof min; + + expect(typeof min).toBe("object"); + expect(typeof max).toBe("object"); + expect(second.aggregateLeaves[0]!.aggregate).toBe(min); + expect(second.aggregateLeaves[1]!.aggregate).toBe(max); + expect( + min.finalize( + min.merge( + min.accumulate(min.init(), "2026-08-18", {}), + min.accumulate(min.init(), "2025-01-01", {}), + ), + ), + ).toBe("2025-01-01"); + expect( + max.finalize( + max.merge( + max.accumulate(max.init(), "2026-08-18", {}), + max.accumulate(max.init(), "2027-01-01", {}), + ), + ), + ).toBe("2027-01-01"); + }); + + test.each([ + ["date", "sum", false], + ["date", "avg", false], + ["date", "min", true], + ["date", "max", true], + ["text", "min", false], + ["text", "max", false], + ["boolean", "count", true], + ] as const)( + "validates %s aggregate %s according to column semantics", + (type, aggregate, accepted) => { + const derivations = [ + { + id: "value", + type, + accessor: (row: { value: unknown }) => row.value, + value: (row: { value: unknown }) => row.value, + aggregate, + "~pretableColumn": { + row: {} as { value: unknown }, + id: "value", + value: undefined, + type, + aggregate, + }, + }, + ] as const; + const compile = () => + compileQuery({ + derivations, + query: { filters: [], rowGroups: [], sort: [] }, + } as never); + + if (accepted) expect(compile).not.toThrow(); + else expect(compile).toThrow(CompiledQueryValidationError); + }, + ); + test.each([ [ "unknown query column", diff --git a/packages/row-model/src/__tests__/grouping.test.ts b/packages/row-model/src/__tests__/grouping.test.ts index 6870ead08..4658d7033 100644 --- a/packages/row-model/src/__tests__/grouping.test.ts +++ b/packages/row-model/src/__tests__/grouping.test.ts @@ -89,6 +89,99 @@ function groupRows(model: ReturnType) { } describe("incremental grouped row model", () => { + test("maintains date extrema through filtering, expansion, and row mutations", () => { + interface DatedRow { + readonly id: number; + readonly team: string; + readonly earliest: string | null; + readonly latest: string | null; + readonly score: number; + } + const dated = createColumnHelper(); + const datedColumns = [ + dated.accessor("team", { type: "text" }), + dated.accessor("earliest", { type: "date", aggregate: "min" }), + dated.accessor("latest", { type: "date", aggregate: "max" }), + dated.accessor("score", { type: "number" }), + ] as const; + const model = createLocalRowModel({ + rows: [ + { + id: 1, + team: "A", + earliest: "2026-01-01", + latest: "2026-01-01", + score: 1, + }, + { + id: 2, + team: "A", + earliest: "2025-01-01", + latest: "2027-01-01", + score: 2, + }, + { + id: 3, + team: "A", + earliest: "2025-02-29", + latest: null, + score: 3, + }, + ], + columns: datedColumns, + query: { + filters: [{ columnId: "score", operator: "gte", value: 2 }], + sort: [], + rowGroups: [{ columnId: "team" }], + }, + initialExpansion: { kind: "collapsed" }, + }); + const aggregate = () => { + const group = model.getState().snapshot.rowAt(0); + if (group?.kind !== "group") throw new Error("missing team group"); + return group.aggregates; + }; + + expect(aggregate()).toEqual({ + earliest: "2025-01-01", + latest: "2027-01-01", + }); + const collapsedGroup = model.getState().snapshot.rowAt(0); + if (collapsedGroup?.kind !== "group") throw new Error("missing team group"); + model.setGroupExpanded(collapsedGroup.groupId, true); + expect(aggregate()).toEqual({ + earliest: "2025-01-01", + latest: "2027-01-01", + }); + + model.applyTransaction({ + add: [ + { + id: 4, + team: "A", + earliest: "2024-01-01", + latest: "2028-01-01", + score: 4, + }, + ], + }); + expect(aggregate()).toEqual({ + earliest: "2024-01-01", + latest: "2028-01-01", + }); + + model.applyTransaction({ + update: [{ id: 4, changes: { score: 0 } }], + }); + expect(aggregate()).toEqual({ + earliest: "2025-01-01", + latest: "2027-01-01", + }); + + model.applyTransaction({ remove: [2] }); + expect(aggregate()).toEqual({ earliest: null, latest: null }); + }); + test("builds one and multi-level typed paths with escaped collision-proof IDs", () => { const model = grouped(); const visible = model.getState().snapshot.range(0, 100); diff --git a/packages/row-model/src/__tests__/transactions.test.ts b/packages/row-model/src/__tests__/transactions.test.ts index 655219525..baa6b51d2 100644 --- a/packages/row-model/src/__tests__/transactions.test.ts +++ b/packages/row-model/src/__tests__/transactions.test.ts @@ -5,6 +5,7 @@ import { createColumnHelper, createLocalRowModel, } from "../index"; +import { createInstrumentedLocalRowModel } from "../diagnostics"; interface Row { id: number; @@ -19,6 +20,48 @@ const columns = [ ] as const; describe("flat transactions", () => { + test("does not churn lowered date extrema for an unrelated row update", () => { + interface DatedRow { + readonly id: number; + readonly group: string; + readonly occurredOn: string | null; + readonly label: string; + } + const dated = createColumnHelper(); + const datedColumns = [ + dated.accessor("group", { type: "text" }), + dated.accessor("occurredOn", { + type: "date", + aggregate: "min", + }), + ] as const; + const { model, diagnostics } = createInstrumentedLocalRowModel({ + rows: [ + { id: 1, group: "A", occurredOn: "2026-08-18", label: "one" }, + { id: 2, group: "A", occurredOn: "2025-01-01", label: "two" }, + ], + columns: datedColumns, + query: { + filters: [], + sort: [], + rowGroups: [{ columnId: "group" }], + }, + }); + const before = model.getState().snapshot.rowAt(0); + if (before?.kind !== "group") throw new Error("missing group"); + const aggregateRoot = before.aggregates; + diagnostics.resetWork(); + + model.applyTransaction({ + update: [{ id: 1, changes: { label: "renamed" } }], + }); + + const after = model.getState().snapshot.rowAt(0); + expect(after?.kind).toBe("group"); + expect(after?.kind === "group" && after.aggregates).toBe(aggregateRoot); + expect(diagnostics.read().work.aggregateMerges).toBe(0); + }); + test("rejects nested commands during proxy capture without losing the outer commit", () => { const model = createLocalRowModel({ rows: [{ id: 1, label: "one", score: 1 }], diff --git a/packages/row-model/src/__tests__/transitions.test.ts b/packages/row-model/src/__tests__/transitions.test.ts index 265855ceb..d0b388c82 100644 --- a/packages/row-model/src/__tests__/transitions.test.ts +++ b/packages/row-model/src/__tests__/transitions.test.ts @@ -124,6 +124,56 @@ function createModel(options: { } describe("cooperative query and derivation transitions", () => { + test("switches min/max lowering when column semantics transition to and from date", async () => { + interface DatedRow { + readonly id: number; + readonly team: string; + readonly asOf: string | null; + } + const dated = createColumnHelper(); + const datedColumns = [ + dated.accessor("team", { type: "text" }), + dated.accessor("asOf", { type: "date", aggregate: "min" }), + ] as const; + const scheduler = new ManualScheduler(); + const model = createLocalRowModel({ + rows: [ + { id: 1, team: "A", asOf: "2026-08-18" }, + { id: 2, team: "A", asOf: "2025-01-01" }, + ], + columns: datedColumns, + query: { + filters: [], + sort: [], + rowGroups: [{ columnId: "team" }], + }, + transitionScheduler: scheduler, + transitionClock: tickingClock(), + transitionBudgetMs: 1, + transitionMaxUnitsPerSlice: 1, + }); + const aggregate = () => { + const group = model.getState().snapshot.rowAt(0); + if (group?.kind !== "group") throw new Error("missing team group"); + return group.aggregates.asOf; + }; + + expect(aggregate()).toBe("2025-01-01"); + const numeric = model.setDerivations([ + datedColumns[0], + { ...datedColumns[1], type: "number" }, + ] as never); + scheduler.flushAll(); + await expect(numeric.finished).resolves.toBe(1); + expect(aggregate()).toBeNull(); + + const calendarDate = model.setDerivations(datedColumns); + scheduler.flushAll(); + await expect(calendarDate.finished).resolves.toBe(2); + expect(aggregate()).toBe("2025-01-01"); + expect(calendarDate.requestedDerivations[1]?.aggregate).toBe("min"); + }); + test.each([ ["text", "date"], ["date", "text"], diff --git a/packages/row-model/src/__tests__/types.test.ts b/packages/row-model/src/__tests__/types.test.ts index 1a3a0dc1f..71e9d5490 100644 --- a/packages/row-model/src/__tests__/types.test.ts +++ b/packages/row-model/src/__tests__/types.test.ts @@ -2,6 +2,8 @@ import { expect, test } from "vitest"; import { createColumnHelper, + type PretableAggregateOutputOf, + type PretableBuiltinAggregate, type ColumnIdOf, type ColumnsOf, type PretableAggregator, @@ -37,6 +39,19 @@ type Equal = : false; type Expect = T; +type _dateBuiltinContract = Expect< + Equal< + PretableBuiltinAggregate, + "count" | "min" | "max" + > +>; +type _dateExtremumOutput = Expect< + Equal, string | null> +>; +type _numberExtremumOutput = Expect< + Equal, number | null> +>; + interface Holding { id: number; sector: string; @@ -62,6 +77,60 @@ const columns = [ }), ] as const; +interface CalendarHolding { + id: number; + openedOn: string | null; + closedOn: string; + label: string; + legacyDate: Date; +} +const calendarColumn = createColumnHelper(); +const calendarColumns = [ + calendarColumn.accessor("openedOn", { + type: "date", + aggregate: "min", + formatAggregate: ({ value }) => { + const exact: string | null = value; + return exact ?? ""; + }, + }), + calendarColumn.accessor("closedOn", { + type: "date", + aggregate: "max", + }), +] as const; +const calendarAggregates: PretableGroupRow< + typeof calendarColumns +>["aggregates"] = { + openedOn: "2025-01-01", + closedOn: null, +}; +calendarColumn.accessor("openedOn", { + type: "date", + // @ts-expect-error date columns reject sum + aggregate: "sum", +}); +calendarColumn.accessor("closedOn", { + type: "date", + // @ts-expect-error date columns reject avg + aggregate: "avg", +}); +calendarColumn.accessor("label", { + type: "text", + // @ts-expect-error text columns reject min + aggregate: "min", +}); +calendarColumn.accessor("legacyDate", { + // @ts-expect-error Date-valued extrema are not calendar-date columns + type: "date", + // @ts-expect-error Date-valued extrema are not admitted as built-ins + aggregate: "max", +}); +void calendarAggregates; +void (null as unknown as _dateBuiltinContract); +void (null as unknown as _dateExtremumOutput); +void (null as unknown as _numberExtremumOutput); + const computedColumn = column.accessor( "marketValue", (row) => row.quantity * 10, diff --git a/packages/row-model/src/calendar-date-aggregates.ts b/packages/row-model/src/calendar-date-aggregates.ts new file mode 100644 index 000000000..bcaadbe84 --- /dev/null +++ b/packages/row-model/src/calendar-date-aggregates.ts @@ -0,0 +1,70 @@ +import { isValidDateValue } from "@pretable-internal/calendar-date"; + +import type { PretableAggregator } from "./column-types"; + +type CalendarDateAggregator = PretableAggregator< + object, + unknown, + string | null, + string | null +>; + +function calendarDateExtremum(kind: "min" | "max"): CalendarDateAggregator { + return Object.freeze({ + init: () => null, + accumulate: (accumulator: string | null, value: unknown) => { + if (!isValidDateValue(value)) return accumulator; + if (accumulator === null) return value; + return kind === "min" + ? value < accumulator + ? value + : accumulator + : value > accumulator + ? value + : accumulator; + }, + merge: (left: string | null, right: string | null) => { + if (left === null) return right; + if (right === null) return left; + return kind === "min" + ? left < right + ? left + : right + : left > right + ? left + : right; + }, + finalize: (accumulator: string | null) => accumulator, + }); +} + +const calendarDateMin = calendarDateExtremum("min"); +const calendarDateMax = calendarDateExtremum("max"); + +export function lowerCalendarDateAggregate( + columnType: string, + aggregate: string | PretableAggregator, +): string | PretableAggregator { + if (columnType !== "date") return aggregate; + if (aggregate === "min") { + return calendarDateMin as PretableAggregator< + object, + unknown, + unknown, + unknown + >; + } + if (aggregate === "max") { + return calendarDateMax as PretableAggregator< + object, + unknown, + unknown, + unknown + >; + } + return aggregate; +} + +export function isCalendarDateAggregate(aggregate: unknown): boolean { + return aggregate === calendarDateMin || aggregate === calendarDateMax; +} diff --git a/packages/row-model/src/column-types.ts b/packages/row-model/src/column-types.ts index a716f1201..fe8925494 100644 --- a/packages/row-model/src/column-types.ts +++ b/packages/row-model/src/column-types.ts @@ -42,17 +42,25 @@ export interface PretableAggregator< } /** @public */ -export type PretableBuiltinAggregate = +export type PretableBuiltinAggregate = | "count" - | ([NonNullable] extends [never] - ? never - : NonNullable extends number + | (TType extends "number" + ? NonNullable extends number ? "sum" | "avg" | "min" | "max" + : never + : TType extends "date" + ? NonNullable extends string + ? "min" | "max" + : never : never); /** @public */ -export type PretableAggregateSpec = - | PretableBuiltinAggregate +export type PretableAggregateSpec< + TRow extends object, + TValue, + TType extends PretableColumnType, +> = + | PretableBuiltinAggregate | PretableCompatibleAggregator; /** @@ -112,13 +120,20 @@ export interface PretableAggregateFormatInput { } /** @public */ -export type PretableAggregateOutputOf = TAggregate extends { +export type PretableAggregateOutputOf< + TAggregate, + TType extends PretableColumnType, +> = TAggregate extends { readonly finalize: (accumulator: never) => infer TOutput; } ? TOutput - : TAggregate extends "sum" | "avg" | "min" | "max" | "count" - ? number | null - : never; + : TAggregate extends "min" | "max" + ? TType extends "date" + ? string | null + : number | null + : TAggregate extends "sum" | "avg" | "count" + ? number | null + : never; /** @public */ export interface PretableColumnDefinition< @@ -152,7 +167,7 @@ export interface PretableColumnDefinition< ) => string; readonly formatAggregate?: ( input: PretableAggregateFormatInput< - PretableAggregateOutputOf, + PretableAggregateOutputOf, PretableColumnDefinition >, ) => string; @@ -206,7 +221,7 @@ export type PretableColumnOptions< TId extends string, TValue, TType extends PretableColumnTypeFor, - TAggregate extends PretableAggregateSpec | undefined, + TAggregate extends PretableAggregateSpec | undefined, > = { readonly type: TType; readonly header?: string; @@ -226,7 +241,7 @@ export type PretableColumnOptions< >; }) => string; readonly formatAggregate?: (input: { - readonly value: PretableAggregateOutputOf; + readonly value: PretableAggregateOutputOf; readonly column: PretableColumnCallbackContext< TRow, TId, @@ -255,11 +270,16 @@ export interface PretableColumnHelper { : never), TValue = PretableUninferredColumnValue, const TAggregate extends - | PretableAggregateSpec> + | PretableAggregateSpec, TType> | ([TValue] extends [PretableUninferredColumnValue] ? [ReturnType] extends [never] ? never - : PretableAggregateSpec | "sum" | "avg" | "min" | "max" + : | PretableAggregateSpec + | (TType extends "number" + ? "sum" | "avg" | "min" | "max" + : TType extends "date" + ? "min" | "max" + : never) : never) | undefined = undefined, >( @@ -284,7 +304,7 @@ export interface PretableColumnHelper { >; }) => string; readonly formatAggregate?: (input: { - readonly value: PretableAggregateOutputOf; + readonly value: PretableAggregateOutputOf; readonly column: PretableColumnCallbackContext< TRow, TId, @@ -307,7 +327,7 @@ export interface PretableColumnHelper { const TKey extends Extract, const TType extends PretableColumnTypeFor, const TAggregate extends - PretableAggregateSpec | undefined = undefined, + PretableAggregateSpec | undefined = undefined, >( key: TKey, options: PretableColumnOptions, @@ -416,9 +436,10 @@ export type ColumnAggregateValueOf< > = TColumns extends readonly (infer TColumn)[] ? TColumn extends { readonly id: TColumnId; + readonly type: infer TType extends PretableColumnType; readonly aggregate?: infer TAggregate; } - ? PretableAggregateOutputOf + ? PretableAggregateOutputOf : never : never; @@ -429,14 +450,18 @@ export type PretableAggregatesFor = Prettify<{ ? TItem : never as TColumn extends { readonly id: infer TId extends string; + readonly type: PretableColumnType; readonly aggregate?: infer TAggregate; } ? [TAggregate] extends [undefined] ? never : TId : never - ]: TColumn extends { readonly aggregate?: infer TAggregate } - ? PretableAggregateOutputOf + ]: TColumn extends { + readonly type: infer TType extends PretableColumnType; + readonly aggregate?: infer TAggregate; + } + ? PretableAggregateOutputOf : never; }>; @@ -584,22 +609,37 @@ export interface PretableCompatibleAggregator< }["bivarianceHack"]; } +type CompatibleBuiltinAggregate< + TValue, + TType extends PretableColumnType, + TAggregate, + TName extends PretableBuiltinAggregate = + PretableBuiltinAggregate, +> = TName extends unknown + ? [PretableAggregateOutputOf] extends [ + PretableAggregateOutputOf, + ] + ? [PretableAggregateOutputOf] extends [ + PretableAggregateOutputOf, + ] + ? TName + : never + : never + : never; + /** @public */ export type PretableCompatibleAggregateSpec< TRow extends object, TValue, + TType extends PretableColumnType, TAggregate, > = [TAggregate] extends [undefined] ? undefined - : | ([PretableAggregateOutputOf] extends [number | null] - ? [number | null] extends [PretableAggregateOutputOf] - ? PretableBuiltinAggregate - : never - : never) + : | CompatibleBuiltinAggregate | PretableCompatibleAggregator< TRow, TValue, - PretableAggregateOutputOf + PretableAggregateOutputOf >; /** @public */ @@ -618,6 +658,7 @@ export interface PretableColumnDerivation< readonly aggregate?: PretableCompatibleAggregateSpec< TRow, TValue, + TType, TAggregate >; readonly ["~pretableColumn"]: { @@ -628,6 +669,7 @@ export interface PretableColumnDerivation< readonly aggregate: PretableCompatibleAggregateSpec< TRow, TValue, + TType, TAggregate >; }; diff --git a/packages/row-model/src/compiled-query.ts b/packages/row-model/src/compiled-query.ts index d02cd7e2a..5f46a042b 100644 --- a/packages/row-model/src/compiled-query.ts +++ b/packages/row-model/src/compiled-query.ts @@ -6,10 +6,12 @@ import { import type { ColumnDescriptorOf, ColumnIdOf, + PretableAggregator, PretableDerivationsFor, PretableQueryFor, PretableRowId, } from "./column-types"; +import { lowerCalendarDateAggregate } from "./calendar-date-aggregates"; import { PretableRowModelError } from "./errors"; import type { AggregateTreeLeaf } from "./persistent/aggregate-tree"; @@ -48,13 +50,18 @@ type CompiledAggregateLeafForDescriptor< readonly row: infer TRow extends object; readonly id: infer TColumnId extends string; readonly value: infer TValue; + readonly type: infer TType; readonly aggregate: infer TAggregate; } ? [TAggregate] extends [undefined] ? never : { readonly columnId: TColumnId; - readonly aggregate: TAggregate; + readonly aggregate: TType extends "date" + ? TAggregate extends "min" | "max" + ? PretableAggregator + : TAggregate + : TAggregate; readonly allLeaf: AggregateTreeLeaf< TRowId, TRow, @@ -316,7 +323,8 @@ const FILTER_OPERATORS = { } as const; const BUILTIN_AGGREGATES = new Set(["sum", "avg", "min", "max", "count"]); -const NUMERIC_AGGREGATES = new Set(["sum", "avg", "min", "max"]); +const NUMBER_ONLY_AGGREGATES = new Set(["sum", "avg"]); +const EXTREMA_AGGREGATES = new Set(["min", "max"]); const COLUMN_TYPES = new Set(["text", "number", "date", "enum", "boolean"]); interface CapturedCompileInput { @@ -630,13 +638,24 @@ function validateAggregate(column: RuntimeColumn, path: string): void { if (typeof aggregate === "string") { if (!BUILTIN_AGGREGATES.has(aggregate)) fail(`unknown aggregate ${aggregate}`, path, column.id); - if (NUMERIC_AGGREGATES.has(aggregate) && column.type !== "number") { + if (NUMBER_ONLY_AGGREGATES.has(aggregate) && column.type !== "number") { fail( `numeric aggregate ${aggregate} requires a number column`, path, column.id, ); } + if ( + EXTREMA_AGGREGATES.has(aggregate) && + column.type !== "number" && + column.type !== "date" + ) { + fail( + `aggregate ${aggregate} requires a number or date column`, + path, + column.id, + ); + } return; } if (!aggregate || typeof aggregate !== "object") @@ -1456,7 +1475,11 @@ class CompiledQueryPlan }); return Object.freeze({ columnId: column.id, - aggregate: column.aggregate, + aggregate: lowerCalendarDateAggregate( + column.type, + column.aggregate as + string | PretableAggregator, + ) as string | RuntimeAggregator, allLeaf, filteredLeaf: filterPasses ? allLeaf : undefined, }); diff --git a/packages/row-model/src/transaction-draft.ts b/packages/row-model/src/transaction-draft.ts index 0f8ea27fd..8e077351f 100644 --- a/packages/row-model/src/transaction-draft.ts +++ b/packages/row-model/src/transaction-draft.ts @@ -1,4 +1,5 @@ import type { CompiledQuery } from "./compiled-query"; +import { isCalendarDateAggregate } from "./calendar-date-aggregates"; import { attachChangeOperationDiagnosticsForTesting, getChangeOperationDiagnosticsForTesting, @@ -393,7 +394,10 @@ function sameGroupIndexContribution< ) { return false; } - if (typeof previousLeaf.aggregate !== "string") { + if ( + typeof previousLeaf.aggregate !== "string" && + !isCalendarDateAggregate(previousLeaf.aggregate) + ) { return ( Object.is(previousLeaf.allLeaf.row, nextLeaf.allLeaf.row) && Object.is(previousLeaf.allLeaf.value, nextLeaf.allLeaf.value) && diff --git a/type-tests/core/query-and-aggregate.types.ts b/type-tests/core/query-and-aggregate.types.ts index c5ef11ac8..6e8668db5 100644 --- a/type-tests/core/query-and-aggregate.types.ts +++ b/type-tests/core/query-and-aggregate.types.ts @@ -1,5 +1,8 @@ import { createColumnHelper, + type PretableAggregator, + type PretableAggregateOutputOf, + type PretableBuiltinAggregate, type PretableDerivationsFor, type PretableFilterOperandFor, type PretableFilterFor, @@ -25,6 +28,149 @@ const query: PretableQueryFor = { }; void query; +type _NumberBuiltins = Expect< + Equal< + PretableBuiltinAggregate, + "count" | "sum" | "avg" | "min" | "max" + > +>; +type _DateBuiltins = Expect< + Equal< + PretableBuiltinAggregate, + "count" | "min" | "max" + > +>; +type _TextBuiltins = Expect< + Equal, "count"> +>; +type _NumberMinOutput = Expect< + Equal, number | null> +>; +type _DateMinOutput = Expect< + Equal, string | null> +>; +type _DateMaxOutput = Expect< + Equal, string | null> +>; +type _CountOutput = Expect< + Equal, number | null> +>; + +interface DatedAggregateRow { + id: number; + earliest: string | null; + latest: string; + label: string; + timestamp: Date; +} +const datedAggregateColumn = createColumnHelper(); +const datedAggregateColumns = [ + datedAggregateColumn.accessor("earliest", { + type: "date", + aggregate: "min", + formatAggregate: ({ value }) => { + const exact: string | null = value; + return exact ?? ""; + }, + }), + datedAggregateColumn.accessor("latest", { + type: "date", + aggregate: "max", + formatAggregate: ({ value }) => { + const exact: string | null = value; + return exact ?? ""; + }, + }), +] as const; +const dateSpan = { + init: () => ({ first: null as string | null, last: null as string | null }), + accumulate: ( + accumulator: { + readonly first: string | null; + readonly last: string | null; + }, + value: string | null, + ) => ({ + first: accumulator.first ?? value, + last: value ?? accumulator.last, + }), + merge: ( + left: { readonly first: string | null; readonly last: string | null }, + right: { readonly first: string | null; readonly last: string | null }, + ) => ({ + first: left.first ?? right.first, + last: right.last ?? left.last, + }), + finalize: (accumulator: { + readonly first: string | null; + readonly last: string | null; + }) => ({ ...accumulator }), +} satisfies PretableAggregator< + DatedAggregateRow, + string | null, + { readonly first: string | null; readonly last: string | null }, + { readonly first: string | null; readonly last: string | null } +>; +const structuralFinalizeColumn = datedAggregateColumn.accessor( + "dateSpan", + (row) => row.earliest, + { + type: "date", + aggregate: dateSpan, + formatAggregate: ({ value }) => { + const exact: { + readonly first: string | null; + readonly last: string | null; + } = value; + return `${exact.first ?? ""}:${exact.last ?? ""}`; + }, + }, +); +type _DatedAggregateValues = Expect< + Equal< + PretableGroupRow["aggregates"], + { readonly earliest: string | null; readonly latest: string | null } + > +>; + +datedAggregateColumn.accessor("earliest", { + type: "date", + // @ts-expect-error date columns reject numeric sum + aggregate: "sum", +}); +datedAggregateColumn.accessor("latest", { + type: "date", + // @ts-expect-error date columns reject numeric average + aggregate: "avg", +}); +datedAggregateColumn.accessor("label", { + type: "text", + // @ts-expect-error text columns do not gain string extrema + aggregate: "min", +}); +datedAggregateColumn.accessor("label", { + type: "text", + // @ts-expect-error text columns do not gain string extrema + aggregate: "max", +}); +datedAggregateColumn.accessor("timestamp", { + // @ts-expect-error Date-valued columns cannot opt into calendar-date extrema + type: "date", + // @ts-expect-error Date-valued extrema are not admitted as built-ins + aggregate: "min", +}); + +void datedAggregateColumns; +void structuralFinalizeColumn; +void (null as unknown as _NumberBuiltins); +void (null as unknown as _DateBuiltins); +void (null as unknown as _TextBuiltins); +void (null as unknown as _NumberMinOutput); +void (null as unknown as _DateMinOutput); +void (null as unknown as _DateMaxOutput); +void (null as unknown as _CountOutput); +void (null as unknown as _DatedAggregateValues); + const invalidNumberOperator: PretableQueryFor = { filters: [ // @ts-expect-error number columns reject text-only operators and operands diff --git a/type-tests/react/model-inference.types.tsx b/type-tests/react/model-inference.types.tsx index 9959bf9c6..a362c860d 100644 --- a/type-tests/react/model-inference.types.tsx +++ b/type-tests/react/model-inference.types.tsx @@ -376,6 +376,60 @@ const sameKeyModel = createLocalRowModel({ rows: [{ id: "p1", symbol: "PRE", quantity: 2, price: 10 }], columns: [sameKeyComputed] as const, }); + +interface CalendarPosition { + id: string; + occurredOn: string | null; + settledOn: string; + label: string; + legacyDate: Date; +} +const calendarColumn = createColumnHelper(); +const calendarColumns = usePretableColumns( + () => + [ + calendarColumn.accessor("occurredOn", { + type: "date", + aggregate: "min", + formatAggregate: ({ value, column }) => { + const exactValue: string | null = value; + const exactId: "occurredOn" = column.id; + return `${exactId}:${exactValue ?? ""}`; + }, + }), + calendarColumn.accessor("settledOn", { + type: "date", + aggregate: "max", + formatAggregate: ({ value }) => { + const exactValue: string | null = value; + return exactValue ?? ""; + }, + }), + ] as const, + [], +); +calendarColumn.accessor("occurredOn", { + type: "date", + // @ts-expect-error React date columns reject sum + aggregate: "sum", +}); +calendarColumn.accessor("settledOn", { + type: "date", + // @ts-expect-error React date columns reject avg + aggregate: "avg", +}); +calendarColumn.accessor("label", { + type: "text", + // @ts-expect-error React text columns reject extrema + aggregate: "min", +}); +calendarColumn.accessor("legacyDate", { + type: "date", + // @ts-expect-error Date-valued extrema are not admitted as built-ins + aggregate: "max", +}); +void calendarColumns; + usePretable({ model: sameKeyModel, // @ts-expect-error model overrides cannot edit same-key computed columns without setValue From e4490c09ee1de906bcdccecbb35ad7add28ac9ae Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 19:09:02 -0700 Subject: [PATCH 09/16] feat(react): make date input canonical --- packages/react/package.json | 1 + .../src/__tests__/date-cell-editor.test.tsx | 178 ++++++++- .../react/src/__tests__/date-utils.test.ts | 331 ++++------------ .../filter-menu-row-model-boundary.test.ts | 28 ++ .../__tests__/filter-menu-surface.test.tsx | 44 +++ .../react/src/__tests__/filter-menu.test.tsx | 22 ++ .../src/__tests__/filter-operators.test.ts | 20 + .../src/__tests__/paste-surface.test.tsx | 47 +++ .../pretable-surface-editing.test.tsx | 352 +++++++++++++++++- .../react/src/__tests__/type-parsing.test.ts | 29 +- .../use-cell-edit-controller.test.ts | 36 +- packages/react/src/editors/DateCellEditor.tsx | 252 +++++++------ packages/react/src/editors/date-utils.ts | 217 +++-------- packages/react/src/editors/type-parsing.ts | 19 +- .../react/src/filter-menu/filter-operators.ts | 6 +- packages/react/src/pretable-surface.tsx | 154 +++++--- .../react/src/use-cell-edit-controller.ts | 24 +- packages/react/tsconfig.build.json | 2 + packages/react/tsconfig.json | 1 + packages/react/tsconfig.typecheck.json | 2 + packages/react/vitest.config.ts | 4 + pnpm-lock.yaml | 3 + 22 files changed, 1154 insertions(+), 618 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 3b25b993b..26310a02d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -42,6 +42,7 @@ "@pretable/ui": "workspace:*" }, "devDependencies": { + "@pretable-internal/calendar-date": "workspace:*", "@pretable-internal/grid-core": "workspace:*", "@pretable-internal/row-model": "workspace:*", "@pretable-internal/renderer-dom": "workspace:*", diff --git a/packages/react/src/__tests__/date-cell-editor.test.tsx b/packages/react/src/__tests__/date-cell-editor.test.tsx index 22d09372d..a42dea15d 100644 --- a/packages/react/src/__tests__/date-cell-editor.test.tsx +++ b/packages/react/src/__tests__/date-cell-editor.test.tsx @@ -142,7 +142,7 @@ describe("DateCellEditor (via dispatcher)", () => { expect(notPrevented).toBe(false); }); - it("blur commits a valid date and reverts an invalid one", () => { + it("blur commits an untouched canonical date and cancels an invalid seed", () => { const commit = vi.fn(); const cancel = vi.fn(); render(); @@ -162,6 +162,175 @@ describe("DateCellEditor (via dispatcher)", () => { expect(commit2).not.toHaveBeenCalled(); }); + it("commits an untouched canonical null but cancels an untouched empty string", () => { + const commit = vi.fn(); + const cancel = vi.fn(); + render( + , + ); + fireEvent.blur(screen.getByRole("textbox")); + expect(commit).toHaveBeenCalledWith(); + expect(cancel).not.toHaveBeenCalled(); + cleanup(); + + const emptyCommit = vi.fn(); + const emptyCancel = vi.fn(); + render( + , + ); + fireEvent.blur(screen.getByRole("textbox")); + expect(emptyCancel).toHaveBeenCalledWith(); + expect(emptyCommit).not.toHaveBeenCalled(); + }); + + it.each([ + ["Date", new Date("2026-08-06T00:00:00Z")], + ["epoch", Date.UTC(2026, 7, 6)], + ["datetime", "2026-08-06T00:00:00Z"], + ["padded", " 2026-08-06 "], + ["empty", ""], + ["whitespace", " "], + ["undefined", undefined], + ])( + "keeps an untouched raw %s seed visible and cancels without mutation", + (_label, draft) => { + const setDraft = vi.fn(); + const commit = vi.fn(); + const cancel = vi.fn(); + render( + , + ); + expect(screen.getByRole("textbox")).toHaveValue(String(draft ?? "")); + expect(setDraft).not.toHaveBeenCalled(); + fireEvent.blur(screen.getByRole("textbox")); + expect(cancel).toHaveBeenCalledWith(); + expect(commit).not.toHaveBeenCalled(); + }, + ); + + it("runs a custom parser path on blur only after a real user change", () => { + const commit = vi.fn(); + const cancel = vi.fn(); + const parseEditValue = vi.fn((draft: string) => draft); + const input = makeInput({ + draft: "legacy", + value: "legacy", + column: { + id: "due", + type: "date", + editable: true, + parseEditValue, + }, + commit, + cancel, + }); + const view = render(); + fireEvent.blur(screen.getByRole("textbox")); + expect(cancel).toHaveBeenCalledTimes(1); + expect(commit).not.toHaveBeenCalled(); + + view.rerender(); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "legacy changed" }, + }); + fireEvent.blur(screen.getByRole("textbox")); + expect(commit).toHaveBeenCalledWith(); + }); + + it("synchronizes the calendar and selection to a controlled canonical rerender", () => { + const input = makeInput(); + const view = render(); + view.rerender( + , + ); + expect(screen.getByRole("textbox")).toHaveValue("9999-12-31"); + expect(screen.getByText("December 9999")).toBeInTheDocument(); + expect(dayCell("9999-12-31")).toHaveAttribute("aria-selected", "true"); + expect(activeDay()).toBe(dayCell("9999-12-31").id); + expect(screen.getByRole("button", { name: "Next month" })).toBeDisabled(); + }); + + it("clears selection for a controlled invalid rerender without replacing its useful cursor", () => { + const setDraft = vi.fn(); + const commit = vi.fn(); + const input = makeInput({ setDraft, commit }); + const view = render(); + view.rerender(); + expect(screen.getByText("August 2026")).toBeInTheDocument(); + expect(dayCell("2026-08-06")).not.toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + expect(setDraft).not.toHaveBeenCalled(); + expect(commit).toHaveBeenCalledWith("down"); + }); + + it("marks arrow and page navigation as modified so blur commits it", () => { + const setDraft = vi.fn(); + const commit = vi.fn(); + render(); + const box = screen.getByRole("textbox"); + fireEvent.keyDown(box, { key: "ArrowRight" }); + fireEvent.blur(box); + expect(setDraft).toHaveBeenLastCalledWith("2026-08-07"); + expect(commit).toHaveBeenCalledWith(); + }); + + it("clamps navigation and buttons at the supported boundaries", () => { + const minSetDraft = vi.fn(); + render( + , + ); + const minBox = screen.getByRole("textbox"); + expect( + screen.getByRole("button", { name: "Previous month" }), + ).toBeDisabled(); + fireEvent.keyDown(minBox, { key: "ArrowLeft" }); + fireEvent.keyDown(minBox, { key: "PageUp" }); + fireEvent.click(screen.getByRole("button", { name: "Previous month" })); + expect(minSetDraft).not.toHaveBeenCalled(); + expect( + screen + .getAllByRole("gridcell") + .filter((cell) => cell.hasAttribute("aria-disabled")), + ).not.toHaveLength(0); + cleanup(); + + const maxSetDraft = vi.fn(); + render( + , + ); + const maxBox = screen.getByRole("textbox"); + expect(screen.getByRole("button", { name: "Next month" })).toBeDisabled(); + fireEvent.keyDown(maxBox, { key: "ArrowRight" }); + fireEvent.keyDown(maxBox, { key: "PageDown" }); + fireEvent.click(screen.getByRole("button", { name: "Next month" })); + expect(maxSetDraft).not.toHaveBeenCalled(); + }); + it("Escape cancels; Tab commits right", () => { const commit = vi.fn(); const cancel = vi.fn(); @@ -173,7 +342,7 @@ describe("DateCellEditor (via dispatcher)", () => { expect(cancel).toHaveBeenCalled(); }); - it("re-seeds the field as ISO when the cell holds a Date instance", () => { + it("does not re-seed the field when the cell holds a Date instance", () => { const setDraft = vi.fn(); render( { })} />, ); - expect(setDraft).toHaveBeenCalledWith("2026-08-06"); + expect(setDraft).not.toHaveBeenCalled(); + expect(screen.getByRole("textbox")).toHaveValue( + String(new Date(Date.UTC(2026, 7, 6))), + ); }); }); diff --git a/packages/react/src/__tests__/date-utils.test.ts b/packages/react/src/__tests__/date-utils.test.ts index 06facc863..c6d15bbe2 100644 --- a/packages/react/src/__tests__/date-utils.test.ts +++ b/packages/react/src/__tests__/date-utils.test.ts @@ -1,278 +1,81 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { - addDaysIso, - addMonthsIso, - isValidIsoDate, - monthLabel, - monthMatrix, - parseIsoDate, - toIsoDate, -} from "../editors/date-utils"; +import { monthLabel, monthMatrix, todayIso } from "../editors/date-utils"; -describe("date-utils", () => { - it("validates strict yyyy-mm-dd only", () => { - expect(isValidIsoDate("2026-08-06")).toBe(true); - expect(isValidIsoDate("2026-8-6")).toBe(false); // not zero-padded - expect(isValidIsoDate("08/06/2026")).toBe(false); // locale format - expect(isValidIsoDate("nope")).toBe(false); - expect(isValidIsoDate("")).toBe(false); - }); - - it("rejects calendar overflow instead of rolling forward", () => { - expect(isValidIsoDate("2026-02-30")).toBe(false); - expect(isValidIsoDate("2026-13-01")).toBe(false); - expect(isValidIsoDate("2024-02-29")).toBe(true); // leap year - expect(isValidIsoDate("2026-02-29")).toBe(false); - }); - - it("normalises cell values of every shape to ISO", () => { - expect(toIsoDate("2026-08-06")).toBe("2026-08-06"); - expect(toIsoDate(new Date(Date.UTC(2026, 7, 6)))).toBe("2026-08-06"); - expect(toIsoDate(Date.UTC(2026, 7, 6))).toBe("2026-08-06"); - expect(toIsoDate("2026-08-06T12:34:56Z")).toBe("2026-08-06"); - expect(toIsoDate(null)).toBe(""); - expect(toIsoDate("")).toBe(""); - expect(toIsoDate("not a date")).toBe(""); - }); - - it("normalises datetime strings that carry an explicit zone", () => { - expect(toIsoDate("2026-08-06T12:00:00Z")).toBe("2026-08-06"); - expect(toIsoDate("2026-08-06T12:00Z")).toBe("2026-08-06"); - expect(toIsoDate("2026-08-06T12:00:00.500Z")).toBe("2026-08-06"); - // The zone is honoured, so the UTC day can differ from the literal date — - // this matches the engine's `toDayMs`, which also reads UTC getters. - expect(toIsoDate("2026-08-06T00:00:00+02:00")).toBe("2026-08-05"); - expect(toIsoDate("2026-08-06T00:00:00+0200")).toBe("2026-08-05"); - expect(toIsoDate("2026-08-06T23:00:00-11:00")).toBe("2026-08-07"); - }); - - it("reads a zone-less datetime as its literal date portion", () => { - // No zone → interpreted as UTC, so the day is viewer-independent. - expect(toIsoDate("2026-08-06T00:00:00")).toBe("2026-08-06"); - expect(toIsoDate("2026-08-06T23:59:59")).toBe("2026-08-06"); - expect(toIsoDate("2026-08-06T13:45")).toBe("2026-08-06"); - expect(toIsoDate("2026-08-06T13:45:00.500")).toBe("2026-08-06"); - }); - - it("rejects ambiguous strings rather than resolving them in local time", () => { - // Each of these would resolve differently for viewers in different zones. - expect(toIsoDate("2026-8-6")).toBe(""); - expect(toIsoDate("08/06/2026")).toBe(""); - expect(toIsoDate("August 6, 2026")).toBe(""); - }); - - it("rejects calendar overflow rather than rolling it forward", () => { - expect(toIsoDate("2026-02-30")).toBe(""); - expect(toIsoDate("2026-02-30T00:00:00Z")).toBe(""); - expect(toIsoDate("2026-13-01")).toBe(""); - }); - - it("rejects timestamps outside the Date range instead of formatting NaN", () => { - expect(toIsoDate(8.64e15 + 1)).toBe(""); // one ms past the max Date - expect(toIsoDate(1.78e18)).toBe(""); // a nanosecond epoch - expect(toIsoDate(Number.POSITIVE_INFINITY)).toBe(""); - expect(toIsoDate(new Date(Number.NaN))).toBe(""); - }); - - it("rejects years outside 0000–9999, the 4-digit ISO contract", () => { - // `new Date(8.64e15)` is a real instant, but it is year 275760: a 6-digit - // year no `yyyy-mm-dd` consumer here can read back, so it is not a date. - expect(toIsoDate(8.64e15)).toBe(""); - expect(toIsoDate(-8.64e15)).toBe(""); - expect(isValidIsoDate("275760-09-13")).toBe(false); - }); - - it("handles years 0000–0099 instead of mapping them to 19xx", () => { - // `Date.UTC(50, 0, 1)` is 1950; the helpers must not inherit that. - const yearFifty = new Date(Date.UTC(2000, 0, 1)); - yearFifty.setUTCFullYear(50); - expect(toIsoDate(yearFifty)).toBe("0050-01-01"); - expect(isValidIsoDate("0050-01-01")).toBe(true); - expect(parseIsoDate("0050-01-01")).toBe(yearFifty.getTime()); - // Year 0 is a leap year in the proleptic Gregorian calendar 1900 is not. - expect(isValidIsoDate("0000-02-29")).toBe(true); - expect(isValidIsoDate("0001-02-29")).toBe(false); - // Calendar navigation stays in the same century, too. - expect(addDaysIso("0050-01-01", -1)).toBe("0049-12-31"); - expect(addMonthsIso("0050-01-31", 1)).toBe("0050-02-28"); - expect(addMonthsIso("0050-12-15", 1)).toBe("0051-01-15"); - expect(monthLabel("0050-01-01")).toBe("January 50"); - expect(monthMatrix("0050-01-01")[0][0].iso).toBe("0049-12-27"); - }); - - it("adds days across month and year boundaries", () => { - expect(addDaysIso("2026-08-06", 1)).toBe("2026-08-07"); - expect(addDaysIso("2026-08-31", 1)).toBe("2026-09-01"); - expect(addDaysIso("2026-01-01", -1)).toBe("2025-12-31"); - expect(addDaysIso("2026-08-06", 7)).toBe("2026-08-13"); - }); - - it("adds months, clamping to the target month's length", () => { - expect(addMonthsIso("2026-08-06", 1)).toBe("2026-09-06"); - expect(addMonthsIso("2026-01-31", 1)).toBe("2026-02-28"); - expect(addMonthsIso("2024-01-31", 1)).toBe("2024-02-29"); // leap - expect(addMonthsIso("2026-01-15", -1)).toBe("2025-12-15"); +describe("date editor calendar utilities", () => { + it("formats an English month label across the supported domain", () => { + expect(monthLabel("0000-01-01")).toBe("January 0"); + expect(monthLabel("2026-08-06")).toBe("August 2026"); + expect(monthLabel("9999-12-31")).toBe("December 9999"); + expect(monthLabel(" 2026-08-06")).toBe(""); }); - it("builds six Monday-start weeks covering the month", () => { + it("builds six Monday-first weeks with selectable valid filler days", () => { const weeks = monthMatrix("2026-08-06"); expect(weeks).toHaveLength(6); - expect(weeks.every((w) => w.length === 7)).toBe(true); - // 2026-08-01 is a Saturday, so the grid starts Monday 2026-07-27. - expect(weeks[0][0].iso).toBe("2026-07-27"); - expect(weeks[0][0].inMonth).toBe(false); - expect(weeks[0][5].iso).toBe("2026-08-01"); - expect(weeks[0][5].inMonth).toBe(true); - // Every in-month day appears exactly once. - const inMonth = weeks.flat().filter((d) => d.inMonth); - expect(inMonth).toHaveLength(31); + expect(weeks.every((week) => week.length === 7)).toBe(true); + expect(weeks[0]![0]).toEqual({ + iso: "2026-07-27", + day: 27, + inMonth: false, + disabled: false, + }); + expect(weeks.flat().find((day) => day.iso === "2026-08-06")).toEqual({ + iso: "2026-08-06", + day: 6, + inMonth: true, + disabled: false, + }); }); - it("labels the month of the given date", () => { - expect(monthLabel("2026-08-06")).toBe("August 2026"); - expect(monthLabel("2025-12-01")).toBe("December 2025"); + it("uses disabled null placeholders before the minimum date", () => { + const days = monthMatrix("0000-01-01").flat(); + expect(days.slice(0, 5)).toEqual( + Array.from({ length: 5 }, () => ({ + iso: null, + day: null, + inMonth: false, + disabled: true, + })), + ); + expect(days[5]).toEqual({ + iso: "0000-01-01", + day: 1, + inMonth: true, + disabled: false, + }); }); -}); - -/** - * Shared date case table. The twin lives in - * `packages/grid-core/src/__tests__/evaluate-filter-date.test.ts` and - * exercises the engine's `toDayMs`; this one exercises the editor's - * `toIsoDate`. The two helpers are deliberate duplicates of one rule - * (grid-core must not depend on @pretable/react), so the tables must stay - * identical — that is what stops the two halves from drifting apart. - * - * `day: null` means "not a date": the editor yields `""`, the engine matches - * nothing. - */ -/** - * 0050-01-01T13:45Z. Built with an explicit `setUTCFullYear` because - * `Date.UTC(50, …)` means *1950* — the two-digit mapping both twins have to - * work around. - */ -const YEAR_50 = new Date(Date.UTC(2000, 0, 1, 13, 45)); -YEAR_50.setUTCFullYear(50); - -const DATE_CASES: { label: string; cell: unknown; day: string | null }[] = [ - { label: "strict ISO date", cell: "2026-08-06", day: "2026-08-06" }, - { - label: "zone-less datetime → literal date portion (UTC)", - cell: "2026-08-06T13:45:00", - day: "2026-08-06", - }, - { - label: "zone-less datetime, no seconds", - cell: "2026-08-06T13:45", - day: "2026-08-06", - }, - { - label: "zone-less datetime at midnight", - cell: "2026-08-06T00:00:00", - day: "2026-08-06", - }, - { label: "Z datetime", cell: "2026-08-06T23:00:00Z", day: "2026-08-06" }, - { - label: "positive offset shifts the UTC day back", - cell: "2026-08-06T00:00:00+02:00", - day: "2026-08-05", - }, - { - label: "negative offset shifts the UTC day forward", - cell: "2026-08-06T23:00:00-11:00", - day: "2026-08-07", - }, - { - label: "offset without a colon", - cell: "2026-08-06T00:00:00+0200", - day: "2026-08-05", - }, - { - label: "space-separated datetime → literal date portion (UTC)", - cell: "2026-08-06 13:45:00", - day: "2026-08-06", - }, - { - label: "space-separated datetime with a Z zone", - cell: "2026-08-06 13:45:00Z", - day: "2026-08-06", - }, - { - label: "space-separated datetime whose offset shifts the UTC day", - cell: "2026-08-06 23:00:00-11:00", - day: "2026-08-07", - }, - { - label: "Date instance", - cell: new Date(Date.UTC(2026, 7, 6, 13, 45)), - day: "2026-08-06", - }, - { label: "epoch ms", cell: Date.UTC(2026, 7, 6, 23, 59), day: "2026-08-06" }, - { label: "year before 0100", cell: "0050-01-01", day: "0050-01-01" }, - { - label: "zoned datetime in a year before 0100", - cell: "0050-01-01T13:45:00Z", - day: "0050-01-01", - }, - { - label: "Date instance in a year before 0100", - cell: YEAR_50, - day: "0050-01-01", - }, - { label: "US/EU-ambiguous locale string", cell: "08/06/2026", day: null }, - { label: "unpadded ISO", cell: "2026-8-6", day: null }, - { label: "prose date", cell: "August 6, 2026", day: null }, - { label: "nonsense", cell: "nope", day: null }, - { label: "calendar overflow", cell: "2026-02-30", day: null }, - { - label: "calendar overflow, zone-less datetime", - cell: "2026-02-30T00:00:00", - day: null, - }, - { - label: "calendar overflow, zoned datetime", - cell: "2026-02-30T00:00:00Z", - day: null, - }, - { - label: "calendar overflow, space-separated datetime", - cell: "2026-02-30 12:00:00", - day: null, - }, - { label: "month overflow", cell: "2026-13-01", day: null }, - { label: "empty string", cell: "", day: null }, - { label: "null", cell: null, day: null }, - { label: "undefined", cell: undefined, day: null }, - { label: "invalid Date", cell: new Date(Number.NaN), day: null }, - { label: "out-of-range epoch ms", cell: 8.64e15 + 1, day: null }, - { - label: "max epoch ms — year 275760, past 4-digit ISO", - cell: 8.64e15, - day: null, - }, - { - label: "min epoch ms — year -271821, before 0000", - cell: -8.64e15, - day: null, - }, - { label: "nanosecond epoch", cell: 1.78e18, day: null }, -]; -describe("toIsoDate — shared case table (twin of the engine's toDayMs)", () => { - for (const { label, cell, day } of DATE_CASES) { - it(`${label}: ${day ?? "not a date"}`, () => { - expect(toIsoDate(cell)).toBe(day ?? ""); - }); - } + it("uses disabled null placeholders after the maximum date", () => { + const days = monthMatrix("9999-12-31").flat(); + const maximum = days.findIndex((day) => day.iso === "9999-12-31"); + expect(maximum).toBeGreaterThanOrEqual(0); + expect(days.slice(maximum + 1)).toEqual( + Array.from({ length: days.length - maximum - 1 }, () => ({ + iso: null, + day: null, + inMonth: false, + disabled: true, + })), + ); + }); - it("is idempotent: every output it produces is an input it accepts", () => { - // The editor round-trips its own output (the open value becomes the draft, - // the draft is re-validated on commit), so a day `toIsoDate` can emit but - // `isValidIsoDate` rejects would commit a value no filter can match. - for (const { label, cell } of DATE_CASES) { - const once = toIsoDate(cell); - expect(toIsoDate(once), label).toBe(once); - if (once !== "") expect(isValidIsoDate(once), label).toBe(true); + it("returns an empty matrix for noncanonical input", () => { + for (const value of [ + "2026-8-6", + "2026-02-30", + "2026-08-06T00:00:00Z", + " 2026-08-06", + "", + ]) { + expect(monthMatrix(value), value).toEqual([]); } }); + + it("spells viewer-local today without UTC-day coercion", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 6, 23, 30)); + expect(todayIso()).toBe("2026-08-06"); + vi.useRealTimers(); + }); }); diff --git a/packages/react/src/__tests__/filter-menu-row-model-boundary.test.ts b/packages/react/src/__tests__/filter-menu-row-model-boundary.test.ts index 1e2eb95f7..c3a59ef01 100644 --- a/packages/react/src/__tests__/filter-menu-row-model-boundary.test.ts +++ b/packages/react/src/__tests__/filter-menu-row-model-boundary.test.ts @@ -277,6 +277,34 @@ const CASES: Case[] = [ ]; describe("filter menu -> row-model boundary", () => { + it("keeps an application-controlled invalid date filter active with zero matches", () => { + const column = createColumnHelper(); + const columns = [ + column.accessor("value", { type: "date" as never }), + ] as const; + const query = { + filters: [{ columnId: "value", operator: "on", value: "2026-02-30" }], + rowGroups: [], + sort: [], + } as unknown as PretableQueryFor; + const plan = compileQuery({ derivations: columns, query }); + + expect( + plan.evaluate({ + rowId: 1, + row: { id: 1, value: "2026-02-28" }, + sourceOrder: 0, + }).filterPasses, + ).toBe(false); + expect( + plan.evaluate({ + rowId: 2, + row: { id: 2, value: "2026-02-30" }, + sourceOrder: 1, + }).filterPasses, + ).toBe(false); + }); + it("covers every operator each column type's menu actually offers", () => { for (const type of ["text", "number", "date", "enum", "boolean"] as const) { const covered = CASES.filter((c) => c.type === type).map( diff --git a/packages/react/src/__tests__/filter-menu-surface.test.tsx b/packages/react/src/__tests__/filter-menu-surface.test.tsx index 9e1b18430..d0a5d835d 100644 --- a/packages/react/src/__tests__/filter-menu-surface.test.tsx +++ b/packages/react/src/__tests__/filter-menu-surface.test.tsx @@ -435,4 +435,48 @@ describe("PretableSurface — built-in filter funnel", () => { expect(view.getAllByTestId("pretable-row")).toHaveLength(3), ); }); + + it("removes a menu-owned date filter when its input becomes incomplete", async () => { + const onFiltersChange = vi.fn(); + type DateRow = { id: string; due: string }; + function DateHarness() { + const [query, setQuery] = React.useState({ + filters: [ + { columnId: "due", operator: "on" as const, value: "2026-08-06" }, + ], + sort: [], + rowGroups: [], + }); + return ( + + ariaLabel="Dates" + columns={[ + { id: "due", header: "Due", type: "date", filterable: true }, + ]} + getRowId={(row) => row.id} + rows={[ + { id: "d1", due: "2026-08-06" }, + { id: "d2", due: "2026-08-07" }, + ]} + query={query} + onQueryChange={(next) => { + setQuery(next as typeof query); + onFiltersChange(next.filters); + }} + viewportHeight={200} + /> + ); + } + const view = render(); + expect(view.getAllByTestId("pretable-row")).toHaveLength(1); + fireEvent.click(view.getByRole("button", { name: "Filter Due" })); + fireEvent.change(view.getByLabelText("Filter value"), { + target: { value: "" }, + }); + + expect(onFiltersChange).toHaveBeenLastCalledWith([]); + await waitFor(() => + expect(view.getAllByTestId("pretable-row")).toHaveLength(2), + ); + }); }); diff --git a/packages/react/src/__tests__/filter-menu.test.tsx b/packages/react/src/__tests__/filter-menu.test.tsx index ae65735e9..9bad2f5b5 100644 --- a/packages/react/src/__tests__/filter-menu.test.tsx +++ b/packages/react/src/__tests__/filter-menu.test.tsx @@ -349,6 +349,28 @@ describe("FilterMenu — date single applies immediately", () => { { operator: "before", value: "2026-06-18" }, ]); }); + + it.each([ + "", + "2026-6-18", + "2026-02-30", + " 2026-06-18", + "2026-06-18T00:00:00Z", + ])( + "removes a menu-owned current filter when the date draft becomes %j", + (text) => { + const { onChange } = renderMenu({ + type: "date", + initialFilter: { operator: "before", value: "2026-06-18" }, + }); + act(() => { + fireEvent.change(screen.getByLabelText("Filter value"), { + target: { value: text }, + }); + }); + expect(lastCall(onChange)).toEqual(["c", null]); + }, + ); }); describe("FilterMenu — outside click", () => { diff --git a/packages/react/src/__tests__/filter-operators.test.ts b/packages/react/src/__tests__/filter-operators.test.ts index 4e9a55a84..79810ca89 100644 --- a/packages/react/src/__tests__/filter-operators.test.ts +++ b/packages/react/src/__tests__/filter-operators.test.ts @@ -173,11 +173,31 @@ describe("isComplete + toColumnFilter (gating)", () => { max: "2026-02-01", }), ).toEqual({ operator: "dateBetween", value: ["2026-01-01", "2026-02-01"] }); + for (const [min, max] of [ + ["2026-1-01", "2026-02-01"], + ["2026-01-01", "2026-02-30"], + [" 2026-01-01", "2026-02-01"], + ["2026-01-01T00:00:00Z", "2026-02-01"], + ]) { + expect( + toColumnFilter("date", { operator: "dateBetween", min, max }), + ).toBeNull(); + } }); it("date single", () => { expect( toColumnFilter("date", { operator: "before", text: "2026-06-18" }), ).toEqual({ operator: "before", value: "2026-06-18" }); + for (const text of [ + "", + "2026-6-18", + "2026-02-30", + " 2026-06-18", + "2026-06-18 ", + "2026-06-18T00:00:00Z", + ]) { + expect(toColumnFilter("date", { operator: "before", text })).toBeNull(); + } }); it("enum set; empty selection is incomplete", () => { expect(isComplete("enum", { operator: "isAnyOf", selected: [] })).toBe( diff --git a/packages/react/src/__tests__/paste-surface.test.tsx b/packages/react/src/__tests__/paste-surface.test.tsx index 59f23fba3..9ef797590 100644 --- a/packages/react/src/__tests__/paste-surface.test.tsx +++ b/packages/react/src/__tests__/paste-surface.test.tsx @@ -164,6 +164,53 @@ async function flush(): Promise { } describe("PretableSurface paste", () => { + it("accepts only canonical and exact-empty built-in date values", async () => { + const onPaste = vi.fn(); + const raw = [ + "2026-08-06", + "", + "1785974400000", + "2026-08-06T00:00:00Z", + "2026-8-6", + "2026-02-30", + " 2026-08-06", + "2026-08-06 ", + " ", + ]; + const rows = raw.map((_, index) => ({ + id: `r${index + 1}`, + name: "", + note: "", + locked: "", + qty: 0, + due: null, + })); + renderPasteGrid({ + rows, + columns: [{ id: "due", type: "date", editable: true }], + state: cellSelection("r1", "due"), + onPaste, + }); + + firePaste(grid(), raw.join("\n")); + await flush(); + + const payload = onPaste.mock.calls[0]![0] as PastePayload; + expect(payload.cells.map((cell) => cell.value)).toEqual([ + "2026-08-06", + null, + ]); + expect(payload.rejected).toEqual( + raw.slice(2).map((value, index) => ({ + rowId: `r${index + 3}`, + columnId: "due", + raw: value, + reason: "invalid", + message: "Use YYYY-MM-DD", + })), + ); + }); + it("keeps one paste listener while streaming rows publish", async () => { const addListener = vi.spyOn(HTMLElement.prototype, "addEventListener"); const removeListener = vi.spyOn( diff --git a/packages/react/src/__tests__/pretable-surface-editing.test.tsx b/packages/react/src/__tests__/pretable-surface-editing.test.tsx index 275c7fe6a..16cb123af 100644 --- a/packages/react/src/__tests__/pretable-surface-editing.test.tsx +++ b/packages/react/src/__tests__/pretable-surface-editing.test.tsx @@ -1,5 +1,7 @@ import "@testing-library/jest-dom/vitest"; +import { useState } from "react"; import { + act, cleanup, fireEvent, render, @@ -8,7 +10,7 @@ import { } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { PretableSurface } from "../pretable-surface"; +import { PretableSurface, type PretableSurfaceGrid } from "../pretable-surface"; import type { PretableColumn } from "../types"; afterEach(() => { @@ -221,6 +223,354 @@ describe("PretableSurface editing", () => { expect(box.selectionEnd).toBe("Ada".length); }); + it("does not leak typing provenance through a batched public cancel and begin", () => { + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + render( + + ariaLabel="people" + columns={COLUMNS} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={vi.fn()} + onGridReady={(grid) => (publicGrid = grid)} + />, + ); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "a" }); + + act(() => { + publicGrid.cancelEdit(); + publicGrid.beginEdit({ rowId: "r1", columnId: "name", value: "Ada" }); + }); + + const box = screen.getByRole("textbox") as HTMLInputElement; + expect(box.selectionStart).toBe(0); + expect(box.selectionEnd).toBe(3); + }); + + it("does not leak typing provenance through direct edit replacement", () => { + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + render( + + ariaLabel="people" + columns={COLUMNS} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={vi.fn()} + onGridReady={(grid) => (publicGrid = grid)} + />, + ); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "a" }); + + act(() => { + publicGrid.beginEdit({ + rowId: "r1", + columnId: "name", + value: "Ada", + }); + }); + + const box = screen.getByRole("textbox") as HTMLInputElement; + expect(box.selectionStart).toBe(0); + expect(box.selectionEnd).toBe(3); + }); + + it("preserves a typing session through an immediately resolved editable gate", async () => { + render( + + ariaLabel="people" + columns={[ + { + id: "name", + header: "Name", + editable: () => Promise.resolve(true), + }, + ]} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={vi.fn()} + />, + ); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "a" }); + await flush(); + const box = screen.getByRole("textbox") as HTMLInputElement; + expect(box).toHaveValue("a"); + expect(box.selectionStart).toBe(1); + expect(box.selectionEnd).toBe(1); + }); + + it("keeps a public replacement open when a stale editable gate resolves false", async () => { + let allow!: (value: boolean) => void; + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + render( + + ariaLabel="people" + columns={[ + { + id: "name", + header: "Name", + editable: () => + new Promise((resolve) => (allow = resolve)), + }, + ]} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={vi.fn()} + onGridReady={(grid) => (publicGrid = grid)} + />, + ); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "a" }); + expect(cell).toHaveAttribute("data-pretable-edit-status", "checking"); + + act(() => { + publicGrid.beginEdit({ + rowId: "r1", + columnId: "name", + value: "replacement", + }); + }); + allow(false); + await flush(); + + expect(screen.getByRole("textbox")).toHaveValue("replacement"); + }); + + it("drops stale validation after a public edit replacement", async () => { + let finishValidation!: (value: true) => void; + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + const onRowChange = vi.fn(); + render( + + ariaLabel="people" + columns={[ + { + id: "name", + header: "Name", + editable: true, + validate: () => + new Promise((resolve) => (finishValidation = resolve)), + }, + ]} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={onRowChange} + onGridReady={(grid) => (publicGrid = grid)} + />, + ); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "first edit" }, + }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + + act(() => { + publicGrid.beginEdit({ + rowId: "r1", + columnId: "name", + value: "replacement", + }); + }); + finishValidation(true); + await flush(); + + expect(onRowChange).not.toHaveBeenCalled(); + expect(screen.getByRole("textbox")).toHaveValue("replacement"); + }); + + it("clears typing tokens when controlled-row reconciliation closes the edit", async () => { + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + function Harness() { + const [rows, setRows] = useState(ROWS); + return ( + + ariaLabel="people" + columns={COLUMNS} + rows={rows} + getRowId={(row) => row.id} + viewportHeight={300} + onGridReady={(grid) => (publicGrid = grid)} + onRowChange={(change) => { + setRows((current) => + current.map((row) => + row.id === change.rowId ? { ...row, ...change.changes } : row, + ), + ); + }} + /> + ); + } + render(); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "a" }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + await flush(); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + + act(() => { + publicGrid.beginEdit({ rowId: "r1", columnId: "name", value: "a" }); + }); + const box = screen.getByRole("textbox") as HTMLInputElement; + expect(box.selectionStart).toBe(0); + expect(box.selectionEnd).toBe(1); + }); + + it("does not let delayed reconciliation for an old edit close its replacement", async () => { + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + let applyPendingChange!: () => void; + let pendingChange: { rowId: string; changes: Partial } | null = null; + function Harness() { + const [rows, setRows] = useState(ROWS); + applyPendingChange = () => { + const change = pendingChange; + if (change === null) return; + setRows((current) => + current.map((row) => + row.id === change.rowId ? { ...row, ...change.changes } : row, + ), + ); + }; + return ( + + ariaLabel="people" + columns={COLUMNS} + rows={rows} + getRowId={(row) => row.id} + viewportHeight={300} + onGridReady={(grid) => (publicGrid = grid)} + onRowChange={(change) => { + pendingChange = change; + }} + /> + ); + } + render(); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "a" }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + await flush(); + + act(() => { + publicGrid.beginEdit({ + rowId: "r1", + columnId: "name", + value: "replacement", + }); + }); + act(() => applyPendingChange()); + + expect(screen.getByRole("textbox")).toHaveValue("replacement"); + }); + + it("does not let an old rejected row change erase replacement reconciliation", async () => { + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + let rejectFirstChange!: (error: Error) => void; + let applyReplacementChange!: () => void; + let replacementChange: { + rowId: string; + changes: Partial; + } | null = null; + let changeCount = 0; + function Harness() { + const [rows, setRows] = useState(ROWS); + applyReplacementChange = () => { + const change = replacementChange; + if (change === null) return; + setRows((current) => + current.map((row) => + row.id === change.rowId ? { ...row, ...change.changes } : row, + ), + ); + }; + return ( + + ariaLabel="people" + columns={COLUMNS} + rows={rows} + getRowId={(row) => row.id} + viewportHeight={300} + onGridReady={(grid) => (publicGrid = grid)} + onRowChange={(change) => { + changeCount += 1; + if (changeCount === 1) { + return new Promise((_resolve, reject) => { + rejectFirstChange = reject; + }); + } + replacementChange = change; + }} + /> + ); + } + render(); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "a" }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + + act(() => { + publicGrid.beginEdit({ + rowId: "r1", + columnId: "name", + value: "replacement", + }); + }); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "replacement committed" }, + }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + await flush(); + + rejectFirstChange(new Error("old change rejected")); + await flush(); + act(() => applyReplacementChange()); + + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + }); + it("narrows an enum combobox progressively across two typed characters", () => { render( diff --git a/packages/react/src/__tests__/type-parsing.test.ts b/packages/react/src/__tests__/type-parsing.test.ts index 38f48cff8..72629109e 100644 --- a/packages/react/src/__tests__/type-parsing.test.ts +++ b/packages/react/src/__tests__/type-parsing.test.ts @@ -92,30 +92,29 @@ describe("parseDraftForType — date", () => { }); }); - it("normalises a Date instance or timestamp draft to ISO", () => { - expect(parseDraftForType(column, new Date(Date.UTC(2026, 7, 6)))).toEqual({ - ok: true, - value: "2026-08-06", - }); - }); - - it("commits null for an empty draft", () => { + it("commits null only for an exact user-cleared empty draft", () => { expect(parseDraftForType(column, "")).toEqual({ ok: true, value: null }); - }); - - it("rejects locale formats and nonsense", () => { - expect(parseDraftForType(column, "08/06/2026")).toEqual({ + expect(parseDraftForType(column, null)).toEqual({ ok: false, message: "Use YYYY-MM-DD", }); - expect(parseDraftForType(column, "nope")).toEqual({ + expect(parseDraftForType(column, undefined)).toEqual({ ok: false, message: "Use YYYY-MM-DD", }); }); - it("rejects calendar overflow", () => { - expect(parseDraftForType(column, "2026-02-30")).toEqual({ + it.each([ + ["Date", new Date("2026-08-06T00:00:00Z")], + ["epoch", Date.UTC(2026, 7, 6)], + ["datetime", "2026-08-06T00:00:00Z"], + ["loose", "2026-8-6"], + ["overflow", "2026-02-30"], + ["padded", " 2026-08-06 "], + ["whitespace", " "], + ["locale", "08/06/2026"], + ])("rejects %s without coercion or trimming", (_label, draft) => { + expect(parseDraftForType(column, draft)).toEqual({ ok: false, message: "Use YYYY-MM-DD", }); diff --git a/packages/react/src/__tests__/use-cell-edit-controller.test.ts b/packages/react/src/__tests__/use-cell-edit-controller.test.ts index 65ceaffb5..00ea1c5ef 100644 --- a/packages/react/src/__tests__/use-cell-edit-controller.test.ts +++ b/packages/react/src/__tests__/use-cell-edit-controller.test.ts @@ -13,6 +13,7 @@ const ROWS: Row[] = [{ id: "r1", name: "Ada" }]; function setup( columnOverrides: Partial> = {}, onCommit = vi.fn(), + rows = ROWS, ) { const columns: PretableColumn[] = [ { id: "name", editable: true, ...columnOverrides }, @@ -30,6 +31,7 @@ function setup( edit?: { readonly draft?: unknown; readonly status?: "checking" | "editing"; + readonly seededFromTyping?: boolean; }, ) { editing = { @@ -68,7 +70,7 @@ function setup( const controller = createCellEditController({ grid, getColumns: () => columns, - getRowById: (id) => ROWS.find((r) => r.id === id) ?? null, + getRowById: (id) => rows.find((r) => r.id === id) ?? null, onCommit, }); return { grid, controller, onCommit }; @@ -84,6 +86,25 @@ describe("cell edit controller", () => { }); }); + it("forwards explicit typing provenance and defaults other begins to false", async () => { + const { grid, controller } = setup(); + const beginEdit = vi.spyOn(grid, "beginEdit"); + + await controller.begin({ rowId: "r1", columnId: "name" }, "x", { + seededFromTyping: true, + }); + expect(beginEdit).toHaveBeenLastCalledWith( + { rowId: "r1", columnId: "name" }, + { draft: "x", status: "editing", seededFromTyping: true }, + ); + + await controller.begin({ rowId: "r1", columnId: "name" }); + expect(beginEdit).toHaveBeenLastCalledWith( + { rowId: "r1", columnId: "name" }, + { draft: "Ada", status: "editing", seededFromTyping: false }, + ); + }); + it("gates begin through 'checking' for async editable", async () => { let resolve!: (v: boolean) => void; const { grid, controller } = setup({ @@ -166,6 +187,19 @@ describe("cell edit controller", () => { ); }); + it("retains an untouched canonical null date without marking it invalid", async () => { + const onCommit = vi.fn().mockResolvedValue(undefined); + const nullRows = [{ id: "r1", name: null as unknown as string }]; + const { grid, controller } = setup({ type: "date" }, onCommit, nullRows); + await controller.begin({ rowId: "r1", columnId: "name" }); + await controller.commit(); + + expect(onCommit).toHaveBeenCalledWith( + expect.objectContaining({ value: null }), + ); + expect(grid.getSnapshot().editing).toBeNull(); + }); + it("drops a stale async-editable resolution after cancel (staleness guard)", async () => { let resolve!: (v: boolean) => void; const { grid, controller } = setup({ diff --git a/packages/react/src/editors/DateCellEditor.tsx b/packages/react/src/editors/DateCellEditor.tsx index ac4e55738..8d6b730fb 100644 --- a/packages/react/src/editors/DateCellEditor.tsx +++ b/packages/react/src/editors/DateCellEditor.tsx @@ -1,24 +1,20 @@ import { useId, useLayoutEffect, useRef, useState } from "react"; import type { PretableFocusDirection } from "@pretable/core"; +import { + MAX_DATE_VALUE, + MIN_DATE_VALUE, + addDateValueDays, + addDateValueMonths, + isValidDateValue, +} from "@pretable-internal/calendar-date"; import { OverlayPortal } from "../overlay/OverlayPortal"; import { popoverStyle } from "../overlay/popover-position"; import type { PretableEditorInput } from "../types"; -import { - addDaysIso, - addMonthsIso, - isValidIsoDate, - monthLabel, - monthMatrix, - toIsoDate, - todayIso, -} from "./date-utils"; +import { monthLabel, monthMatrix, todayIso } from "./date-utils"; import { useEditorField } from "./use-editor-field"; -/** Column headers, Monday-first. The visible label stays two characters; the - * `aria-label` spells the day out, since "Mo" is not a word a screen reader - * pronounces usefully. */ const WEEKDAYS = [ ["Mo", "Monday"], ["Tu", "Tuesday"], @@ -29,35 +25,54 @@ const WEEKDAYS = [ ["Su", "Sunday"], ] as const; -/** - * Date editor: a strict ISO field plus a month grid in a portaled popover. - * - * DOM focus stays in the input — moving it into the grid would fire the shared - * chrome's blur-commit — so the active day is published with - * `aria-activedescendant` and arrow keys drive the calendar rather than the - * text caret (the field is a fixed 10-character date). - */ +interface DateEditorState { + readonly observedDraft: unknown; + readonly cursor: string; + readonly selected: string | null; + readonly userModified: boolean; + readonly userDraft: unknown; +} + +const initialState = (draft: unknown): DateEditorState => { + const canonical = isValidDateValue(draft) ? draft : null; + return { + observedDraft: draft, + cursor: canonical ?? todayIso(), + selected: canonical, + userModified: false, + userDraft: draft, + }; +}; + export function DateCellEditor({ input }: { input: PretableEditorInput }) { const { ref, pending, fieldProps } = useEditorField(input); const gridId = useId(); const anchorRef = useRef(null); const [rect, setRect] = useState(null); - const text = String(input.draft ?? ""); - // `cursor` is the single source of truth for what the calendar shows and - // highlights: arrows/PageUp/Down move it, and typing a valid date syncs it. - // (Deriving it from the draft text instead would make the calendar lag the - // engine round-trip.) - const [cursor, setCursor] = useState( - () => toIsoDate(input.draft ?? input.value) || todayIso(), + const [storedState, setStoredState] = useState(() => + initialState(input.draft), ); + let state = storedState; + if (!Object.is(state.observedDraft, input.draft)) { + const canonical = isValidDateValue(input.draft) ? input.draft : null; + const reflectsUserWrite = + state.userModified && Object.is(state.userDraft, input.draft); + state = { + observedDraft: input.draft, + cursor: canonical ?? state.cursor, + selected: canonical, + userModified: reflectsUserWrite, + userDraft: input.draft, + }; + setStoredState(state); + } + useLayoutEffect(() => { const measure = () => { if (anchorRef.current) setRect(anchorRef.current.getBoundingClientRect()); }; measure(); - // The popover is portaled and `position: fixed`, so it detaches visually - // when anything scrolls. Capture phase catches grid-internal scrollers. window.addEventListener("scroll", measure, true); window.addEventListener("resize", measure); return () => { @@ -66,35 +81,55 @@ export function DateCellEditor({ input }: { input: PretableEditorInput }) { }; }, []); - // The controller seeds the draft with the raw cell value, which may be a - // Date or a timestamp; show it as ISO so the field matches what commits. - useLayoutEffect(() => { - const seeded = String(input.draft ?? ""); - const iso = toIsoDate(input.draft); - if (iso && iso !== seeded) input.setDraft(iso); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const active = cursor; + const text = String(input.draft ?? ""); + const active = state.cursor; const weeks = monthMatrix(active); const today = todayIso(); + const previousMonthDisabled = + active.slice(0, 7) === MIN_DATE_VALUE.slice(0, 7); + const nextMonthDisabled = active.slice(0, 7) === MAX_DATE_VALUE.slice(0, 7); + + const writeUserDraft = (next: string, selected: string | null) => { + setStoredState({ + observedDraft: input.draft, + cursor: selected ?? state.cursor, + selected, + userModified: true, + userDraft: next, + }); + input.setDraft(next); + }; const choose = (iso: string, direction?: PretableFocusDirection) => { if (pending) return; - // setDraft mutates engine state synchronously, so the commit that follows - // reads the day we just wrote. - input.setDraft(iso); + writeUserDraft(iso, iso); if (direction) input.commit(direction); else input.commit(); }; const move = (next: string) => { - // Same guard as `choose`: the field is readOnly while an edit is in flight, - // so navigation must not rewrite the draft behind a pending save either. - if (pending) return; - setCursor(next); - // Keep the field and the calendar in step while navigating. - input.setDraft(next); + if (pending || next === state.cursor) return; + writeUserDraft(next, next); + }; + + const blur = () => { + if (input.status !== "editing") return; + if (!state.userModified) { + if (input.column.parseEditValue) input.cancel(); + else if (input.draft === null || isValidDateValue(input.draft)) + input.commit(); + else input.cancel(); + return; + } + if ( + input.column.parseEditValue || + state.userDraft === "" || + isValidDateValue(state.userDraft) + ) { + input.commit(); + } else { + input.cancel(); + } }; return ( @@ -107,72 +142,65 @@ export function DateCellEditor({ input }: { input: PretableEditorInput }) { aria-controls={gridId} aria-activedescendant={`${gridId}-${active}`} value={text} - onChange={(e) => { - const next = e.target.value; + onChange={(event) => { + const next = event.target.value; + const canonical = isValidDateValue(next) ? next : null; + setStoredState({ + observedDraft: input.draft, + cursor: canonical ?? state.cursor, + selected: canonical, + userModified: true, + userDraft: next, + }); input.setDraft(next); - // A complete, valid date retargets the calendar as you type. - if (isValidIsoDate(next)) setCursor(next); }} {...fieldProps} - onBlur={() => { - // Clicking away with an unparseable date reverts rather than leaving - // a rejected edit stuck open on the cell. - if (input.status !== "editing") return; - if (text.trim() === "" || isValidIsoDate(text)) input.commit(); - else input.cancel(); - }} - onKeyDown={(e) => { + onBlur={blur} + onKeyDown={(event) => { const step = - e.key === "ArrowLeft" + event.key === "ArrowLeft" ? -1 - : e.key === "ArrowRight" + : event.key === "ArrowRight" ? 1 - : e.key === "ArrowUp" + : event.key === "ArrowUp" ? -7 - : e.key === "ArrowDown" + : event.key === "ArrowDown" ? 7 : 0; if (step !== 0) { - e.preventDefault(); - e.stopPropagation(); - move(addDaysIso(active, step)); + event.preventDefault(); + event.stopPropagation(); + move(addDateValueDays(active, step)); return; } - if (e.key === "PageUp" || e.key === "PageDown") { - e.preventDefault(); - e.stopPropagation(); - move(addMonthsIso(active, e.key === "PageDown" ? 1 : -1)); + if (event.key === "PageUp" || event.key === "PageDown") { + event.preventDefault(); + event.stopPropagation(); + move(addDateValueMonths(active, event.key === "PageDown" ? 1 : -1)); return; } - // Enter takes the highlighted day, but only when the typed text is - // itself a valid date — so garbage still reaches the parser's reject - // and an empty field still commits null, instead of silently - // committing whatever the calendar happened to be showing. - if (e.key === "Enter" && isValidIsoDate(text)) { - e.preventDefault(); - e.stopPropagation(); - choose(active, "down"); + if (event.key === "Enter" && state.selected !== null) { + event.preventDefault(); + event.stopPropagation(); + choose(state.selected, "down"); return; } - // Escape, Tab, an empty field, and an invalid draft fall through to - // the shared chrome — parseDraftForType rejects what it can't read. - fieldProps.onKeyDown(e); + fieldProps.onKeyDown(event); }} />
e.preventDefault()} + onMouseDown={(event) => event.preventDefault()} >
@@ -181,7 +209,8 @@ export function DateCellEditor({ input }: { input: PretableEditorInput }) { type="button" tabIndex={-1} aria-label="Next month" - onClick={() => move(addMonthsIso(active, 1))} + disabled={nextMonthDisabled} + onClick={() => move(addDateValueMonths(active, 1))} > › @@ -194,23 +223,38 @@ export function DateCellEditor({ input }: { input: PretableEditorInput }) { ))}
- {weeks.map((week) => ( -
- {week.map((d) => ( - choose(d.iso)} - > - {d.day} - - ))} + {weeks.map((week, weekIndex) => ( +
+ {week.map((day, dayIndex) => { + const id = + day.iso === null + ? `${gridId}-disabled-${weekIndex}-${dayIndex}` + : `${gridId}-${day.iso}`; + return ( + choose(day.iso!) + } + > + {day.day} + + ); + })}
))}
diff --git a/packages/react/src/editors/date-utils.ts b/packages/react/src/editors/date-utils.ts index 9cb4571d0..d4548b967 100644 --- a/packages/react/src/editors/date-utils.ts +++ b/packages/react/src/editors/date-utils.ts @@ -1,15 +1,12 @@ -const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})$/; -/** - * An ISO datetime; group 1 is the date portion, group 2 the zone if spelled - * out. The separator may be `T` or a space: `2026-08-06 13:45:00` is what - * MySQL, SQLite, Postgres-as-text and `pandas.to_csv` emit, and its date - * portion is exactly as unambiguous as the `T` form's. - */ -const ISO_DATETIME_RE = - /^(\d{4}-\d{2}-\d{2})[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/i; +import { + MAX_DATE_VALUE, + MIN_DATE_VALUE, + dateValueToUtcMs, + isValidDateValue, + parseDateValue, +} from "@pretable-internal/calendar-date"; + const DAY_MS = 86_400_000; -/** `yyyy-mm-dd` is four digits of year; the Date range runs far wider. */ -const MAX_ISO_YEAR = 9999; const MONTHS = [ "January", "February", @@ -23,169 +20,67 @@ const MONTHS = [ "October", "November", "December", -]; - -/** - * 400 Gregorian years, exactly — the calendar repeats on that cycle, so this - * many ms is a lossless year shift for any date. - */ -const GREGORIAN_400Y_MS = 146_097 * DAY_MS; - -/** - * `Date.UTC` with years 0–99 meaning themselves, not 1900+y (`Date.UTC(50, …)` - * is otherwise 1950). Such a year is built 400 years forward and shifted back, - * which — unlike a follow-up `setUTCFullYear` — survives out-of-range `month` - * and `day` arguments (`addMonthsIso` and `monthMatrix` pass them) and gets - * year 0's leap day right (0000 is a leap year in the proleptic Gregorian - * calendar; 1900 is not). - */ -function utcMs(year: number, month: number, day: number): number { - return year >= 0 && year < 100 - ? Date.UTC(year + 400, month, day) - GREGORIAN_400Y_MS - : Date.UTC(year, month, day); -} - -/** A UTC-midnight timestamp as `yyyy-mm-dd`. */ -function formatUtc(ms: number): string { - const d = new Date(ms); - return [ - String(d.getUTCFullYear()).padStart(4, "0"), - String(d.getUTCMonth() + 1).padStart(2, "0"), - String(d.getUTCDate()).padStart(2, "0"), - ].join("-"); -} - -/** - * Strict `yyyy-mm-dd` → UTC-midnight ms, or NaN. Deliberately refuses locale - * formats: `03/04` is ambiguous, and the filter engine speaks ISO too. - */ -export function parseIsoDate(text: string): number { - const trimmed = text.trim(); - if (!ISO_RE.test(trimmed)) return Number.NaN; - const [y, m, d] = trimmed.split("-").map(Number); - const ms = utcMs(y, m - 1, d); - // Date.UTC rolls 2026-02-30 forward to March; the round-trip catches that. - return formatUtc(ms) === trimmed ? ms : Number.NaN; -} +] as const; -export function isValidIsoDate(text: string): boolean { - return !Number.isNaN(parseIsoDate(text)); -} - -/** - * A cell value → `yyyy-mm-dd`, or `""`. - * - * Accepts a strict `yyyy-mm-dd` string, an ISO datetime (zoned or not, - * `T`- or space-separated), a `Date`, or a finite epoch-ms number. A - * **zone-less** datetime - * (`2026-08-06T00:00:00`, the shape most JSON and SQL backends emit) is - * interpreted as UTC, i.e. its literal date portion is taken — deterministic, - * so the cell reads the same day for every viewer. A zoned datetime yields the - * *UTC* day of that instant. Everything else — `2026-8-6`, `08/06/2026`, - * `August 6, 2026` — is refused rather than run through `Date.parse`, which - * resolves them in the *viewer's* timezone (so the same cell would show a - * different day for different users) and silently rolls calendar overflow - * forward (`2026-02-30` → March 2). - * - * TWIN: `toDayMs` in `packages/grid-core/src/evaluate-filter.ts` implements - * the same rule for the filter engine (grid-core must not depend on - * @pretable/react). Change one and you must change the other; the shared case - * table in `../__tests__/date-utils.test.ts` and its twin in - * `packages/grid-core/src/__tests__/evaluate-filter-date.test.ts` pin them - * together. - */ -export function toIsoDate(value: unknown): string { - if (value === null || value === undefined || value === "") return ""; - let ms: number; - if (value instanceof Date) ms = value.getTime(); - else if (typeof value === "number") ms = value; - else if (typeof value === "string") { - const trimmed = value.trim(); - if (isValidIsoDate(trimmed)) return trimmed; - const parts = ISO_DATETIME_RE.exec(trimmed); - // Guard the date portion whether or not a zone follows: `Date.parse` rolls - // `2026-02-30T00:00:00Z` forward to March, the very thing `parseIsoDate` - // exists to reject. - if (!parts || !isValidIsoDate(parts[1])) return ""; - // Zone-less → the literal date portion, UTC-interpreted. Zoned → the UTC - // day of that instant, so `2026-08-06T00:00:00+02:00` is 2026-08-05. - if (!parts[2]) return parts[1]; - // A space separator is normalised to `T` first: only the `T` spelling is - // in the `Date.parse` spec, the space form is engine-specific. - ms = Date.parse(trimmed.replace(" ", "T")); - } else return ""; - const d = new Date(ms); - // NaN *and* out-of-range timestamps (|ms| > 8.64e15, e.g. a nanosecond - // epoch) both yield an Invalid Date, whose UTC getters would format as - // "0NaN-NaN-NaN" rather than failing. - if (Number.isNaN(d.getTime())) return ""; - // A 4-digit year is the contract: `yyyy-mm-dd` is what this returns, what - // `parseIsoDate` accepts back, and what the engine's `toDayMs` reads. The - // extremes of the Date range (year 275760, year -271821) can't be spelled - // that way, so they are not dates here rather than output nothing can - // round-trip. - const year = d.getUTCFullYear(); - if (year < 0 || year > MAX_ISO_YEAR) return ""; - return formatUtc(d.getTime()); -} +const formatParts = (year: number, month: number, day: number) => + `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String( + day, + ).padStart(2, "0")}`; -/** - * The *viewer's* today. Deliberately local (unlike `toIsoDate`, which resolves - * stored values to their UTC day): the "today" marker should be the day the - * user is living in, not the one their stored values are bucketed into. - */ +/** The calendar's today marker follows the viewer's local civil day. */ export function todayIso(): string { const now = new Date(); - return formatUtc(utcMs(now.getFullYear(), now.getMonth(), now.getDate())); + return formatParts(now.getFullYear(), now.getMonth() + 1, now.getDate()); } -export function addDaysIso(iso: string, days: number): string { - const ms = parseIsoDate(iso); - if (Number.isNaN(ms)) return iso; - return formatUtc(ms + days * DAY_MS); +export function monthLabel(value: string): string { + const parsed = parseDateValue(value); + if (parsed === null) return ""; + return `${MONTHS[parsed.month - 1]} ${parsed.year}`; } -/** Add months, clamping the day to the target month's length (Jan 31 + 1 → Feb 28). */ -export function addMonthsIso(iso: string, months: number): string { - const ms = parseIsoDate(iso); - if (Number.isNaN(ms)) return iso; - const d = new Date(ms); - const year = d.getUTCFullYear(); - const month = d.getUTCMonth() + months; - const lastDay = new Date(utcMs(year, month + 1, 0)).getUTCDate(); - return formatUtc(utcMs(year, month, Math.min(d.getUTCDate(), lastDay))); +export interface CalendarDay { + readonly iso: string | null; + readonly day: number | null; + readonly inMonth: boolean; + readonly disabled: boolean; } -export function monthLabel(iso: string): string { - const ms = parseIsoDate(iso); - if (Number.isNaN(ms)) return ""; - const d = new Date(ms); - return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; -} +const disabledDay = (): CalendarDay => ({ + iso: null, + day: null, + inMonth: false, + disabled: true, +}); -export interface CalendarDay { - iso: string; - day: number; - inMonth: boolean; -} +/** Six Monday-first weeks covering the month containing a canonical date. */ +export function monthMatrix(value: string): CalendarDay[][] { + if (!isValidDateValue(value)) return []; + const parsed = parseDateValue(value)!; + const first = formatParts(parsed.year, parsed.month, 1); + const firstMs = dateValueToUtcMs(first); + const minimumMs = dateValueToUtcMs(MIN_DATE_VALUE); + const maximumMs = dateValueToUtcMs(MAX_DATE_VALUE); + const mondayOffset = (new Date(firstMs).getUTCDay() + 6) % 7; + + return Array.from({ length: 6 }, (_, week) => + Array.from({ length: 7 }, (_, weekday) => { + const timestamp = firstMs + (week * 7 + weekday - mondayOffset) * DAY_MS; + if (timestamp < minimumMs || timestamp > maximumMs) return disabledDay(); -/** Six Monday-start weeks covering the month containing `iso`. */ -export function monthMatrix(iso: string): CalendarDay[][] { - const ms = parseIsoDate(iso); - if (Number.isNaN(ms)) return []; - const d = new Date(ms); - const month = d.getUTCMonth(); - const first = utcMs(d.getUTCFullYear(), month, 1); - // getUTCDay is 0=Sunday; shift so Monday is column 0. - const offset = (new Date(first).getUTCDay() + 6) % 7; - const start = first - offset * DAY_MS; - return Array.from({ length: 6 }, (_, w) => - Array.from({ length: 7 }, (_, i) => { - const cur = new Date(start + (w * 7 + i) * DAY_MS); + const current = new Date(timestamp); + const iso = formatParts( + current.getUTCFullYear(), + current.getUTCMonth() + 1, + current.getUTCDate(), + ); return { - iso: formatUtc(cur.getTime()), - day: cur.getUTCDate(), - inMonth: cur.getUTCMonth() === month, + iso, + day: current.getUTCDate(), + inMonth: + current.getUTCFullYear() === parsed.year && + current.getUTCMonth() + 1 === parsed.month, + disabled: false, }; }), ); diff --git a/packages/react/src/editors/type-parsing.ts b/packages/react/src/editors/type-parsing.ts index 7d40b3802..6372d1852 100644 --- a/packages/react/src/editors/type-parsing.ts +++ b/packages/react/src/editors/type-parsing.ts @@ -1,6 +1,6 @@ import type { ColumnOption, ColumnType } from "@pretable/core"; +import { isValidDateValue } from "@pretable-internal/calendar-date"; -import { isValidIsoDate, toIsoDate } from "./date-utils"; import { matchOption } from "./enum-options"; export type DraftParseResult = @@ -35,20 +35,9 @@ export function parseDraftForType( : { ok: false, message: "Pick an option" }; } case "date": { - if (draft === null || draft === undefined || draft === "") - return { ok: true, value: null }; - // A Date/timestamp draft (a cell value that never went through the - // editor) normalises; typed text must be strict ISO. - if (typeof draft !== "string") { - const iso = toIsoDate(draft); - return iso - ? { ok: true, value: iso } - : { ok: false, message: "Use YYYY-MM-DD" }; - } - const raw = draft.trim(); - if (raw === "") return { ok: true, value: null }; - return isValidIsoDate(raw) - ? { ok: true, value: raw } + if (draft === "") return { ok: true, value: null }; + return isValidDateValue(draft) + ? { ok: true, value: draft } : { ok: false, message: "Use YYYY-MM-DD" }; } default: diff --git a/packages/react/src/filter-menu/filter-operators.ts b/packages/react/src/filter-menu/filter-operators.ts index fdf9d876e..856c436f3 100644 --- a/packages/react/src/filter-menu/filter-operators.ts +++ b/packages/react/src/filter-menu/filter-operators.ts @@ -6,6 +6,7 @@ import type { FilterOperator, PretableProcessingOptions, } from "@pretable/core"; +import { isValidDateValue } from "@pretable-internal/calendar-date"; import { warnOnce } from "../dev-warn"; /** Local editing shape for the popover. One field set per value-shape. */ @@ -153,10 +154,13 @@ export function isComplete(type: ColumnType, d: FilterDraft): boolean { if (shape === "set") return (d.selected?.length ?? 0) > 0; if (shape === "range") { if (type === "number") return isNum(d.min) && isNum(d.max); - return !!d.min && !!d.max; // date ISO strings + if (type === "date") + return isValidDateValue(d.min) && isValidDateValue(d.max); + return !!d.min && !!d.max; } // single if (type === "number") return isNum(d.text); + if (type === "date") return isValidDateValue(d.text); return !!d.text && d.text.trim() !== ""; } diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index c4f6cac6d..22985412c 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -440,7 +440,11 @@ interface SurfaceFacade { moveColumn(columnId: string, toIndex: number): void; beginEdit( addr: PretableCellAddress, - edit?: { draft?: unknown; status?: "checking" | "editing" }, + edit?: { + draft?: unknown; + status?: "checking" | "editing"; + seededFromTyping?: boolean; + }, ): void; setEditDraft(value: unknown): void; markEditing(): void; @@ -1963,6 +1967,43 @@ export function PretableSurface< // the transaction boundary as well. Every edit session transition, model // replacement and unmount invalidates work that was awaiting the write gate. const editOperationTokenRef = useRef(0); + const editSessionSequenceRef = useRef(0); + const [editSession, setEditSession] = useState<{ + readonly activeToken: number | null; + readonly typedToken: number | null; + }>({ activeToken: null, typedToken: null }); + const editSessionRef = useRef(editSession); + const editControllerRef = useRef<{ cancel(): void } | null>(null); + const beginEditWithSession = useCallback( + ( + input: Parameters[0], + seededFromTyping: boolean, + ) => { + const sessionToken = (editSessionSequenceRef.current += 1); + const nextSession = { + activeToken: sessionToken, + typedToken: seededFromTyping ? sessionToken : null, + }; + editSessionRef.current = nextSession; + setEditSession(nextSession); + editOperationTokenRef.current += 1; + indexedGrid.beginEdit(input); + }, + [indexedGrid], + ); + const endEditSession = useCallback(() => { + editSessionSequenceRef.current += 1; + const nextSession = { activeToken: null, typedToken: null }; + editSessionRef.current = nextSession; + setEditSession(nextSession); + editOperationTokenRef.current += 1; + indexedGrid.cancelEdit(); + }, [indexedGrid]); + const cancelControllerEdit = useCallback(() => { + const controller = editControllerRef.current; + if (controller === null) endEditSession(); + else controller.cancel(); + }, [endEditSession]); const editModelIdentityRef = useRef(indexed.rowModel); useLayoutEffect(() => { editModelIdentityRef.current = indexed.rowModel; @@ -2641,25 +2682,25 @@ export function PretableSurface< }, beginEdit( addr: PretableCellAddress, - edit?: { draft?: unknown; status?: "checking" | "editing" }, + edit?: { + draft?: unknown; + status?: "checking" | "editing"; + seededFromTyping?: boolean; + }, ) { const ref = resolveRef(addr.rowId); if (ref?.kind !== "data") return; - editOperationTokenRef.current += 1; - indexedGrid.beginEdit({ - rowId: ref.rowId, - columnId: addr.columnId, - // `ColumnValueOf` is `never` for a value-erased - // tuple. A draft is genuinely `unknown` at this point (it comes from - // an editor's DOM value), so there is no narrower honest target. - value: edit?.draft as never, - // Forwarded, not dropped. `useCellEditController` opens an async - // `editable` check in `"checking"` so the editor renders read-only - // and `aria-busy` until the predicate answers; swallowing it here - // left the field fully interactive — and blur-committable — while - // the check was still in flight. - ...(edit?.status === undefined ? {} : { status: edit.status }), - }); + beginEditWithSession( + { + rowId: ref.rowId, + columnId: addr.columnId, + // `ColumnValueOf` is `never` for a + // value-erased tuple. Editor drafts are genuinely unknown here. + value: edit?.draft as never, + ...(edit?.status === undefined ? {} : { status: edit.status }), + }, + edit?.seededFromTyping ?? false, + ); }, setEditDraft: indexedGrid.setEditDraft, markEditing() { @@ -2678,12 +2719,10 @@ export function PretableSurface< indexedGrid.setEditStatus("error", message); }, commitEditSucceeded() { - editOperationTokenRef.current += 1; - indexedGrid.cancelEdit(); + endEditSession(); }, cancelEdit() { - editOperationTokenRef.current += 1; - indexedGrid.cancelEdit(); + endEditSession(); }, autosizeColumn() {}, scrollToRow(rowId: TRowId) { @@ -2714,19 +2753,31 @@ export function PretableSurface< }, }; return facade; - }, [effectiveColumns, indexed.rowModel, indexedGrid]); + }, [ + beginEditWithSession, + effectiveColumns, + endEditSession, + indexed.rowModel, + indexedGrid, + ]); const surfaceGrid = useMemo( () => Object.assign(Object.create(indexedGrid) as object, { beginEdit: (input: Parameters[0]) => { - editOperationTokenRef.current += 1; - indexedGrid.beginEdit(input); + cancelControllerEdit(); + beginEditWithSession(input, false); }, - cancelEdit: grid.cancelEdit, + cancelEdit: cancelControllerEdit, scrollToRow: grid.scrollToRow, exportCsv, }) as unknown as PretableSurfaceGrid, - [exportCsv, grid.cancelEdit, grid.scrollToRow, indexedGrid], + [ + beginEditWithSession, + cancelControllerEdit, + exportCsv, + grid.scrollToRow, + indexedGrid, + ], ); const baseTelemetry = useMemo< @@ -3196,24 +3247,10 @@ export function PretableSurface< onPasteRef.current = onPaste; effectiveMessagesRef.current = effectiveMessages; }); - // Which entry path opened the active edit. Type-to-replace seeds the draft - // with the typed character, so the editor must not select it (the next - // keystroke would replace it). Every begin() that opens an editor sets this, - // batched with the begin in the same event, so the editor mounts knowing it. - // - // It is surface state rather than something the controller derives from - // `initialDraft !== undefined`, because deriving it would still not cover - // the one path that can go stale: `grid.beginEdit()` called imperatively - // bypasses the controller entirely, so an editor opened that way inherits - // whichever value the *previous* edit left behind. Closing that hole means - // carrying the flag in the engine's edit state, which is a public-API - // decision, not a rendering detail. The consequence today is cosmetic: an - // imperatively opened editor may put the caret at the end instead of - // selecting the draft. - const [seededFromTyping, setSeededFromTyping] = useState(false); const pendingRowsEditRef = useRef<{ readonly rowId: PretableRowId; readonly changes: Partial; + readonly sessionToken: number | null; } | null>(null); const editController = useCellEditController({ grid, @@ -3251,10 +3288,12 @@ export function PretableSurface< if (model === undefined) { const callback = onRowChangeRef.current; if (callback === undefined) return; - pendingRowsEditRef.current = { + const pending = { rowId: change.rowId, changes: change.changes, + sessionToken: editSessionRef.current.activeToken, }; + pendingRowsEditRef.current = pending; try { await callback( change as unknown as PretableSurfaceRowChange< @@ -3264,7 +3303,9 @@ export function PretableSurface< >, ); } catch (error) { - pendingRowsEditRef.current = null; + if (pendingRowsEditRef.current === pending) { + pendingRowsEditRef.current = null; + } throw error; } return "keep-open"; @@ -3302,6 +3343,15 @@ export function PretableSurface< ), }); + useLayoutEffect(() => { + editControllerRef.current = editController; + return () => { + if (editControllerRef.current === editController) { + editControllerRef.current = null; + } + }; + }, [editController]); + useLayoutEffect(() => { const pending = pendingRowsEditRef.current; if (pending === null) return; @@ -3315,8 +3365,9 @@ export function PretableSurface< if (!Object.is(entry.row[key], pending.changes[key])) return; } pendingRowsEditRef.current = null; - indexedGrid.cancelEdit(); - }, [indexedGrid, rowModelSnapshot]); + if (pending.sessionToken !== editSessionRef.current.activeToken) return; + endEditSession(); + }, [endEditSession, rowModelSnapshot]); // Boolean cells toggle-and-commit directly through the edit lifecycle (no // popover): begin seeds the negated value as the draft, commit runs the @@ -4850,7 +4901,6 @@ export function PretableSurface< // grid handling — printable keys must not seed a popover draft. } else if (event.key === "Enter" || event.key === "F2") { event.preventDefault(); - setSeededFromTyping(false); void editController.begin(focusAddr); return; } @@ -4865,8 +4915,9 @@ export function PretableSurface< !event.altKey ) { event.preventDefault(); - setSeededFromTyping(true); - void editController.begin(focusAddr, event.key); + void editController.begin(focusAddr, event.key, { + seededFromTyping: true, + }); return; } } @@ -5978,7 +6029,6 @@ export function PretableSurface< // active edit with no editor rendered). if (column.type === "boolean") return; if (column.editable) { - setSeededFromTyping(false); void editController.begin({ rowId: rowId as unknown as string, columnId: column.id, @@ -6271,6 +6321,7 @@ export function PretableSurface< ) : cellEdit ? ( void editController.commit(dir), cancel: () => editController.cancel(), - seededFromTyping, + seededFromTyping: + editSession.activeToken !== null && + editSession.activeToken === + editSession.typedToken, } as unknown as PretableEditorInput } /> diff --git a/packages/react/src/use-cell-edit-controller.ts b/packages/react/src/use-cell-edit-controller.ts index b7048fd3c..cb94a393c 100644 --- a/packages/react/src/use-cell-edit-controller.ts +++ b/packages/react/src/use-cell-edit-controller.ts @@ -14,6 +14,7 @@ export interface CellEditController { begin( addr: { readonly rowId: TRowId; readonly columnId: string }, initialDraft?: unknown, + provenance?: { readonly seededFromTyping?: boolean }, ): Promise; commit(moveDirection?: PretableFocusDirection): Promise; cancel(): void; @@ -29,6 +30,7 @@ export interface CellEditControllerOptions< edit?: { readonly draft?: unknown; readonly status?: "checking" | "editing"; + readonly seededFromTyping?: boolean; }, ): void; getSnapshot(): { @@ -91,7 +93,7 @@ export function createCellEditController< }; return { - async begin(addr, initialDraft) { + async begin(addr, initialDraft, provenance) { const input = inputFor(addr); if (!input) return; const editable = input.column.editable ?? false; @@ -104,13 +106,21 @@ export function createCellEditController< if (editable === false) return; if (editable === true) { - grid.beginEdit(addr, { draft: seed, status: "editing" }); + grid.beginEdit(addr, { + draft: seed, + status: "editing", + seededFromTyping: provenance?.seededFromTyping ?? false, + }); token += 1; return; } // async / function editable const myToken = (token += 1); - grid.beginEdit(addr, { draft: seed, status: "checking" }); + grid.beginEdit(addr, { + draft: seed, + status: "checking", + seededFromTyping: provenance?.seededFromTyping ?? false, + }); const allowed = await editable(input); if (myToken !== token) return; // stale if (allowed) grid.markEditing(); @@ -128,6 +138,14 @@ export function createCellEditController< let value: unknown; if (input.column.parseEditValue) { value = input.column.parseEditValue(String(draft ?? ""), input); + } else if ( + input.column.type === "date" && + draft === null && + input.value === null + ) { + // Null is the canonical empty cell value, not a user-entered draft. + // Retain an untouched null seed without weakening the strict parser. + value = null; } else { const parsed = parseDraftForType(input.column, draft); if (!parsed.ok) { diff --git a/packages/react/tsconfig.build.json b/packages/react/tsconfig.build.json index 7f546187f..3e6c1c7d5 100644 --- a/packages/react/tsconfig.build.json +++ b/packages/react/tsconfig.build.json @@ -5,6 +5,8 @@ "paths": { "@pretable/core": ["../core/dist/index.d.ts"], "@pretable/core/*": ["../core/dist/*.d.ts"], + "@pretable-internal/calendar-date": ["../calendar-date/dist/index.d.ts"], + "@pretable-internal/calendar-date/*": ["../calendar-date/dist/*.d.ts"], "@pretable-internal/grid-core": ["../grid-core/dist/index.d.ts"], "@pretable-internal/grid-core/*": ["../grid-core/dist/*.d.ts"], "@pretable-internal/row-model": ["../row-model/dist/index.d.ts"], diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index c6f39dc1b..b54491d2c 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -5,6 +5,7 @@ "outDir": "dist", "paths": { "@pretable/core": ["../core/src/index.ts"], + "@pretable-internal/calendar-date": ["../calendar-date/src/index.ts"], "@pretable-internal/grid-core": ["../grid-core/src/index.ts"], "@pretable-internal/row-model": ["../row-model/src/index.ts"], "@pretable-internal/scenario-data": ["../scenario-data/src/index.ts"], diff --git a/packages/react/tsconfig.typecheck.json b/packages/react/tsconfig.typecheck.json index aa3a54b87..01b03ce0e 100644 --- a/packages/react/tsconfig.typecheck.json +++ b/packages/react/tsconfig.typecheck.json @@ -7,6 +7,8 @@ "paths": { "@pretable/core": ["../core/dist/index.d.ts"], "@pretable/core/*": ["../core/dist/*.d.ts"], + "@pretable-internal/calendar-date": ["../calendar-date/dist/index.d.ts"], + "@pretable-internal/calendar-date/*": ["../calendar-date/dist/*.d.ts"], "@pretable-internal/grid-core": ["../grid-core/dist/index.d.ts"], "@pretable-internal/grid-core/*": ["../grid-core/dist/*.d.ts"], "@pretable-internal/row-model": ["../row-model/dist/index.d.ts"], diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 24823895c..11400e4ff 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ alias: { "@pretable/core": resolve(__dirname, "../core/src/index.ts"), "@pretable/ui": resolve(__dirname, "../ui/src/index.ts"), + "@pretable-internal/calendar-date": resolve( + __dirname, + "../calendar-date/src/index.ts", + ), "@pretable-internal/grid-core": resolve( __dirname, "../grid-core/src/index.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 417fabb8b..84fefa11e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -314,6 +314,9 @@ importers: specifier: workspace:* version: link:../ui devDependencies: + '@pretable-internal/calendar-date': + specifier: workspace:* + version: link:../calendar-date '@pretable-internal/grid-core': specifier: workspace:* version: link:../grid-core From 2c5e246f2607f860e11cd4b5d5c087f7f0587f13 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 19:34:23 -0700 Subject: [PATCH 10/16] fix(react): guard stale edit continuations --- .../pretable-surface-boolean.test.tsx | 58 +++++- .../pretable-surface-editing.test.tsx | 173 ++++++++++++++++++ .../use-cell-edit-controller.test.ts | 89 +++++++++ packages/react/src/pretable-surface.tsx | 14 +- .../react/src/use-cell-edit-controller.ts | 53 ++++-- 5 files changed, 371 insertions(+), 16 deletions(-) diff --git a/packages/react/src/__tests__/pretable-surface-boolean.test.tsx b/packages/react/src/__tests__/pretable-surface-boolean.test.tsx index 019867803..355d0d0bc 100644 --- a/packages/react/src/__tests__/pretable-surface-boolean.test.tsx +++ b/packages/react/src/__tests__/pretable-surface-boolean.test.tsx @@ -1,9 +1,15 @@ import "@testing-library/jest-dom/vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import * as React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { PretableSurface } from "../pretable-surface"; +import { PretableSurface, type PretableSurfaceGrid } from "../pretable-surface"; import type { PretableSurfaceProps } from "../pretable-surface"; import type { PretableColumn } from "../types"; @@ -78,6 +84,54 @@ describe("PretableSurface boolean columns", () => { expect(onRowChange).not.toHaveBeenCalled(); }); + it("does not let a stale async toggle commit a same-cell public replacement", async () => { + let allow!: (value: boolean) => void; + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + const onRowChange = vi.fn(); + render( + + ariaLabel="bools" + columns={[ + { id: "name", header: "Name" }, + { + id: "active", + header: "Active", + type: "boolean", + editable: () => + new Promise((resolve) => (allow = resolve)), + }, + ]} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={onRowChange} + onGridReady={(grid) => (publicGrid = grid)} + />, + ); + + const checkbox = screen.getAllByRole("checkbox")[0]; + const cell = checkbox.closest('[role="gridcell"]')!; + fireEvent.click(checkbox); + act(() => { + publicGrid.beginEdit({ + rowId: "r1", + columnId: "active", + value: false, + }); + }); + expect(cell).toHaveAttribute("data-pretable-edit-status", "editing"); + + allow(true); + await flush(); + + expect(cell).toHaveAttribute("data-pretable-edit-status", "editing"); + expect(onRowChange).not.toHaveBeenCalled(); + }); + it("shows the validate error, cancels on Escape, and recovers", async () => { const validate = vi.fn().mockReturnValueOnce("nope").mockReturnValue(true); const { onRowChange } = renderGrid({ validate }); diff --git a/packages/react/src/__tests__/pretable-surface-editing.test.tsx b/packages/react/src/__tests__/pretable-surface-editing.test.tsx index 16cb123af..020f597ee 100644 --- a/packages/react/src/__tests__/pretable-surface-editing.test.tsx +++ b/packages/react/src/__tests__/pretable-surface-editing.test.tsx @@ -407,6 +407,179 @@ describe("PretableSurface editing", () => { expect(screen.getByRole("textbox")).toHaveValue("replacement"); }); + it("drops an old editable result after its controller is replaced", async () => { + let allow!: (value: boolean) => void; + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + const firstColumns: PretableColumn[] = [ + { + id: "name", + header: "Name", + editable: () => new Promise((resolve) => (allow = resolve)), + }, + ]; + const replacementColumns: PretableColumn[] = [ + { id: "name", header: "Replacement name", editable: true }, + ]; + const surface = (columns: PretableColumn[]) => ( + + ariaLabel="people" + columns={columns} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={vi.fn()} + onGridReady={(grid) => (publicGrid = grid)} + /> + ); + const view = render(surface(firstColumns)); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + expect(cell).toHaveAttribute("data-pretable-edit-status", "checking"); + + view.rerender(surface(replacementColumns)); + act(() => { + publicGrid.beginEdit({ + rowId: "r1", + columnId: "name", + value: "replacement", + }); + }); + allow(false); + await flush(); + + expect(screen.getByRole("textbox")).toHaveValue("replacement"); + }); + + it("does not continue validation after its controller is replaced", async () => { + let finishValidation!: (value: true) => void; + const onRowChange = vi.fn(); + const firstColumns: PretableColumn[] = [ + { + id: "name", + header: "Name", + editable: true, + validate: () => + new Promise((resolve) => (finishValidation = resolve)), + }, + ]; + const surface = (columns: PretableColumn[]) => ( + + ariaLabel="people" + columns={columns} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={onRowChange} + /> + ); + const view = render(surface(firstColumns)); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "stale validation" }, + }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + + view.rerender( + surface([{ id: "name", header: "Replacement name", editable: true }]), + ); + finishValidation(true); + await flush(); + + expect(onRowChange).not.toHaveBeenCalled(); + }); + + it("does not let an old save rejection mark a replacement edit", async () => { + let rejectSave!: (error: Error) => void; + let publicGrid!: PretableSurfaceGrid< + Row, + string, + readonly PretableColumn[] + >; + const onRowChange = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectSave = reject; + }), + ); + const surface = (header: string) => ( + + ariaLabel="people" + columns={[{ id: "name", header, editable: true }]} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={onRowChange} + onGridReady={(grid) => (publicGrid = grid)} + /> + ); + const view = render(surface("Name")); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "stale save" }, + }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + expect(onRowChange).toHaveBeenCalledOnce(); + + view.rerender(surface("Replacement name")); + act(() => { + publicGrid.beginEdit({ + rowId: "r1", + columnId: "name", + value: "replacement", + }); + }); + rejectSave(new Error("stale save failed")); + await flush(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("replacement"); + }); + + it("does not continue validation after unmount", async () => { + let finishValidation!: (value: true) => void; + const onRowChange = vi.fn(); + const view = render( + + ariaLabel="people" + columns={[ + { + id: "name", + header: "Name", + editable: true, + validate: () => + new Promise((resolve) => (finishValidation = resolve)), + }, + ]} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={onRowChange} + />, + ); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "stale validation" }, + }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + + view.unmount(); + finishValidation(true); + await flush(); + + expect(onRowChange).not.toHaveBeenCalled(); + }); + it("clears typing tokens when controlled-row reconciliation closes the edit", async () => { let publicGrid!: PretableSurfaceGrid< Row, diff --git a/packages/react/src/__tests__/use-cell-edit-controller.test.ts b/packages/react/src/__tests__/use-cell-edit-controller.test.ts index 00ea1c5ef..c5d3ddec5 100644 --- a/packages/react/src/__tests__/use-cell-edit-controller.test.ts +++ b/packages/react/src/__tests__/use-cell-edit-controller.test.ts @@ -212,4 +212,93 @@ describe("cell edit controller", () => { await p; expect(grid.getSnapshot().editing).toBeNull(); // stale true did not re-open }); + + it("requires the authorization returned by the exact begin before committing", async () => { + const onCommit = vi.fn().mockResolvedValue(undefined); + const { grid, controller } = setup({}, onCommit); + const first = await controller.begin({ rowId: "r1", columnId: "name" }); + const replacement = await controller.begin( + { rowId: "r1", columnId: "name" }, + "replacement", + ); + if (first === null || replacement === null) { + throw new Error("editable begins must return authorizations"); + } + + await controller.commit(undefined, first); + expect(onCommit).not.toHaveBeenCalled(); + expect(grid.getSnapshot().editing).toMatchObject({ draft: "replacement" }); + + await controller.commit(undefined, replacement); + expect(onCommit).toHaveBeenCalledWith( + expect.objectContaining({ value: "replacement" }), + ); + }); + + it("invalidates async editable without acting on the engine", async () => { + let resolve!: (value: boolean) => void; + const { grid, controller } = setup({ + editable: () => new Promise((accept) => (resolve = accept)), + }); + const markEditing = vi.spyOn(grid, "markEditing"); + const cancelEdit = vi.spyOn(grid, "cancelEdit"); + const pending = controller.begin({ rowId: "r1", columnId: "name" }); + + controller.invalidate(); + expect(cancelEdit).not.toHaveBeenCalled(); + resolve(true); + await pending; + + expect(markEditing).not.toHaveBeenCalled(); + expect(cancelEdit).not.toHaveBeenCalled(); + }); + + it("invalidates validation before it can invoke onCommit", async () => { + let resolveValidation!: (value: true) => void; + const onCommit = vi.fn(); + const { grid, controller } = setup( + { + validate: () => + new Promise((resolve) => (resolveValidation = resolve)), + }, + onCommit, + ); + const markEditSaving = vi.spyOn(grid, "markEditSaving"); + await controller.begin({ rowId: "r1", columnId: "name" }); + const pending = controller.commit(); + + controller.invalidate(); + resolveValidation(true); + await pending; + + expect(markEditSaving).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it.each(["resolve", "reject"] as const)( + "invalidates an in-flight save before its %s can mutate edit state", + async (outcome) => { + let resolveSave!: () => void; + let rejectSave!: (error: Error) => void; + const save = new Promise((resolve, reject) => { + resolveSave = resolve; + rejectSave = reject; + }); + const onCommit = vi.fn(() => save); + const { grid, controller } = setup({}, onCommit); + const commitEditSucceeded = vi.spyOn(grid, "commitEditSucceeded"); + const markEditError = vi.spyOn(grid, "markEditError"); + await controller.begin({ rowId: "r1", columnId: "name" }); + const pending = controller.commit(); + expect(onCommit).toHaveBeenCalledOnce(); + + controller.invalidate(); + if (outcome === "resolve") resolveSave(); + else rejectSave(new Error("stale save failed")); + await pending; + + expect(commitEditSucceeded).not.toHaveBeenCalled(); + expect(markEditError).not.toHaveBeenCalled(); + }, + ); }); diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index 22985412c..15ea01a98 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -1973,7 +1973,10 @@ export function PretableSurface< readonly typedToken: number | null; }>({ activeToken: null, typedToken: null }); const editSessionRef = useRef(editSession); - const editControllerRef = useRef<{ cancel(): void } | null>(null); + const editControllerRef = useRef<{ + cancel(): void; + invalidate(): void; + } | null>(null); const beginEditWithSession = useCallback( ( input: Parameters[0], @@ -3346,6 +3349,7 @@ export function PretableSurface< useLayoutEffect(() => { editControllerRef.current = editController; return () => { + editController.invalidate(); if (editControllerRef.current === editController) { editControllerRef.current = null; } @@ -3401,8 +3405,12 @@ export function PretableSurface< // Negate the value the checkbox is *showing*, not raw truthiness: a cell // holding `"false"` renders unchecked, so its toggle must commit `true`. const current = toBooleanCell(resolveCellValue(row, column)); - await editController.begin({ rowId, columnId: column.id }, !current); - await editController.commit(); + const authorization = await editController.begin( + { rowId, columnId: column.id }, + !current, + ); + if (authorization === null) return; + await editController.commit(undefined, authorization); }; // --------------------------------------------------------------------- diff --git a/packages/react/src/use-cell-edit-controller.ts b/packages/react/src/use-cell-edit-controller.ts index cb94a393c..4bad24f48 100644 --- a/packages/react/src/use-cell-edit-controller.ts +++ b/packages/react/src/use-cell-edit-controller.ts @@ -10,14 +10,23 @@ import type { PretableColumn, PretableEditInput } from "./types"; import { parseDraftForType } from "./editors/type-parsing"; +declare const cellEditAuthorizationBrand: unique symbol; +interface CellEditAuthorization { + readonly [cellEditAuthorizationBrand]: true; +} + export interface CellEditController { begin( addr: { readonly rowId: TRowId; readonly columnId: string }, initialDraft?: unknown, provenance?: { readonly seededFromTyping?: boolean }, + ): Promise; + commit( + moveDirection?: PretableFocusDirection, + authorization?: CellEditAuthorization, ): Promise; - commit(moveDirection?: PretableFocusDirection): Promise; cancel(): void; + invalidate(): void; } export interface CellEditControllerOptions< @@ -72,6 +81,12 @@ export function createCellEditController< // Monotonic token: every begin()/cancel() bumps it, so a stale async // resolution (editable/commit) can detect it is no longer the active edit. let token = 0; + let activeAuthorization: CellEditAuthorization | null = null; + + const invalidate = () => { + token += 1; + activeAuthorization = null; + }; const inputFor = (addr: { readonly rowId: TRowId; @@ -95,7 +110,7 @@ export function createCellEditController< return { async begin(addr, initialDraft, provenance) { const input = inputFor(addr); - if (!input) return; + if (!input) return null; const editable = input.column.editable ?? false; const seed = initialDraft !== undefined @@ -104,36 +119,50 @@ export function createCellEditController< ? input.column.formatEditValue(input.value, input) : input.value; - if (editable === false) return; + if (editable === false) return null; + const myToken = (token += 1); + const authorization = {} as CellEditAuthorization; + activeAuthorization = authorization; if (editable === true) { grid.beginEdit(addr, { draft: seed, status: "editing", seededFromTyping: provenance?.seededFromTyping ?? false, }); - token += 1; - return; + return authorization; } // async / function editable - const myToken = (token += 1); grid.beginEdit(addr, { draft: seed, status: "checking", seededFromTyping: provenance?.seededFromTyping ?? false, }); const allowed = await editable(input); - if (myToken !== token) return; // stale - if (allowed) grid.markEditing(); - else grid.cancelEdit(); + if (myToken !== token || activeAuthorization !== authorization) + return null; + if (allowed) { + grid.markEditing(); + return authorization; + } + activeAuthorization = null; + grid.cancelEdit(); + return null; }, - async commit(moveDirection) { + async commit(moveDirection, authorization) { + if ( + authorization !== undefined && + authorization !== activeAuthorization + ) { + return; + } const editing = grid.getSnapshot().editing; if (!editing) return; const addr = { rowId: editing.rowId, columnId: editing.columnId }; const input = inputFor(addr); if (!input) return; const myToken = (token += 1); + activeAuthorization = null; const draft = editing.draft; let value: unknown; if (input.column.parseEditValue) { @@ -184,9 +213,11 @@ export function createCellEditController< }, cancel() { - token += 1; + invalidate(); grid.cancelEdit(); }, + + invalidate, }; } From 7fba52b385ab3e06ba6006c2d76b520b0d568e5b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 19:50:23 -0700 Subject: [PATCH 11/16] feat: add native date format options --- packages/core/src/public_api.ts | 1 + packages/core/src/types.ts | 1 + packages/grid-core/src/types.ts | 6 + .../column-helper-number-format.test.tsx | 25 ++ .../src/__tests__/date-formatters.test.ts | 293 ++++++++++++++++++ .../__tests__/indexed-presentation.test.tsx | 7 +- packages/react/src/date-formatters.ts | 95 ++++++ packages/react/src/public_api.ts | 7 +- packages/react/src/types.ts | 8 + packages/react/src/use-pretable.ts | 2 + packages/row-model/src/column-types.ts | 34 ++ type-tests/core/date-format.types.ts | 85 +++++ type-tests/react/date-format.types.tsx | 78 +++++ 13 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 packages/react/src/__tests__/date-formatters.test.ts create mode 100644 packages/react/src/date-formatters.ts create mode 100644 type-tests/core/date-format.types.ts create mode 100644 type-tests/react/date-format.types.tsx diff --git a/packages/core/src/public_api.ts b/packages/core/src/public_api.ts index 377af1a8b..49d905799 100644 --- a/packages/core/src/public_api.ts +++ b/packages/core/src/public_api.ts @@ -62,6 +62,7 @@ export type { PretableColumnOptions, PretableColumnType, PretableColumnTypeFor, + PretableDateFormatOptions, PretableCompatibleAggregateSpec, PretableCompatibleAggregator, PretableDataRow, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ff293ff70..c48210903 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -72,6 +72,7 @@ export type { PretableColumnOptions, PretableColumnType, PretableColumnTypeFor, + PretableDateFormatOptions, PretableCompatibleAggregateSpec, PretableCompatibleAggregator, PretableDataRow, diff --git a/packages/grid-core/src/types.ts b/packages/grid-core/src/types.ts index b794b8498..c60bb33d4 100644 --- a/packages/grid-core/src/types.ts +++ b/packages/grid-core/src/types.ts @@ -1,6 +1,7 @@ import type { ColumnIdOf, ColumnValueOf, + PretableDateFormatOptions, PretableFormatInput, PretableRowId as IndexedPretableRowId, PretableRowModel, @@ -159,6 +160,11 @@ export interface PretableColumn { ) => string; /** Native number presentation; derivation and editing continue to use raw values. */ numberFormat?: Intl.NumberFormatOptions; + /** + * Native calendar-date presentation for canonical `YYYY-MM-DD` strings. + * Derivation and editing continue to use raw values. + */ + dateFormat?: PretableDateFormatOptions; /** * Render this column's aggregate on a group row. * diff --git a/packages/react/src/__tests__/column-helper-number-format.test.tsx b/packages/react/src/__tests__/column-helper-number-format.test.tsx index eed6f5459..475e5b4d6 100644 --- a/packages/react/src/__tests__/column-helper-number-format.test.tsx +++ b/packages/react/src/__tests__/column-helper-number-format.test.tsx @@ -233,4 +233,29 @@ describe("createColumnHelper numberFormat", () => { expect(merged[0]?.numberFormat).toBe(usd); expect(merged[0]?.header).toBe("Revenue"); }); + + it("keeps dateFormat schema-authoritative with the same merge policy", () => { + const schemaDateFormat = { dateStyle: "long" } as const; + const presentationDateFormat = { dateStyle: "short" } as const; + const merged = mergeModelPresentationColumnsForTesting( + [ + { + id: "due", + accessor: () => "2026-01-02", + value: () => "2026-01-02", + dateFormat: schemaDateFormat, + }, + ], + [ + { + id: "due", + header: "Due", + dateFormat: presentationDateFormat, + }, + ], + ); + + expect(merged[0]?.dateFormat).toBe(schemaDateFormat); + expect(merged[0]?.header).toBe("Due"); + }); }); diff --git a/packages/react/src/__tests__/date-formatters.test.ts b/packages/react/src/__tests__/date-formatters.test.ts new file mode 100644 index 000000000..a16c5a033 --- /dev/null +++ b/packages/react/src/__tests__/date-formatters.test.ts @@ -0,0 +1,293 @@ +import { dateValueToUtcMs } from "@pretable-internal/calendar-date"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + reconcileDateFormatters, + type DateFormatterCacheState, +} from "../date-formatters"; +import type { PretableColumn } from "../types"; + +type Row = { id: string; due: string | null }; + +function reconcile( + previous: DateFormatterCacheState | undefined, + columns: readonly PretableColumn[], + locale?: Intl.LocalesArgument, +) { + return reconcileDateFormatters(previous, columns, locale); +} + +function expectInvalidOption( + options: object, + expectedOption: PropertyKey, +): void { + expect(() => + reconcile( + undefined, + [ + { + id: "due", + dateFormat: options, + } as PretableColumn, + ], + "en-US", + ), + ).toThrowError( + expect.objectContaining({ + message: '[pretable] invalid dateFormat for column "due"', + cause: expect.objectContaining({ + message: expect.stringContaining(String(expectedOption)), + }), + }), + ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("native date formatter reconciliation", () => { + it("formats dateStyle and granular fields for en-US and en-GB", () => { + const dateStyle = { dateStyle: "long" } as const; + const granular = { + year: "numeric", + month: "2-digit", + day: "2-digit", + } as const; + const columns: PretableColumn[] = [ + { id: "due", dateFormat: dateStyle }, + { id: "settled", dateFormat: granular }, + ]; + const instant = dateValueToUtcMs("2026-01-02"); + + const us = reconcile(undefined, columns, "en-US").formatters; + const gb = reconcile(undefined, columns, "en-GB").formatters; + + expect(us.get("due")?.format(instant)).toBe("January 2, 2026"); + expect(gb.get("due")?.format(instant)).toBe("2 January 2026"); + expect(us.get("settled")?.format(instant)).toBe("01/02/2026"); + expect(gb.get("settled")?.format(instant)).toBe("02/01/2026"); + }); + + it("forces UTC even when the process uses a non-UTC time zone", () => { + const previousTimeZone = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; + + try { + const formatter = reconcile( + undefined, + [{ id: "due", dateFormat: { dateStyle: "full" } }], + "en-US", + ).formatters.get("due"); + + expect(formatter?.resolvedOptions().timeZone).toBe("UTC"); + expect(formatter?.format(dateValueToUtcMs("2026-01-01"))).toBe( + "Thursday, January 1, 2026", + ); + } finally { + if (previousTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = previousTimeZone; + } + }); + + it("retains allowed calendar and numbering-system options", () => { + const formatter = reconcile( + undefined, + [ + { + id: "due", + dateFormat: { + calendar: "gregory", + numberingSystem: "latn", + year: "numeric", + month: "long", + day: "numeric", + }, + }, + ], + "en-US", + ).formatters.get("due"); + + expect(formatter?.resolvedOptions()).toMatchObject({ + calendar: "gregory", + numberingSystem: "latn", + timeZone: "UTC", + }); + expect(formatter?.format(dateValueToUtcMs("2026-01-02"))).toBe( + "January 2, 2026", + ); + }); + + it("passes year 0000 to native locale presentation without literal assumptions", () => { + const formatter = reconcile( + undefined, + [ + { + id: "due", + dateFormat: { + era: "short", + year: "numeric", + month: "short", + day: "numeric", + }, + }, + ], + "en-US", + ).formatters.get("due"); + const expected = new Intl.DateTimeFormat("en-US", { + era: "short", + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }).format(dateValueToUtcMs("0000-01-01")); + + expect(formatter?.format(dateValueToUtcMs("0000-01-01"))).toBe(expected); + expect(expected).toMatch(/BC|BCE/); + }); + + it("wraps native-invalid combinations with column context and cause", () => { + expect(() => + reconcile( + undefined, + [ + { + id: "due", + dateFormat: { + dateStyle: "long", + year: "numeric", + }, + }, + ], + "en-US", + ), + ).toThrowError( + expect.objectContaining({ + message: '[pretable] invalid dateFormat for column "due"', + cause: expect.any(Error), + }), + ); + }); + + it("rejects enumerable and non-enumerable unknown own string keys", () => { + expectInvalidOption({ unknown: undefined }, "unknown"); + + const hidden = Object.defineProperty({}, "hidden", { + configurable: true, + value: undefined, + }); + expectInvalidOption(hidden, "hidden"); + }); + + it("rejects forbidden own keys even when their value is undefined", () => { + expectInvalidOption({ timeZone: undefined }, "timeZone"); + expectInvalidOption({ hour: undefined }, "hour"); + }); + + it("rejects every symbol own key", () => { + const option = Symbol("calendar-option"); + expectInvalidOption({ [option]: undefined }, option); + }); + + it("reuses stable formatters and rebuilds only a changed options identity", () => { + const constructor = vi.spyOn(Intl, "DateTimeFormat"); + const dueOptions = { dateStyle: "long" } as const; + const settledOptions = { dateStyle: "short" } as const; + const structurallyEqualSettledOptions = { dateStyle: "short" } as const; + const first = reconcile( + undefined, + [ + { id: "due", dateFormat: dueOptions }, + { id: "settled", dateFormat: settledOptions }, + ], + "en-US", + ); + const second = reconcile( + first, + [ + { id: "due", dateFormat: dueOptions }, + { id: "settled", dateFormat: structurallyEqualSettledOptions }, + ], + "en-US", + ); + + expect(constructor).toHaveBeenCalledTimes(3); + expect(second.formatters.get("due")).toBe(first.formatters.get("due")); + expect(second.formatters.get("settled")).not.toBe( + first.formatters.get("settled"), + ); + }); + + it("uses locale identity and rebuilds every formatter when it changes", () => { + const constructor = vi.spyOn(Intl, "DateTimeFormat"); + const locale = ["en-US"]; + const options = { dateStyle: "long" } as const; + const first = reconcile( + undefined, + [ + { id: "due", dateFormat: options }, + { id: "settled", dateFormat: options }, + ], + locale, + ); + const sameLocale = reconcile( + first, + [ + { id: "due", dateFormat: options }, + { id: "settled", dateFormat: options }, + ], + locale, + ); + const equalButDistinctLocale = reconcile( + sameLocale, + [ + { id: "due", dateFormat: options }, + { id: "settled", dateFormat: options }, + ], + ["en-US"], + ); + + expect(constructor).toHaveBeenCalledTimes(4); + expect(sameLocale.formatters.get("due")).toBe(first.formatters.get("due")); + expect(equalButDistinctLocale.formatters.get("due")).not.toBe( + first.formatters.get("due"), + ); + }); + + it("keeps the registry coherent across reorder and removal", () => { + const dueOptions = { dateStyle: "long" } as const; + const settledOptions = { dateStyle: "short" } as const; + const first = reconcile( + undefined, + [ + { id: "due", dateFormat: dueOptions }, + { id: "settled", dateFormat: settledOptions }, + ], + "en-US", + ); + const reordered = reconcile( + first, + [ + { id: "settled", dateFormat: settledOptions }, + { id: "due", dateFormat: dueOptions }, + ], + "en-US", + ); + const removed = reconcile( + reordered, + [{ id: "settled", dateFormat: settledOptions }], + "en-US", + ); + + expect(reordered.formatters.get("due")).toBe(first.formatters.get("due")); + expect(reordered.formatters.get("settled")).toBe( + first.formatters.get("settled"), + ); + expect(removed.formatters.size).toBe(1); + expect(removed.formatters.has("due")).toBe(false); + expect(removed.optionsByColumnId.has("due")).toBe(false); + expect(removed.formatters.get("settled")).toBe( + first.formatters.get("settled"), + ); + }); +}); diff --git a/packages/react/src/__tests__/indexed-presentation.test.tsx b/packages/react/src/__tests__/indexed-presentation.test.tsx index 85d5373be..4c5684dff 100644 --- a/packages/react/src/__tests__/indexed-presentation.test.tsx +++ b/packages/react/src/__tests__/indexed-presentation.test.tsx @@ -2,7 +2,7 @@ import "@testing-library/jest-dom/vitest"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { Pretable, PretableSurface } from "../public_api"; +import { isValidDateValue, Pretable, PretableSurface } from "../public_api"; import type { PretableColumn } from "../types"; type Row = { key: string; amount: number }; @@ -17,6 +17,11 @@ const columns: PretableColumn[] = [ ]; describe("indexed presentation integration", () => { + it("re-exports the canonical date validator from the React entry point", () => { + expect(isValidDateValue("2026-01-02")).toBe(true); + expect(isValidDateValue("2026-02-30")).toBe(false); + }); + it("formats raw indexed values with the configured locale", () => { const view = render( ; + +export interface DateFormatterCacheState { + readonly locale: Intl.LocalesArgument | undefined; + readonly optionsByColumnId: ReadonlyMap; + readonly formatters: DateFormatterRegistry; +} + +type PretableDateFormatKey = keyof { + [ + TKey in keyof PretableDateFormatOptions as NonNullable< + PretableDateFormatOptions[TKey] + > extends never + ? never + : TKey + ]: true; +}; + +const DATE_FORMAT_KEY_RECORD = { + localeMatcher: true, + calendar: true, + numberingSystem: true, + dateStyle: true, + weekday: true, + era: true, + year: true, + month: true, + day: true, + formatMatcher: true, +} as const satisfies Record; + +const DATE_FORMAT_KEYS: ReadonlySet = new Set( + Object.keys(DATE_FORMAT_KEY_RECORD), +); + +function assertValidDateFormatKeys(options: PretableDateFormatOptions): void { + for (const key of Reflect.ownKeys(options)) { + if (typeof key !== "string" || !DATE_FORMAT_KEYS.has(key)) { + throw new TypeError(`invalid dateFormat option "${String(key)}"`); + } + } +} + +function createDateFormatter( + columnId: string, + locale: Intl.LocalesArgument | undefined, + options: PretableDateFormatOptions, +): Intl.DateTimeFormat { + try { + assertValidDateFormatKeys(options); + return new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" }); + } catch (cause) { + throw new Error(`[pretable] invalid dateFormat for column "${columnId}"`, { + cause, + }); + } +} + +export function reconcileDateFormatters( + previous: DateFormatterCacheState | undefined, + columns: readonly PretableColumn[], + locale?: Intl.LocalesArgument, +): DateFormatterCacheState { + const optionsByColumnId = new Map(); + const formatters = new Map(); + const canReuseLocale = + previous !== undefined && Object.is(previous.locale, locale); + + for (const column of columns) { + const options = column.dateFormat; + if (options === undefined) continue; + + optionsByColumnId.set(column.id, options); + const previousFormatter = previous?.formatters.get(column.id); + if ( + canReuseLocale && + previous?.optionsByColumnId.get(column.id) === options && + previousFormatter !== undefined + ) { + formatters.set(column.id, previousFormatter); + } else { + formatters.set( + column.id, + createDateFormatter(column.id, locale, options), + ); + } + } + + return { locale, optionsByColumnId, formatters }; +} diff --git a/packages/react/src/public_api.ts b/packages/react/src/public_api.ts index 91aae096a..6d6ad074f 100644 --- a/packages/react/src/public_api.ts +++ b/packages/react/src/public_api.ts @@ -151,7 +151,11 @@ export type { DensityHeights } from "@pretable/ui"; // Re-exports from @pretable/core (the engine types react users typically // touch — full headless surface lives in @pretable/core) -export { describeRowSelection, numberFormats } from "@pretable/core"; +export { + describeRowSelection, + isValidDateValue, + numberFormats, +} from "@pretable/core"; export type { AutosizeOptions, ColumnAlign, @@ -179,6 +183,7 @@ export type { PretableColumnDefinition, PretableColumnDerivation, PretableColumnType, + PretableDateFormatOptions, PretableCompatibleAggregator, PretableCompatibleAggregateSpec, PretableChangeOperation, diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index c380c2431..bcefa5846 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -9,6 +9,7 @@ import type { PretableColumnAccessorKind, PretableColumnDefinition, PretableColumnType, + PretableDateFormatOptions, PretableEditStatus, PretableFocusDirection, PretableFormatInput as PretableCoreFormatInput, @@ -392,6 +393,11 @@ export type PretableColumnFactoryOptions< * `formatAggregate` outranks it for group aggregates. */ readonly numberFormat?: Intl.NumberFormatOptions; + /** + * Native calendar-date presentation for canonical `YYYY-MM-DD` strings. + * `format` outranks it; derivation and editing continue to use raw values. + */ + readonly dateFormat?: PretableDateFormatOptions; readonly format?: (input: { readonly value: TValue; readonly row: TRow; @@ -593,6 +599,8 @@ export interface PretableColumn { }) => string; /** Native number presentation; derivation and editing keep raw values. */ numberFormat?: Intl.NumberFormatOptions; + /** Native calendar-date presentation; derivation and editing keep raw values. */ + dateFormat?: PretableDateFormatOptions; formatAggregate?: (input: { value: unknown; column: PretableColumn; diff --git a/packages/react/src/use-pretable.ts b/packages/react/src/use-pretable.ts index 5a9fdbafe..480b1892c 100644 --- a/packages/react/src/use-pretable.ts +++ b/packages/react/src/use-pretable.ts @@ -40,6 +40,7 @@ export type ModelSchemaColumn = { readonly compare?: unknown; readonly aggregate?: unknown; readonly numberFormat?: unknown; + readonly dateFormat?: unknown; readonly format?: unknown; readonly formatAggregate?: unknown; }; @@ -82,6 +83,7 @@ export function mergeModelPresentationColumnsForTesting< compare: schema.compare, aggregate: schema.aggregate, numberFormat: schema.numberFormat, + dateFormat: schema.dateFormat, format: schema.format, formatAggregate: schema.formatAggregate, }; diff --git a/packages/row-model/src/column-types.ts b/packages/row-model/src/column-types.ts index fe8925494..712ff2852 100644 --- a/packages/row-model/src/column-types.ts +++ b/packages/row-model/src/column-types.ts @@ -18,6 +18,31 @@ export type PretableGroupKey = export type PretableColumnType = "text" | "number" | "date" | "enum" | "boolean"; +/** + * Native presentation options for canonical `YYYY-MM-DD` strings. + * + * Pretable fixes the formatter time zone to UTC and excludes every time or + * time-zone option. This configuration affects presentation only; derivation, + * editing, and row-model reads continue to use raw values. + * + * @public + */ +export type PretableDateFormatOptions = { + [TKey in keyof Intl.DateTimeFormatOptions]?: TKey extends + | "localeMatcher" + | "calendar" + | "numberingSystem" + | "dateStyle" + | "weekday" + | "era" + | "year" + | "month" + | "day" + | "formatMatcher" + ? Intl.DateTimeFormatOptions[TKey] + : never; +}; + /** @public */ export interface PretableAggregator< TRow extends object = object, @@ -158,6 +183,11 @@ export interface PretableColumnDefinition< * row-model read continue to see the raw value. */ readonly numberFormat?: Intl.NumberFormatOptions; + /** + * Native calendar-date presentation for canonical `YYYY-MM-DD` strings. + * `format` outranks it; derivation and editing continue to use raw values. + */ + readonly dateFormat?: PretableDateFormatOptions; readonly format?: ( input: PretableFormatInput< TRow, @@ -229,6 +259,8 @@ export type PretableColumnOptions< readonly aggregate?: TAggregate; /** Native number presentation; `format` outranks it for data cells. */ readonly numberFormat?: Intl.NumberFormatOptions; + /** Native calendar-date presentation; `format` outranks it for data cells. */ + readonly dateFormat?: PretableDateFormatOptions; readonly format?: (input: { readonly value: TValue; readonly row: TRow; @@ -292,6 +324,8 @@ export interface PretableColumnHelper { readonly aggregate?: TAggregate; /** Native number presentation; `format` outranks it for data cells. */ readonly numberFormat?: Intl.NumberFormatOptions; + /** Native calendar-date presentation; `format` outranks it for data cells. */ + readonly dateFormat?: PretableDateFormatOptions; readonly format?: (input: { readonly value: TValue; readonly row: TRow; diff --git a/type-tests/core/date-format.types.ts b/type-tests/core/date-format.types.ts new file mode 100644 index 000000000..bf5364684 --- /dev/null +++ b/type-tests/core/date-format.types.ts @@ -0,0 +1,85 @@ +import { + createColumnHelper, + isValidDateValue, + type PretableColumn, + type PretableDateFormatOptions, +} from "@pretable/core"; +// @ts-expect-error the strict key alias is intentionally private +import type { PretableDateFormatKey } from "@pretable/core"; +import type { Equal, Expect } from "../shared/assert"; + +interface Schedule { + id: string; + due: string | null; + label: string; +} + +const granular = { + year: "numeric", + month: "short", + day: "2-digit", +} satisfies PretableDateFormatOptions; + +const styled = { + dateStyle: "long", + calendar: "gregory", + numberingSystem: "latn", +} satisfies PretableDateFormatOptions; + +const column = createColumnHelper(); + +export const scheduleColumns = [ + column.accessor("due", { type: "date", dateFormat: granular }), + column.accessor("computedDue", (row) => row.due, { + type: "date", + dateFormat: styled, + }), + column.accessor("label", { type: "text", dateFormat: granular }), +] as const; + +type _DirectDateFormat = Expect< + Equal< + (typeof scheduleColumns)[0]["dateFormat"], + PretableDateFormatOptions | undefined + > +>; +type _ComputedDateFormat = Expect< + Equal< + (typeof scheduleColumns)[1]["dateFormat"], + PretableDateFormatOptions | undefined + > +>; + +const plainColumn: PretableColumn = { + id: "due", + type: "date", + dateFormat: styled, +}; +void plainColumn; + +const broadOptions: Intl.DateTimeFormatOptions = { year: "numeric" }; +// @ts-expect-error broadly typed native options may contain forbidden fields +const strictOptions: PretableDateFormatOptions = broadOptions; +void strictOptions; + +const forbiddenOptions: PretableDateFormatOptions = { + // @ts-expect-error time fields are forbidden + hour: "numeric", + // @ts-expect-error Pretable always forces UTC + timeZone: "UTC", + // @ts-expect-error time styles are forbidden + timeStyle: "short", + // @ts-expect-error fractional seconds are forbidden + fractionalSecondDigits: 3, +}; +void forbiddenOptions; + +declare const candidate: unknown; +if (isValidDateValue(candidate)) { + const canonical: string = candidate; + void canonical; +} + +void (null as unknown as _DirectDateFormat); +void (null as unknown as _ComputedDateFormat); +void (null as unknown as PretableDateFormatKey); diff --git a/type-tests/react/date-format.types.tsx b/type-tests/react/date-format.types.tsx new file mode 100644 index 000000000..2784d919c --- /dev/null +++ b/type-tests/react/date-format.types.tsx @@ -0,0 +1,78 @@ +import { createColumnHelper, createLocalRowModel } from "@pretable/core"; +import { + isValidDateValue, + usePretable, + type PretableColumn, + type PretableDateFormatOptions, +} from "@pretable/react"; +// @ts-expect-error the strict key alias is intentionally private +import type { PretableDateFormatKey } from "@pretable/react"; +import type { Equal, Expect } from "../shared/assert"; + +interface Schedule { + id: string; + due: string | null; + label: string; +} + +const format = { + year: "numeric", + month: "short", + day: "2-digit", +} satisfies PretableDateFormatOptions; + +const column = createColumnHelper(); +const columns = [ + column.accessor("due", { + type: "date", + dateFormat: format, + render: ({ formattedValue }) => formattedValue, + }), + column.accessor("computedDue", (row) => row.due, { + type: "date", + dateFormat: { dateStyle: "long" }, + render: ({ formattedValue }) => formattedValue, + }), +] as const; + +type _ReactDateFormat = Expect< + Equal< + (typeof columns)[0]["dateFormat"], + PretableDateFormatOptions | undefined + > +>; + +usePretable({ + rows: [{ id: "s1", due: "2026-01-02", label: "Launch" }], + columns, + viewportHeight: 320, +}); + +const model = createLocalRowModel({ + rows: [{ id: "s1", due: "2026-01-02", label: "Launch" }], + columns, +}); +usePretable({ + model, + columns: [ + { id: "computedDue", header: "Computed" }, + { id: "due", header: "Due" }, + ], + viewportHeight: 320, +}); + +const plainColumn: PretableColumn = { + id: "due", + type: "date", + dateFormat: format, +}; +void plainColumn; + +declare const candidate: unknown; +if (isValidDateValue(candidate)) { + const canonical: string = candidate; + void canonical; +} + +void (null as unknown as _ReactDateFormat); +void (null as unknown as PretableDateFormatKey); From 5c5e04863f2a28d23313fe61d26ce01841b4a94c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 20:04:57 -0700 Subject: [PATCH 12/16] fix(react): enforce canonical date column types --- packages/react/src/public_api.ts | 1 + packages/react/src/types.ts | 15 +-- type-tests/react/date-format.types.tsx | 148 ++++++++++++++++++++- type-tests/react/model-inference.types.tsx | 2 +- 4 files changed, 148 insertions(+), 18 deletions(-) diff --git a/packages/react/src/public_api.ts b/packages/react/src/public_api.ts index 6d6ad074f..5e44e965e 100644 --- a/packages/react/src/public_api.ts +++ b/packages/react/src/public_api.ts @@ -183,6 +183,7 @@ export type { PretableColumnDefinition, PretableColumnDerivation, PretableColumnType, + PretableColumnTypeFor, PretableDateFormatOptions, PretableCompatibleAggregator, PretableCompatibleAggregateSpec, diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index bcefa5846..f873bf78f 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -9,6 +9,7 @@ import type { PretableColumnAccessorKind, PretableColumnDefinition, PretableColumnType, + PretableColumnTypeFor, PretableDateFormatOptions, PretableEditStatus, PretableFocusDirection, @@ -87,19 +88,7 @@ export type PretableRowIdRequirement = [ : { readonly getRowId: (row: TRow) => TRowId }; /** Value-compatible column kinds accepted by the React-aware helper. @public */ -export type PretableReactColumnTypeFor = [TValue] extends [never] - ? never - : [NonNullable] extends [never] - ? Exclude - : NonNullable extends number - ? "number" - : NonNullable extends boolean - ? "boolean" - : NonNullable extends Date - ? "date" - : NonNullable extends string - ? "text" | "enum" | "date" - : PretableColumnType; +export type PretableReactColumnTypeFor = PretableColumnTypeFor; /** Stable derivation fields visible to authoritative presentation callbacks. @public */ export interface PretableReactColumnContext< diff --git a/type-tests/react/date-format.types.tsx b/type-tests/react/date-format.types.tsx index 2784d919c..45e7c6225 100644 --- a/type-tests/react/date-format.types.tsx +++ b/type-tests/react/date-format.types.tsx @@ -3,7 +3,9 @@ import { isValidDateValue, usePretable, type PretableColumn, + type PretableColumnTypeFor, type PretableDateFormatOptions, + type PretableReactColumnTypeFor, } from "@pretable/react"; // @ts-expect-error the strict key alias is intentionally private import type { PretableDateFormatKey } from "@pretable/react"; @@ -13,6 +15,12 @@ interface Schedule { id: string; due: string | null; label: string; + optionalDue: string | undefined; + nativeDate: Date; + payload: { readonly source: string }; + unsafe: any; + amount: number; + active: boolean; } const format = { @@ -26,12 +34,12 @@ const columns = [ column.accessor("due", { type: "date", dateFormat: format, - render: ({ formattedValue }) => formattedValue, + wrap: true, }), column.accessor("computedDue", (row) => row.due, { type: "date", dateFormat: { dateStyle: "long" }, - render: ({ formattedValue }) => formattedValue, + wrap: true, }), ] as const; @@ -43,13 +51,37 @@ type _ReactDateFormat = Expect< >; usePretable({ - rows: [{ id: "s1", due: "2026-01-02", label: "Launch" }], + rows: [ + { + id: "s1", + due: "2026-01-02", + label: "Launch", + optionalDue: undefined, + nativeDate: new Date(0), + payload: { source: "api" }, + unsafe: undefined, + amount: 1, + active: true, + }, + ], columns, viewportHeight: 320, }); const model = createLocalRowModel({ - rows: [{ id: "s1", due: "2026-01-02", label: "Launch" }], + rows: [ + { + id: "s1", + due: "2026-01-02", + label: "Launch", + optionalDue: undefined, + nativeDate: new Date(0), + payload: { source: "api" }, + unsafe: undefined, + amount: 1, + active: true, + }, + ], columns, }); usePretable({ @@ -68,6 +100,106 @@ const plainColumn: PretableColumn = { }; void plainColumn; +column.accessor("nativeDate", { + // @ts-expect-error Date instances are not built-in calendar dates + type: "date", + wrap: true, +}); +column.accessor("computedNativeDate", (row) => row.nativeDate, { + // @ts-expect-error computed Date values cannot escape the calendar-date domain + type: "date", + wrap: true, +}); +column.accessor("optionalDue", { + // @ts-expect-error undefined is outside the built-in calendar-date domain + type: "date", + wrap: true, +}); +column.accessor("computedOptionalDue", (row) => row.optionalDue, { + // @ts-expect-error computed values cannot admit undefined calendar dates + type: "date", + wrap: true, +}); +column.accessor("payload", { + // @ts-expect-error object values are not built-in calendar dates + type: "date", + wrap: true, +}); +column.accessor("computedPayload", (row) => row.payload, { + // @ts-expect-error computed objects are not built-in calendar dates + type: "date", + wrap: true, +}); +column.accessor("unsafe", { + // @ts-expect-error any-valued fields cannot opt into built-in calendar dates + type: "date", + wrap: true, +}); +column.accessor("computedUnsafe", (row) => row.unsafe, { + // @ts-expect-error computed any values cannot opt into calendar dates + type: "date", + wrap: true, +}); + +column.accessor("due", { + type: "date", + wrap: true, +}); +column.accessor("computedCanonicalDue", (row) => row.due, { + type: "date", + wrap: true, +}); +column.accessor("amount", { + type: "number", + wrap: true, +}); +column.accessor("active", { + type: "boolean", + wrap: true, +}); +column.accessor("label", { + type: "text", + wrap: true, +}); +column.accessor("label", { + type: "enum", + wrap: true, +}); + +type _DateParity = Expect< + Equal, PretableColumnTypeFor> +>; +type _OptionalStringParity = Expect< + Equal< + PretableReactColumnTypeFor, + PretableColumnTypeFor + > +>; +type _ObjectParity = Expect< + Equal< + PretableReactColumnTypeFor<{ readonly source: string }>, + PretableColumnTypeFor<{ readonly source: string }> + > +>; +type _AnyParity = Expect< + Equal, PretableColumnTypeFor> +>; +type _CanonicalDateParity = Expect< + Equal< + PretableReactColumnTypeFor, + PretableColumnTypeFor + > +>; +type _NumberParity = Expect< + Equal, PretableColumnTypeFor> +>; +type _BooleanParity = Expect< + Equal, PretableColumnTypeFor> +>; +type _StringParity = Expect< + Equal, PretableColumnTypeFor> +>; + declare const candidate: unknown; if (isValidDateValue(candidate)) { const canonical: string = candidate; @@ -75,4 +207,12 @@ if (isValidDateValue(candidate)) { } void (null as unknown as _ReactDateFormat); +void (null as unknown as _DateParity); +void (null as unknown as _OptionalStringParity); +void (null as unknown as _ObjectParity); +void (null as unknown as _AnyParity); +void (null as unknown as _CanonicalDateParity); +void (null as unknown as _NumberParity); +void (null as unknown as _BooleanParity); +void (null as unknown as _StringParity); void (null as unknown as PretableDateFormatKey); diff --git a/type-tests/react/model-inference.types.tsx b/type-tests/react/model-inference.types.tsx index a362c860d..a31db96b5 100644 --- a/type-tests/react/model-inference.types.tsx +++ b/type-tests/react/model-inference.types.tsx @@ -423,9 +423,9 @@ calendarColumn.accessor("label", { // @ts-expect-error React text columns reject extrema aggregate: "min", }); +// @ts-expect-error Date instances are not built-in calendar dates or extrema calendarColumn.accessor("legacyDate", { type: "date", - // @ts-expect-error Date-valued extrema are not admitted as built-ins aggregate: "max", }); void calendarColumns; From 1fc499250232069f2683cdabbfc890831f76fec5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 20:32:44 -0700 Subject: [PATCH 13/16] feat(react): format calendar dates natively --- .../column-helper-date-format.test.tsx | 87 ++++ packages/react/src/__tests__/copy.test.ts | 87 ++++ .../src/__tests__/csv-export-surface.test.tsx | 50 ++ packages/react/src/__tests__/csv.test.ts | 108 +++++ .../date-formatting-surface.test.tsx | 457 ++++++++++++++++++ .../src/__tests__/group-row-render.test.tsx | 72 +++ .../src/__tests__/paste-surface.test.tsx | 38 ++ .../src/__tests__/value-formatting.test.ts | 278 +++++++++-- packages/react/src/copy.ts | 16 +- packages/react/src/csv.ts | 20 +- packages/react/src/group-row.tsx | 13 +- packages/react/src/pretable-surface.tsx | 24 +- packages/react/src/value-formatting.ts | 78 +-- .../src/__tests__/transitions.test.ts | 47 ++ 14 files changed, 1282 insertions(+), 93 deletions(-) create mode 100644 packages/react/src/__tests__/column-helper-date-format.test.tsx create mode 100644 packages/react/src/__tests__/date-formatting-surface.test.tsx diff --git a/packages/react/src/__tests__/column-helper-date-format.test.tsx b/packages/react/src/__tests__/column-helper-date-format.test.tsx new file mode 100644 index 000000000..765660b71 --- /dev/null +++ b/packages/react/src/__tests__/column-helper-date-format.test.tsx @@ -0,0 +1,87 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createColumnHelper, createLocalRowModel } from "@pretable/core"; + +import { PretableSurface } from "../pretable-surface"; + +afterEach(cleanup); + +type DatedRow = { + id: string; + region: string; + due: string | null; +}; + +const rows: DatedRow[] = [ + { id: "r1", region: "West", due: "2026-08-11" }, + { id: "r2", region: "West", due: "2025-01-02" }, +]; +const dateFormat = { dateStyle: "medium" } as const; +const helper = createColumnHelper(); + +function texts(container: HTMLElement, columnId: string): string[] { + return Array.from( + container.querySelectorAll(`[data-pretable-column-id="${columnId}"]`), + ).map((node) => node.textContent ?? ""); +} + +describe("createColumnHelper dateFormat", () => { + it("formats rows-mode data and inherited date extrema", () => { + const columns = [ + helper.accessor("region", { type: "text", header: "Region" }), + helper.accessor("due", { + type: "date", + header: "Due", + aggregate: "min", + dateFormat, + }), + ] as const; + + const view = render( + row.id} + initialExpansion={{ kind: "expanded" }} + locale="en-US" + onQueryChange={() => {}} + query={{ + filters: [], + sort: [], + rowGroups: [{ columnId: "region" }], + }} + rows={rows} + viewportHeight={240} + />, + ); + + expect(texts(view.container, "due")).toContain("Jan 2, 2025"); + expect(texts(view.container, "due")).toContain("Aug 11, 2026"); + }); + + it("formats the schema-owned dateFormat in explicit-model mode", () => { + const columns = [ + helper.accessor("due", { + type: "date", + header: "Due", + dateFormat, + }), + ] as const; + const model = createLocalRowModel({ rows, columns }); + + const view = render( + , + ); + + expect(texts(view.container, "due")).toContain("Aug 11, 2026"); + expect(texts(view.container, "due")).toContain("Jan 2, 2025"); + model.dispose(); + }); +}); diff --git a/packages/react/src/__tests__/copy.test.ts b/packages/react/src/__tests__/copy.test.ts index ae04e4835..bb53fc4b0 100644 --- a/packages/react/src/__tests__/copy.test.ts +++ b/packages/react/src/__tests__/copy.test.ts @@ -11,6 +11,7 @@ import { ROW_SELECT_COLUMN_ID } from "../pretable-surface"; import { createColumnHelper, createLocalRowModel, + GROUP_COLUMN_ID, type PretableRowModelSnapshot, } from "@pretable/core"; import type { PretableColumn } from "../types"; @@ -607,3 +608,89 @@ describe("serializeRanges HTML type hints", () => { expect(html).toContain(`a b`); }); }); + +describe("serializeRanges native date formatting", () => { + type DateRow = { id: string; region: string; due: string | null }; + const dateColumn = createColumnHelper(); + const dateModelColumns = [ + dateColumn.accessor("region", { type: "text" }), + dateColumn.accessor("due", { type: "date", aggregate: "min" }), + ] as const; + const dateRows: DateRow[] = [ + { id: "r1", region: "A", due: "2026-08-11" }, + { id: "r2", region: "B", due: "2025-01-02" }, + ]; + const presentation: PretableColumn[] = [ + { id: GROUP_COLUMN_ID, header: "Group" }, + { + id: "due", + header: "Due", + type: "date", + dateFormat: { dateStyle: "medium" }, + }, + ]; + + it("uses one localized pipeline for standalone TSV and HTML", () => { + const model = createLocalRowModel({ + rows: dateRows, + columns: dateModelColumns, + }); + const out = serializeRanges({ + ranges: [range("r1", "r2", "due", "due")], + rowModelSnapshot: model.getState().snapshot, + columns: presentation, + locale: "en-US", + }); + + expect(out?.text).toBe("Aug 11, 2026\nJan 2, 2025"); + expect(out?.html).toContain("Aug 11, 2026"); + expect(out?.html).toContain("Jan 2, 2025"); + model.dispose(); + }); + + it("formats group date extrema when a selected span crosses groups", async () => { + const model = createLocalRowModel({ + rows: dateRows, + columns: dateModelColumns, + initialExpansion: { kind: "expanded" }, + }); + await model.setQuery({ + filters: [], + sort: [], + rowGroups: [{ columnId: "region" }], + }).finished; + const out = serializeRanges({ + ranges: [range("r1", "r2", "due", "due")], + rowModelSnapshot: model.getState().snapshot, + columns: presentation, + locale: "en-US", + }); + + expect(out?.text.split("\n")).toEqual([ + "Aug 11, 2026", + "Jan 2, 2025", + "Jan 2, 2025", + ]); + expect(out?.html?.match(/Jan 2, 2025/g)).toHaveLength(2); + model.dispose(); + }); + + it("leaves noncanonical strings on the existing raw fallback", () => { + const looseColumn = createColumnHelper<{ id: string; due: string }>(); + const model = createLocalRowModel({ + rows: [{ id: "invalid", due: "2026-02-30" }], + columns: [looseColumn.accessor("due", { type: "text" })] as const, + }); + const out = serializeRanges({ + ranges: [range("invalid", "invalid", "due", "due")], + rowModelSnapshot: model.getState().snapshot, + columns: [ + { id: "due", dateFormat: { dateStyle: "medium" } }, + ] as PretableColumn<{ id: string; due: string }>[], + locale: "en-US", + }); + + expect(out?.text).toBe("2026-02-30"); + model.dispose(); + }); +}); diff --git a/packages/react/src/__tests__/csv-export-surface.test.tsx b/packages/react/src/__tests__/csv-export-surface.test.tsx index 09828b3e7..d629f7562 100644 --- a/packages/react/src/__tests__/csv-export-surface.test.tsx +++ b/packages/react/src/__tests__/csv-export-surface.test.tsx @@ -275,6 +275,56 @@ describe("grid.exportCsv delivery", () => { }); }); +describe("grid.exportCsv native date formatting", () => { + it("reuses the mounted registry and exports localized canonical dates", () => { + const NativeDateTimeFormat = Intl.DateTimeFormat; + const construct = vi.fn(function DateTimeFormat( + locales?: Intl.LocalesArgument, + options?: Intl.DateTimeFormatOptions, + ) { + return new NativeDateTimeFormat(locales, options); + }); + vi.spyOn(Intl, "DateTimeFormat").mockImplementation( + construct as unknown as Intl.DateTimeFormatConstructor, + ); + type DateRow = { id: string; due: string | null }; + const saveFile = vi.fn(); + let grid: PretableSurfaceGrid< + DateRow, + string, + readonly PretableColumn[] + > | null = null; + render( + + ariaLabel="date csv export" + columns={[ + { + id: "due", + header: "Due", + dateFormat: { dateStyle: "medium" }, + }, + ]} + getRowId={(row) => row.id} + locale="en-US" + onGridReady={(ready) => { + grid = ready; + }} + rows={[{ id: "r1", due: "2026-08-11" }]} + saveFile={saveFile} + viewportHeight={200} + />, + ); + expect(construct).toHaveBeenCalledTimes(1); + + act(() => { + grid!.exportCsv({ bom: false }); + }); + + expect(construct).toHaveBeenCalledTimes(1); + expect(saved(saveFile).text).toBe('Due\r\n"Aug 11, 2026"'); + }); +}); + describe("grid.exportCsv option merging", () => { it("merges surface csvOptions under the per-call options", () => { const saveFile = vi.fn(); diff --git a/packages/react/src/__tests__/csv.test.ts b/packages/react/src/__tests__/csv.test.ts index c7e97ab0d..f499a70ca 100644 --- a/packages/react/src/__tests__/csv.test.ts +++ b/packages/react/src/__tests__/csv.test.ts @@ -737,3 +737,111 @@ describe("serializeCsv rowIds — how selection-only export is expressed", () => expect(lines).toContain("a2,"); }); }); + +describe("serializeCsv native date formatting", () => { + type DateRow = { + id: string; + region: string; + due: string | null; + dueCount: string | null; + }; + const dateColumn = createColumnHelper(); + const dateModelColumns = [ + dateColumn.accessor("region", { type: "text" }), + dateColumn.accessor("due", { type: "date", aggregate: "min" }), + dateColumn.accessor("dueCount", { type: "date", aggregate: "count" }), + ] as const; + const dateRows: DateRow[] = [ + { + id: "r1", + region: "West", + due: "2026-08-11", + dueCount: "2026-08-11", + }, + { + id: "r2", + region: "West", + due: "2025-01-02", + dueCount: "2025-01-02", + }, + ]; + const dateColumns: PretableColumn[] = [ + { id: GROUP_COLUMN_ID, header: "Group" }, + { + id: "due", + header: "Due", + dateFormat: { dateStyle: "medium" }, + }, + { + id: "dueCount", + header: "Count", + dateFormat: { dateStyle: "medium" }, + numberFormat: { minimumIntegerDigits: 2 }, + }, + ]; + + it("formats standalone data and group rows with date/count precedence", async () => { + const model = createLocalRowModel({ + rows: dateRows, + columns: dateModelColumns, + initialExpansion: { kind: "expanded" }, + }); + await model.setQuery({ + filters: [], + sort: [], + rowGroups: [{ columnId: "region" }], + }).finished; + const file = serializeCsv({ + rowModelSnapshot: model.getState().snapshot, + columns: dateColumns, + locale: "en-US", + scope: "all", + options: { bom: false, includeAggregateRows: true }, + }); + + expect(file?.text).toContain('West,"Jan 2, 2025",02'); + expect(file?.text).toContain(',"Aug 11, 2026","Aug 11, 2026"'); + model.dispose(); + }); + + it("honors the standalone locale", () => { + const model = createLocalRowModel({ + rows: [dateRows[0]!], + columns: dateModelColumns, + }); + const file = serializeCsv({ + rowModelSnapshot: model.getState().snapshot, + columns: dateColumns.slice(1, 2), + locale: "en-GB", + scope: "all", + options: { bom: false }, + }); + + expect(file?.text).toBe("Due\r\n11 Aug 2026"); + model.dispose(); + }); + + it("keeps noncanonical fallback strings subject to raw formula safety", () => { + const loose = createColumnHelper<{ id: string; due: string }>(); + const model = createLocalRowModel({ + rows: [{ id: "r1", due: "=DATE(2026,8,11)" }], + columns: [loose.accessor("due", { type: "text" })] as const, + }); + const file = serializeCsv({ + rowModelSnapshot: model.getState().snapshot, + columns: [ + { + id: "due", + header: "Due", + dateFormat: { dateStyle: "medium" }, + }, + ], + locale: "en-US", + scope: "all", + options: { bom: false }, + }); + + expect(file?.text).toBe('Due\r\n"\'=DATE(2026,8,11)"'); + model.dispose(); + }); +}); diff --git a/packages/react/src/__tests__/date-formatting-surface.test.tsx b/packages/react/src/__tests__/date-formatting-surface.test.tsx new file mode 100644 index 000000000..7ce96c341 --- /dev/null +++ b/packages/react/src/__tests__/date-formatting-surface.test.tsx @@ -0,0 +1,457 @@ +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; +import * as React from "react"; +import { hydrateRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createColumnHelper, + createLocalRowModel, + type PretableQueryFor, +} from "@pretable/core"; +import { dateValueToUtcMs } from "@pretable-internal/calendar-date"; + +import { + PretableSurface, + type PretableSurfaceQueryColumns, +} from "../pretable-surface"; +import type { PretableColumn } from "../types"; + +type DateRow = { + id: string; + settlementDate: string | null; + dueDate: string | null; + amount: number; +}; + +const NativeDateTimeFormat = Intl.DateTimeFormat; +const NativeNumberFormat = Intl.NumberFormat; +const mediumDate = { dateStyle: "medium" } as const; +const rows: DateRow[] = [ + { + id: "later", + settlementDate: "2026-08-11", + dueDate: "2026-12-31", + amount: 1234.5, + }, + { + id: "earlier", + settlementDate: "2025-01-02", + dueDate: "2025-06-30", + amount: 5, + }, +]; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function cell( + container: HTMLElement, + rowId: string, + columnId = "settlementDate", +) { + return container.querySelector( + `[data-pretable-row-id="${rowId}"] [data-pretable-column-id="${columnId}"]`, + ); +} + +type DateGridProps = { + columns: readonly PretableColumn[]; + gridRows?: readonly DateRow[]; + locale?: Intl.LocalesArgument; +} & Pick< + React.ComponentProps>, + "copyToClipboard" | "onGridReady" | "processing" | "state" +> & + ( + | { + query: PretableQueryFor>; + onQueryChange: ( + query: PretableQueryFor>, + ) => void; + } + | { query?: never; onQueryChange?: never } + ); + +function DateGrid({ + columns, + gridRows = rows, + locale = "en-US", + ...rest +}: DateGridProps) { + return ( + row.id} + locale={locale} + overscan={0} + rows={gridRows} + viewportHeight={180} + {...rest} + /> + ); +} + +describe("PretableSurface native date formatting", () => { + it("formats canonical strings independently of the column processing type", () => { + const columns: PretableColumn[] = [ + { id: "settlementDate", widthPx: 160, dateFormat: mediumDate }, + ]; + const view = render(); + + expect(cell(view.container, "later")).toHaveTextContent("Aug 11, 2026"); + expect(cell(view.container, "earlier")).toHaveTextContent("Jan 2, 2025"); + }); + + it("keeps noncanonical raw values visible through the display fallback", () => { + const columns: PretableColumn[] = [ + { id: "settlementDate", widthPx: 200, dateFormat: mediumDate }, + ]; + const invalidRows = [ + { ...rows[0]!, id: "datetime", settlementDate: "2026-08-11T00:00:00Z" }, + { ...rows[0]!, id: "overflow", settlementDate: "2026-02-30" }, + ] as unknown as DateRow[]; + const view = render(); + + expect(cell(view.container, "datetime")).toHaveTextContent( + "2026-08-11T00:00:00Z", + ); + expect(cell(view.container, "overflow")).toHaveTextContent("2026-02-30"); + }); + + it("keeps calendar output on the UTC day in a non-UTC process zone", () => { + const previousTimeZone = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; + try { + const view = render( + , + ); + + expect(cell(view.container, "new-year")).toHaveTextContent( + "Thursday, January 1, 2026", + ); + } finally { + if (previousTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = previousTimeZone; + } + }); + + it("passes raw values and localized formattedValue to custom renderers", () => { + const renderCell = vi.fn( + ({ formattedValue }: { formattedValue: string }) => ( + {formattedValue} + ), + ); + const columns: PretableColumn[] = [ + { + id: "settlementDate", + widthPx: 160, + dateFormat: mediumDate, + render: renderCell, + }, + ]; + const view = render(); + + expect(view.getByTestId("rendered-date")).toHaveTextContent("Aug 11, 2026"); + expect(renderCell).toHaveBeenCalledWith( + expect.objectContaining({ + value: "2026-08-11", + formattedValue: "Aug 11, 2026", + }), + ); + }); + + it("lets the format callback outrank native date presentation", () => { + const columns: PretableColumn[] = [ + { + id: "settlementDate", + dateFormat: mediumDate, + format: ({ value }) => `custom:${String(value)}`, + }, + ]; + const view = render(); + + expect(cell(view.container, "later")).toHaveTextContent( + "custom:2026-08-11", + ); + }); + + it("formats schema values in explicit-model mode", () => { + const helper = createColumnHelper(); + const schema = [ + helper.accessor("settlementDate", { + type: "date", + dateFormat: mediumDate, + }), + ] as const; + const model = createLocalRowModel({ rows, columns: schema }); + + const view = render( + , + ); + + expect(cell(view.container, "later")).toHaveTextContent("Aug 11, 2026"); + model.dispose(); + }); + + it("preserves external membership and order while formatting locally", () => { + const columns: PretableColumn[] = [ + { + id: "settlementDate", + type: "date", + dateFormat: mediumDate, + }, + ]; + const view = render( + {}} + processing={{ filter: "external", sort: "external" }} + query={{ + sort: [{ columnId: "settlementDate", direction: "asc" }], + filters: [ + { + columnId: "settlementDate", + operator: "after", + value: "2099-01-01", + }, + ], + rowGroups: [], + }} + />, + ); + + expect( + view + .getAllByTestId("pretable-row") + .map((row) => row.getAttribute("data-pretable-row-id")), + ).toEqual(["later", "earlier"]); + expect(cell(view.container, "later")).toHaveTextContent("Aug 11, 2026"); + expect( + view.container.querySelector( + '[data-pretable-column-id="settlementDate"][role="columnheader"]', + ), + ).toHaveAttribute("aria-sort", "ascending"); + }); + + it("reconciles option and locale identity without per-cell construction", () => { + const dateConstruct = vi.fn(function DateTimeFormat( + locales?: Intl.LocalesArgument, + options?: Intl.DateTimeFormatOptions, + ) { + return new NativeDateTimeFormat(locales, options); + }); + const numberConstruct = vi.fn(function NumberFormat( + locales?: Intl.LocalesArgument, + options?: Intl.NumberFormatOptions, + ) { + return new NativeNumberFormat(locales, options); + }); + vi.spyOn(Intl, "DateTimeFormat").mockImplementation( + dateConstruct as unknown as Intl.DateTimeFormatConstructor, + ); + vi.spyOn(Intl, "NumberFormat").mockImplementation( + numberConstruct as unknown as Intl.NumberFormatConstructor, + ); + const dueOptions = { dateStyle: "short" } as const; + const amountOptions = { maximumFractionDigits: 1 } as const; + const columns: PretableColumn[] = [ + { id: "settlementDate", dateFormat: mediumDate }, + { id: "dueDate", dateFormat: dueOptions }, + { id: "amount", numberFormat: amountOptions }, + ]; + const view = render(); + expect(dateConstruct).toHaveBeenCalledTimes(2); + expect(numberConstruct).toHaveBeenCalledTimes(1); + + view.rerender( + , + ); + expect(dateConstruct).toHaveBeenCalledTimes(2); + expect(numberConstruct).toHaveBeenCalledTimes(1); + + const nextColumns: PretableColumn[] = [ + { ...columns[0]!, dateFormat: { dateStyle: "long" } }, + columns[1]!, + columns[2]!, + ]; + view.rerender(); + expect(dateConstruct).toHaveBeenCalledTimes(3); + expect(numberConstruct).toHaveBeenCalledTimes(1); + + view.rerender(); + expect(dateConstruct).toHaveBeenCalledTimes(5); + expect(numberConstruct).toHaveBeenCalledTimes(2); + }); + + it("updates presentation without changing the row-model snapshot or rows", async () => { + type SnapshotGrid = { + rowModel: { + getState(): { snapshot: { rowAt(index: number): unknown } }; + }; + }; + let grid: SnapshotGrid | null = null; + const firstColumns: PretableColumn[] = [ + { + id: "settlementDate", + type: "date", + dateFormat: { dateStyle: "medium" }, + }, + ]; + const view = render( + { + grid = ready as unknown as typeof grid; + }} + />, + ); + const readyGrid = grid as unknown as SnapshotGrid; + const beforeSnapshot = readyGrid.rowModel.getState().snapshot; + const beforeRow = beforeSnapshot.rowAt(0); + + view.rerender( + , + ); + + await waitFor(() => + expect(cell(view.container, "later")).toHaveTextContent( + "August 11, 2026", + ), + ); + const afterSnapshot = readyGrid.rowModel.getState().snapshot; + expect(afterSnapshot).toBe(beforeSnapshot); + expect(afterSnapshot.rowAt(0)).toBe(beforeRow); + }); + + it("reuses the mounted registry for surface-owned copy", () => { + const construct = vi.fn(function DateTimeFormat( + locales?: Intl.LocalesArgument, + options?: Intl.DateTimeFormatOptions, + ) { + return new NativeDateTimeFormat(locales, options); + }); + vi.spyOn(Intl, "DateTimeFormat").mockImplementation( + construct as unknown as Intl.DateTimeFormatConstructor, + ); + const copyToClipboard = vi.fn(); + const columns: PretableColumn[] = [ + { id: "settlementDate", dateFormat: mediumDate }, + ]; + const view = render( + , + ); + expect(construct).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(view.getByRole("grid"), { key: "c", metaKey: true }); + + expect(construct).toHaveBeenCalledTimes(1); + expect(copyToClipboard).toHaveBeenCalledWith( + expect.objectContaining({ text: "Aug 11, 2026" }), + ); + }); + + it("hydrates localized UTC presentation without a recoverable mismatch", async () => { + const columns: PretableColumn[] = [ + { id: "settlementDate", dateFormat: mediumDate }, + ]; + const grid = ( + + ); + // Server and client byte equality requires equivalent Intl/ICU locale + // data. This test deliberately derives the expectation from the same + // runtime instead of promising one locale string across different ICUs. + const expected = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeZone: "UTC", + }).format(dateValueToUtcMs("2026-08-11")); + vi.stubGlobal("document", undefined); + let markup: string; + try { + markup = renderToString(grid); + } finally { + vi.unstubAllGlobals(); + } + expect(markup).toContain('data-pretable-hydrated="false"'); + expect(markup).not.toContain("2026-08-11"); + + const recoverableErrors: unknown[] = []; + let container: HTMLDivElement | undefined; + let root: Root | undefined; + try { + container = document.createElement("div"); + document.body.append(container); + container.innerHTML = markup; + await act(async () => { + root = hydrateRoot(container!, grid, { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + }); + await waitFor(() => expect(container).toHaveTextContent(expected)); + expect(recoverableErrors).toEqual([]); + } finally { + if (root) await act(async () => root!.unmount()); + container?.remove(); + } + }); +}); diff --git a/packages/react/src/__tests__/group-row-render.test.tsx b/packages/react/src/__tests__/group-row-render.test.tsx index 902e4e372..af88f731b 100644 --- a/packages/react/src/__tests__/group-row-render.test.tsx +++ b/packages/react/src/__tests__/group-row-render.test.tsx @@ -10,6 +10,7 @@ import * as React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + createColumnHelper, GROUP_COLUMN_ID, type PretableQueryFor, type PretableSelectionState, @@ -495,6 +496,77 @@ describe("group row rendering", () => { ).toHaveTextContent(""); }); + it("inherits dateFormat for extrema and numberFormat for date counts", async () => { + type DateRow = { + id: string; + sector: string; + due: string | null; + dueCount: string | null; + }; + const helper = createColumnHelper(); + const dateRows: DateRow[] = [ + { + id: "d1", + sector: "Tech", + due: "2026-08-11", + dueCount: "2026-08-11", + }, + { + id: "d2", + sector: "Tech", + due: "2025-01-02", + dueCount: "2025-01-02", + }, + ]; + const dateColumns = [ + helper.accessor("sector", { type: "text", header: "Sector" }), + helper.accessor("due", { + type: "date", + header: "Due", + aggregate: "min", + dateFormat: { dateStyle: "medium" }, + }), + helper.accessor("dueCount", { + type: "date", + header: "Count", + aggregate: "count", + dateFormat: { dateStyle: "medium" }, + numberFormat: { minimumIntegerDigits: 2 }, + }), + ] as const; + const view = render( + row.id} + initialExpansion={{ kind: "expanded" }} + locale="en-US" + onQueryChange={() => {}} + query={{ + filters: [], + sort: [], + rowGroups: [{ columnId: "sector" }], + }} + rows={dateRows} + viewportHeight={300} + />, + ); + await expect + .poll( + () => + view.container.querySelectorAll("[data-pretable-group-row]").length, + ) + .toBe(1); + const group = view.container.querySelector("[data-pretable-group-row]")!; + + expect( + group.querySelector('[data-pretable-column-id="due"]'), + ).toHaveTextContent("Jan 2, 2025"); + expect( + group.querySelector('[data-pretable-column-id="dueCount"]'), + ).toHaveTextContent("02"); + }); + it("marks a data row's cell in the group column as a leaf", async () => { const view = await renderGrouped({ rowGroups: ["sector"] }); const dataRow = view.getAllByTestId("pretable-row")[0]!; diff --git a/packages/react/src/__tests__/paste-surface.test.tsx b/packages/react/src/__tests__/paste-surface.test.tsx index 9ef797590..bfe2cc796 100644 --- a/packages/react/src/__tests__/paste-surface.test.tsx +++ b/packages/react/src/__tests__/paste-surface.test.tsx @@ -211,6 +211,44 @@ describe("PretableSurface paste", () => { ); }); + it("does not treat formatted date copy text as canonical paste input", async () => { + const onPaste = vi.fn(); + renderPasteGrid({ + rows: [ + { + ...ROWS[0]!, + due: "2026-08-11", + }, + ], + columns: [ + { + id: "due", + type: "date", + editable: true, + dateFormat: { dateStyle: "medium" }, + }, + ], + state: cellSelection("r1", "due"), + onPaste, + }); + expect(grid()).toHaveTextContent("Aug 11, 2026"); + + firePaste(grid(), "Aug 11, 2026"); + await flush(); + + const payload = onPaste.mock.calls[0]![0] as PastePayload; + expect(payload.cells).toEqual([]); + expect(payload.rejected).toEqual([ + { + rowId: "r1", + columnId: "due", + raw: "Aug 11, 2026", + reason: "invalid", + message: "Use YYYY-MM-DD", + }, + ]); + }); + it("keeps one paste listener while streaming rows publish", async () => { const addListener = vi.spyOn(HTMLElement.prototype, "addEventListener"); const removeListener = vi.spyOn( diff --git a/packages/react/src/__tests__/value-formatting.test.ts b/packages/react/src/__tests__/value-formatting.test.ts index a13ce467b..e259dc167 100644 --- a/packages/react/src/__tests__/value-formatting.test.ts +++ b/packages/react/src/__tests__/value-formatting.test.ts @@ -1,14 +1,29 @@ -import { describe, expect, it, vi } from "vitest"; +import * as calendarDate from "@pretable-internal/calendar-date"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PretableColumn } from "../types"; import { - compileNumberFormatters, - createNumberFormatterCache, + compileValueFormatters, + createValueFormatterCache, formatAggregateValue, formatDataCellValue, } from "../value-formatting"; -type Row = { id: string; amount: unknown; count?: unknown }; +vi.mock("@pretable-internal/calendar-date", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + dateValueToUtcMs: vi.fn(actual.dateValueToUtcMs), + }; +}); + +type Row = { + id: string; + amount: unknown; + count?: unknown; + settlementDate?: unknown; +}; const fallback = (value: unknown) => value == null ? "" : `fallback:${String(value)}`; @@ -28,6 +43,14 @@ function makeGroup(aggregates: Record) { } describe("native value formatting", () => { + beforeEach(() => { + vi.mocked(calendarDate.dateValueToUtcMs).mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it("compiles one native formatter per configured column", () => { const constructor = vi.spyOn(Intl, "NumberFormat"); @@ -38,11 +61,13 @@ describe("native value formatting", () => { { id: "count", numberFormat: { useGrouping: false } }, ]; - const formatters = compileNumberFormatters(columns, "en-US"); + const formatters = compileValueFormatters(columns, "en-US"); - expect(formatters.size).toBe(2); - expect(formatters.get("amount")).toBeInstanceOf(Intl.NumberFormat); - expect(formatters.get("count")).toBeInstanceOf(Intl.NumberFormat); + expect(formatters.numbers.size).toBe(2); + expect(formatters.numbers.get("amount")).toBeInstanceOf( + Intl.NumberFormat, + ); + expect(formatters.numbers.get("count")).toBeInstanceOf(Intl.NumberFormat); expect(constructor).toHaveBeenCalledTimes(2); } finally { constructor.mockRestore(); @@ -57,7 +82,7 @@ describe("native value formatting", () => { useGrouping: false, }; const nextCountOptions: Intl.NumberFormatOptions = { useGrouping: true }; - const cache = createNumberFormatterCache(); + const cache = createValueFormatterCache(); const constructor = vi.spyOn(Intl, "NumberFormat"); try { @@ -68,8 +93,8 @@ describe("native value formatting", () => { ], "en-US", ); - const amountFormatter = initial.get("amount"); - const countFormatter = initial.get("count"); + const amountFormatter = initial.numbers.get("amount"); + const countFormatter = initial.numbers.get("count"); const reconciled = cache.resolve( [ @@ -80,8 +105,8 @@ describe("native value formatting", () => { ); expect(constructor).toHaveBeenCalledTimes(3); - expect(reconciled.get("amount")).toBe(amountFormatter); - expect(reconciled.get("count")).not.toBe(countFormatter); + expect(reconciled.numbers.get("amount")).toBe(amountFormatter); + expect(reconciled.numbers.get("count")).not.toBe(countFormatter); } finally { constructor.mockRestore(); } @@ -94,7 +119,7 @@ describe("native value formatting", () => { } as Intl.NumberFormatOptions; expect(() => - compileNumberFormatters( + compileValueFormatters( [{ id: "amount", numberFormat: invalidOptions }], "en-US", ), @@ -119,14 +144,14 @@ describe("native value formatting", () => { }, format: ({ value }) => `custom:${String(value)}`, }; - const formatters = compileNumberFormatters([column], "en-US"); + const formatters = compileValueFormatters([column], "en-US"); expect( formatDataCellValue({ value: -12, row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("custom:-12"); @@ -144,14 +169,14 @@ describe("native value formatting", () => { }, }; const row: Row = { id: "row-1", amount: -12 }; - const formatters = compileNumberFormatters([column], "en-US"); + const formatters = compileValueFormatters([column], "en-US"); expect( formatDataCellValue({ value: -12, row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("($12.00)"); @@ -160,7 +185,7 @@ describe("native value formatting", () => { value: BigInt(12), row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("$12.00"); @@ -169,7 +194,7 @@ describe("native value formatting", () => { value: "-12", row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("fallback:-12"); @@ -181,14 +206,14 @@ describe("native value formatting", () => { numberFormat: { maximumFractionDigits: 2 }, }; const row: Row = { id: "row-1", amount: null }; - const formatters = compileNumberFormatters([column], "en-US"); + const formatters = compileValueFormatters([column], "en-US"); expect( formatDataCellValue({ value: null, row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe(""); @@ -197,7 +222,7 @@ describe("native value formatting", () => { value: undefined, row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe(""); @@ -206,7 +231,7 @@ describe("native value formatting", () => { value: false, row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("fallback:false"); @@ -215,7 +240,7 @@ describe("native value formatting", () => { value: { amount: 12 }, row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("fallback:[object Object]"); @@ -227,14 +252,14 @@ describe("native value formatting", () => { numberFormat: { signDisplay: "always" }, }; const row: Row = { id: "row-1", amount: Number.NaN }; - const formatters = compileNumberFormatters([column], "en-US"); + const formatters = compileValueFormatters([column], "en-US"); expect( formatDataCellValue({ value: Number.NaN, row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("+NaN"); @@ -243,7 +268,7 @@ describe("native value formatting", () => { value: Number.POSITIVE_INFINITY, row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("+∞"); @@ -252,7 +277,7 @@ describe("native value formatting", () => { value: Number.NEGATIVE_INFINITY, row, column, - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("-∞"); @@ -272,14 +297,14 @@ describe("native value formatting", () => { formatAggregate: ({ value, scope }) => `aggregate:${String(value)}:${scope}`, }; - const formatters = compileNumberFormatters([column], "en-US"); + const formatters = compileValueFormatters([column], "en-US"); expect( formatAggregateValue({ column, group, scope: "loaded", - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("aggregate:-12:loaded"); @@ -299,17 +324,204 @@ describe("native value formatting", () => { }, format: dataFormat, }; - const formatters = compileNumberFormatters([column], "en-US"); + const formatters = compileValueFormatters([column], "en-US"); expect( formatAggregateValue({ column, group, scope: "all", - numberFormatters: formatters, + valueFormatters: formatters, fallback, }), ).toBe("($12.00)"); expect(dataFormat).not.toHaveBeenCalled(); }); + + it("compiles number and date formatters into one registry", () => { + const formatters = compileValueFormatters( + [ + { id: "amount", numberFormat: { maximumFractionDigits: 1 } }, + { id: "settlementDate", dateFormat: { dateStyle: "medium" } }, + ], + "en-US", + ); + + expect(formatters.numbers.get("amount")).toBeInstanceOf(Intl.NumberFormat); + expect(formatters.dates.get("settlementDate")).toBeInstanceOf( + Intl.DateTimeFormat, + ); + }); + + it("reconciles both formatter kinds as one coherent cache", () => { + const numberConstructor = vi.spyOn(Intl, "NumberFormat"); + const dateConstructor = vi.spyOn(Intl, "DateTimeFormat"); + const amountOptions = { maximumFractionDigits: 2 } as const; + const firstDateOptions = { dateStyle: "medium" } as const; + const nextDateOptions = { dateStyle: "long" } as const; + const cache = createValueFormatterCache(); + + const first = cache.resolve( + [ + { id: "amount", numberFormat: amountOptions }, + { id: "settlementDate", dateFormat: firstDateOptions }, + ], + "en-US", + ); + const changedDate = cache.resolve( + [ + { id: "settlementDate", dateFormat: nextDateOptions }, + { id: "amount", numberFormat: amountOptions }, + ], + "en-US", + ); + const changedLocale = cache.resolve( + [ + { id: "amount", numberFormat: amountOptions }, + { id: "settlementDate", dateFormat: nextDateOptions }, + ], + "en-GB", + ); + const removed = cache.resolve( + [{ id: "settlementDate", dateFormat: nextDateOptions }], + "en-GB", + ); + + expect(changedDate.numbers.get("amount")).toBe(first.numbers.get("amount")); + expect(changedDate.dates.get("settlementDate")).not.toBe( + first.dates.get("settlementDate"), + ); + expect(changedLocale.numbers.get("amount")).not.toBe( + changedDate.numbers.get("amount"), + ); + expect(changedLocale.dates.get("settlementDate")).not.toBe( + changedDate.dates.get("settlementDate"), + ); + expect(removed.numbers.size).toBe(0); + expect(removed.dates.get("settlementDate")).toBe( + changedLocale.dates.get("settlementDate"), + ); + expect(numberConstructor).toHaveBeenCalledTimes(2); + expect(dateConstructor).toHaveBeenCalledTimes(3); + }); + + it("applies format, date, number, then fallback precedence to data cells", () => { + const row: Row = { + id: "row-1", + amount: 12, + settlementDate: "2026-08-11", + }; + const column: PretableColumn = { + id: "settlementDate", + dateFormat: { dateStyle: "medium" }, + numberFormat: { minimumFractionDigits: 1 }, + }; + const valueFormatters = compileValueFormatters([column], "en-US"); + + expect( + formatDataCellValue({ + value: "2026-08-11", + row, + column, + valueFormatters, + fallback, + }), + ).toBe("Aug 11, 2026"); + expect(calendarDate.dateValueToUtcMs).toHaveBeenCalledTimes(1); + + expect( + formatDataCellValue({ + value: 12, + row, + column, + valueFormatters, + fallback, + }), + ).toBe("12.0"); + expect( + formatDataCellValue({ + value: "2026-02-30", + row, + column, + valueFormatters, + fallback, + }), + ).toBe("fallback:2026-02-30"); + const instant = new Date("2026-08-11T00:00:00Z"); + expect( + formatDataCellValue({ + value: instant, + row, + column, + valueFormatters, + fallback, + }), + ).toBe(`fallback:${String(instant)}`); + + const callbackColumn: PretableColumn = { + ...column, + format: ({ value }) => `custom:${String(value)}`, + }; + expect( + formatDataCellValue({ + value: "2026-08-11", + row, + column: callbackColumn, + valueFormatters, + fallback, + }), + ).toBe("custom:2026-08-11"); + expect(calendarDate.dateValueToUtcMs).toHaveBeenCalledTimes(2); + }); + + it("date-formats canonical extrema and number-formats numeric counts", () => { + const column: PretableColumn = { + id: "settlementDate", + dateFormat: { dateStyle: "medium" }, + numberFormat: { minimumIntegerDigits: 2 }, + }; + const valueFormatters = compileValueFormatters([column], "en-US"); + + expect( + formatAggregateValue({ + column, + group: makeGroup({ settlementDate: "2026-08-11" }), + scope: "all", + valueFormatters, + fallback, + }), + ).toBe("Aug 11, 2026"); + expect( + formatAggregateValue({ + column, + group: makeGroup({ settlementDate: 2 }), + scope: "all", + valueFormatters, + fallback, + }), + ).toBe("02"); + expect( + formatAggregateValue({ + column, + group: makeGroup({ settlementDate: "not-a-date" }), + scope: "all", + valueFormatters, + fallback, + }), + ).toBe("fallback:not-a-date"); + + const callbackColumn: PretableColumn = { + ...column, + formatAggregate: ({ value }) => `aggregate:${String(value)}`, + }; + expect( + formatAggregateValue({ + column: callbackColumn, + group: makeGroup({ settlementDate: "2026-08-11" }), + scope: "loaded", + valueFormatters, + fallback, + }), + ).toBe("aggregate:2026-08-11"); + }); }); diff --git a/packages/react/src/copy.ts b/packages/react/src/copy.ts index 07c37654b..6288c457f 100644 --- a/packages/react/src/copy.ts +++ b/packages/react/src/copy.ts @@ -9,10 +9,10 @@ import { ROW_SELECT_COLUMN_ID } from "./constants"; import type { PretableExportScope } from "./csv"; import type { PretableColumn } from "./types"; import { - compileNumberFormatters, + compileValueFormatters, formatAggregateValue, formatDataCellValue, - type NumberFormatterRegistry, + type ValueFormatterRegistry, } from "./value-formatting"; import { formatCellValue } from "./rendering"; import { groupLabel } from "./group-model"; @@ -264,20 +264,20 @@ export function serializeRanges< TRowId extends PretableRowId, TColumns, >(args: SerializeRangesArgs): CopyPayload | null { - return serializeRangesWithNumberFormatters( + return serializeRangesWithValueFormatters( args, - compileNumberFormatters(args.columns, args.locale), + compileValueFormatters(args.columns, args.locale), ); } /** @internal */ -export function serializeRangesWithNumberFormatters< +export function serializeRangesWithValueFormatters< TRow extends PretableRow, TRowId extends PretableRowId, TColumns, >( args: SerializeRangesArgs, - numberFormatters: NumberFormatterRegistry, + valueFormatters: ValueFormatterRegistry, ): CopyPayload | null { const dataColumns = args.columns.filter((c) => c.id !== ROW_SELECT_COLUMN_ID); if (dataColumns.length === 0) return null; @@ -333,7 +333,7 @@ export function serializeRangesWithNumberFormatters< column: col, group: { ...row, id: row.groupId }, scope: args.scope ?? "all", - numberFormatters, + valueFormatters, fallback: formatCellValue, }); } else { @@ -347,7 +347,7 @@ export function serializeRangesWithNumberFormatters< value: raw, row: row.row, column: col, - numberFormatters, + valueFormatters, fallback: defaultCoerceForCopy, }); } diff --git a/packages/react/src/csv.ts b/packages/react/src/csv.ts index 128d0c27f..49e054dd6 100644 --- a/packages/react/src/csv.ts +++ b/packages/react/src/csv.ts @@ -8,7 +8,7 @@ * it be imported from a server graph without exploding. * * The value pipeline is `copy.ts`'s, verbatim — same `formatDataCellValue`, - * same `formatAggregateValue`, same number-formatter registry. A CSV that + * same `formatAggregateValue`, same value-formatter registry. A CSV that * formats differently from the clipboard would be a second answer to the same * question. */ @@ -26,10 +26,10 @@ import { defaultCoerceForCopy } from "./copy"; import { groupLabel } from "./group-model"; import type { PretableColumn } from "./types"; import { - compileNumberFormatters, + compileValueFormatters, formatAggregateValue, formatDataCellValue, - type NumberFormatterRegistry, + type ValueFormatterRegistry, } from "./value-formatting"; /** @@ -367,7 +367,7 @@ export function hidesCollapsedRows( * Serialize a row-model snapshot to CSV. * * Returns `null` when there is nothing to write, matching - * `serializeRangesWithNumberFormatters`. + * `serializeRangesWithValueFormatters`. * * Line endings are CRLF per RFC 4180. Values come from the column's configured * formatter — the same one the grid displays — because a file that disagrees @@ -383,20 +383,20 @@ export function serializeCsv< TRowId extends PretableRowId, TColumns, >(args: SerializeCsvArgs): PretableCsvFile | null { - return serializeCsvWithNumberFormatters( + return serializeCsvWithValueFormatters( args, - compileNumberFormatters(args.columns, args.locale), + compileValueFormatters(args.columns, args.locale), ); } /** @internal */ -export function serializeCsvWithNumberFormatters< +export function serializeCsvWithValueFormatters< TRow extends PretableRow, TRowId extends PretableRowId, TColumns, >( args: SerializeCsvArgs, - numberFormatters: NumberFormatterRegistry, + valueFormatters: ValueFormatterRegistry, ): PretableCsvFile | null { const options = { ...DEFAULT_CSV_OPTIONS, ...args.options }; const { delimiter } = options; @@ -530,7 +530,7 @@ export function serializeCsvWithNumberFormatters< column: col, group: { ...row, id: row.groupId }, scope, - numberFormatters, + valueFormatters, // `defaultCoerceForCopy`, deliberately NOT `formatCellValue` as // `copy.ts` uses here. The two disagree only on an object-valued // aggregate, where the display fallback yields `[object Object]` @@ -550,7 +550,7 @@ export function serializeCsvWithNumberFormatters< value: raw, row: row.row, column: col, - numberFormatters, + valueFormatters, fallback: defaultCoerceForCopy, }); } diff --git a/packages/react/src/group-row.tsx b/packages/react/src/group-row.tsx index 49da61a8a..857864690 100644 --- a/packages/react/src/group-row.tsx +++ b/packages/react/src/group-row.tsx @@ -7,12 +7,17 @@ import { formatCellValue } from "./rendering"; import { ChevronDownIcon } from "./icons"; import { formatAggregateValue, - type NumberFormatterRegistry, + type ValueFormatterRegistry, } from "./value-formatting"; import { resolveColumnAlign } from "./column-align"; import { getPositionedCellStyle, getRowStyle } from "./styles"; import type { PretableColumn } from "./types"; +const EMPTY_VALUE_FORMATTERS: ValueFormatterRegistry = { + numbers: new Map(), + dates: new Map(), +}; + /** @internal */ export interface GroupRowProps { /** Every planned column, in drawn order — the same list data rows use. */ @@ -32,7 +37,7 @@ export interface GroupRowProps { readonly expanded: boolean; }; height: number; - numberFormatters?: NumberFormatterRegistry; + valueFormatters?: ValueFormatterRegistry; scope?: "all" | "loaded"; formatChildCount?: (args: { childCount: number; @@ -68,7 +73,7 @@ export function GroupRow({ focusedColumnId, group, height, - numberFormatters = new Map(), + valueFormatters = EMPTY_VALUE_FORMATTERS, scope = "all", formatChildCount = ({ childCount }) => `(${childCount})`, isFocused, @@ -205,7 +210,7 @@ export function GroupRow({ column, group: { ...group, id: group.groupId }, scope, - numberFormatters, + valueFormatters, fallback: formatCellValue, }) ) : ( diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index 15ea01a98..93145508f 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -140,7 +140,7 @@ import { useHydrated } from "./use-hydrated"; import { type CopyPayload, type SerializeRangesArgs, - serializeRangesWithNumberFormatters, + serializeRangesWithValueFormatters, } from "./copy"; import { type PretableCsvFile, @@ -148,7 +148,7 @@ import { type PretableCsvOptions, type PretableExportScope, type SerializeCsvArgs, - serializeCsv, + serializeCsvWithValueFormatters, } from "./csv"; import { defaultSaveFile } from "./save-file"; @@ -364,8 +364,9 @@ import { parseDraftForType } from "./editors/type-parsing"; import { deriveRowChange } from "./row-change"; import { CheckIcon, MinusIcon, SortAscIcon, SortDescIcon } from "./icons"; import { - compileNumberFormatters, + createValueFormatterCache, formatDataCellValue, + type ValueFormatterRegistry, } from "./value-formatting"; import { resolveAriaRowCount, @@ -1247,6 +1248,7 @@ interface SurfaceExportContext< readonly scope: PretableExportScope; readonly selectedRowIds: readonly TRowId[]; readonly locale: Intl.LocalesArgument | undefined; + readonly valueFormatters: ValueFormatterRegistry; readonly csvOptions: PretableCsvOptions | undefined; readonly onExport: | (( @@ -1847,9 +1849,10 @@ export function PretableSurface< } as PretableColumn; }); }, [columns, model]); - const numberFormatters = useMemo( - () => compileNumberFormatters(authoritativeColumns, locale), - [authoritativeColumns, locale], + const [valueFormatterCache] = useState(createValueFormatterCache); + const valueFormatters = useMemo( + () => valueFormatterCache.resolve(authoritativeColumns, locale), + [authoritativeColumns, locale, valueFormatterCache], ); const resolveEffectiveColumns = useCallback( (currentQuery: { readonly rowGroups: readonly { columnId: string }[] }) => { @@ -2266,7 +2269,7 @@ export function PretableSurface< ? context.onExport( args as unknown as SerializeCsvArgs, ) - : serializeCsv(args); + : serializeCsvWithValueFormatters(args, context.valueFormatters); // `null` cancels — either the consumer declined, or there was nothing to // write. Nothing is saved and nothing is announced. if (file === null) return; @@ -4002,6 +4005,7 @@ export function PretableSurface< scope: dataScope, selectedRowIds, locale, + valueFormatters, csvOptions, onExport, saveFile, @@ -4813,7 +4817,7 @@ export function PretableSurface< ? onCopy( args as unknown as SerializeRangesArgs, ) - : serializeRangesWithNumberFormatters(args, numberFormatters); + : serializeRangesWithValueFormatters(args, valueFormatters); if (payload) { const extent = computeSelectionExtent( copyRanges, @@ -5803,7 +5807,7 @@ export function PretableSurface< focusedColumnId={snapshot.focus.columnId} group={group} height={renderRow.height} - numberFormatters={numberFormatters} + valueFormatters={valueFormatters} scope={dataScope} formatChildCount={effectiveMessages.groupChildCountLabel} isFocused={ @@ -5938,7 +5942,7 @@ export function PretableSurface< value, row, column, - numberFormatters, + valueFormatters, fallback: formatCellValue, }); const bodyInput = { diff --git a/packages/react/src/value-formatting.ts b/packages/react/src/value-formatting.ts index c329d8872..8b072b4ee 100644 --- a/packages/react/src/value-formatting.ts +++ b/packages/react/src/value-formatting.ts @@ -1,20 +1,28 @@ import type { PretableRow } from "@pretable/core"; +import { dateValueToUtcMs } from "@pretable-internal/calendar-date"; +import { + reconcileDateFormatters, + type DateFormatterCacheState, +} from "./date-formatters"; import type { PretableColumn } from "./types"; -export type NumberFormatterRegistry = ReadonlyMap; +export interface ValueFormatterRegistry { + readonly numbers: ReadonlyMap; + readonly dates: ReadonlyMap; +} -export interface NumberFormatterCacheState { +interface NumberFormatterCacheState { readonly locale: Intl.LocalesArgument | undefined; readonly optionsByColumnId: ReadonlyMap; - readonly formatters: NumberFormatterRegistry; + readonly formatters: ReadonlyMap; } -export interface NumberFormatterCache { +export interface ValueFormatterCache { resolve( columns: readonly PretableColumn[], locale?: Intl.LocalesArgument, - ): NumberFormatterRegistry; + ): ValueFormatterRegistry; } function createNumberFormatter( @@ -32,7 +40,7 @@ function createNumberFormatter( } } -export function reconcileNumberFormatters( +function reconcileNumberFormatters( previous: NumberFormatterCacheState | undefined, columns: readonly PretableColumn[], locale?: Intl.LocalesArgument, @@ -44,9 +52,7 @@ export function reconcileNumberFormatters( for (const column of columns) { const options = column.numberFormat; - if (options === undefined) { - continue; - } + if (options === undefined) continue; optionsByColumnId.set(column.id, options); const previousFormatter = previous?.formatters.get(column.id); @@ -67,34 +73,48 @@ export function reconcileNumberFormatters( return { locale, optionsByColumnId, formatters }; } -export function createNumberFormatterCache(): NumberFormatterCache { - let state: NumberFormatterCacheState | undefined; +export function createValueFormatterCache(): ValueFormatterCache { + let numberState: NumberFormatterCacheState | undefined; + let dateState: DateFormatterCacheState | undefined; return { resolve(columns, locale) { - state = reconcileNumberFormatters(state, columns, locale); - return state.formatters; + numberState = reconcileNumberFormatters(numberState, columns, locale); + dateState = reconcileDateFormatters(dateState, columns, locale); + return { + numbers: numberState.formatters, + dates: dateState.formatters, + }; }, }; } -export function compileNumberFormatters( +export function compileValueFormatters( columns: readonly PretableColumn[], locale?: Intl.LocalesArgument, -): NumberFormatterRegistry { - return reconcileNumberFormatters(undefined, columns, locale).formatters; +): ValueFormatterRegistry { + return { + numbers: reconcileNumberFormatters(undefined, columns, locale).formatters, + dates: reconcileDateFormatters(undefined, columns, locale).formatters, + }; } -function formatWithNativeNumber( +function formatWithNative( value: unknown, - formatter: Intl.NumberFormat | undefined, + dateFormatter: Intl.DateTimeFormat | undefined, + numberFormatter: Intl.NumberFormat | undefined, fallback: (value: unknown) => string, ): string { + if (dateFormatter !== undefined && typeof value === "string") { + const timestamp = dateValueToUtcMs(value); + if (Number.isFinite(timestamp)) return dateFormatter.format(timestamp); + } + if ( - formatter !== undefined && + numberFormatter !== undefined && (typeof value === "number" || typeof value === "bigint") ) { - return formatter.format(value); + return numberFormatter.format(value); } return fallback(value); @@ -104,22 +124,23 @@ export function formatDataCellValue({ value, row, column, - numberFormatters, + valueFormatters, fallback, }: { value: unknown; row: TRow; column: PretableColumn; - numberFormatters: NumberFormatterRegistry; + valueFormatters: ValueFormatterRegistry; fallback: (value: unknown) => string; }): string { if (column.format !== undefined) { return column.format({ value, row, column }); } - return formatWithNativeNumber( + return formatWithNative( value, - numberFormatters.get(column.id), + valueFormatters.dates.get(column.id), + valueFormatters.numbers.get(column.id), fallback, ); } @@ -128,7 +149,7 @@ export function formatAggregateValue({ column, group, scope, - numberFormatters, + valueFormatters, fallback, }: { column: PretableColumn; @@ -143,7 +164,7 @@ export function formatAggregateValue({ readonly expanded: boolean; }; scope: "all" | "loaded"; - numberFormatters: NumberFormatterRegistry; + valueFormatters: ValueFormatterRegistry; fallback: (value: unknown) => string; }): string { const value = group.aggregates[column.id]; @@ -151,9 +172,10 @@ export function formatAggregateValue({ return column.formatAggregate({ value, column, group, scope }); } - return formatWithNativeNumber( + return formatWithNative( value, - numberFormatters.get(column.id), + valueFormatters.dates.get(column.id), + valueFormatters.numbers.get(column.id), fallback, ); } diff --git a/packages/row-model/src/__tests__/transitions.test.ts b/packages/row-model/src/__tests__/transitions.test.ts index d0b388c82..adcd1772e 100644 --- a/packages/row-model/src/__tests__/transitions.test.ts +++ b/packages/row-model/src/__tests__/transitions.test.ts @@ -254,6 +254,53 @@ describe("cooperative query and derivation transitions", () => { }, ); + test("treats dateFormat as presentation-only with zero derivation work", async () => { + interface DatedRow { + readonly id: number; + readonly asOf: string | null; + } + const dated = createColumnHelper(); + const initialColumn = dated.accessor("asOf", { + type: "date", + dateFormat: { dateStyle: "medium" }, + }); + const initialColumns = [initialColumn] as const; + const { model, diagnostics } = createInstrumentedLocalRowModel({ + rows: [ + { id: 1, asOf: "2026-08-18" }, + { id: 2, asOf: "2025-01-01" }, + ], + columns: initialColumns, + query: { + filters: [], + sort: [{ columnId: "asOf", direction: "asc" }], + rowGroups: [], + }, + }); + const beforeSnapshot = model.getState().snapshot; + const beforeFirstRow = beforeSnapshot.rowAt(0); + diagnostics.resetWork(); + + const transition = model.setDerivations([ + { ...initialColumn, dateFormat: { dateStyle: "long" } }, + ] as never); + await expect(transition.finished).resolves.toBe(0); + + const afterSnapshot = model.getState().snapshot; + expect(afterSnapshot).toBe(beforeSnapshot); + expect(diagnostics.read().work).toEqual({ + rowsEvaluated: 0, + hamtNodesCopied: 0, + orderNodesCopied: 0, + groupNodesCopied: 0, + aggregateMerges: 0, + transitionRows: 0, + snapshotOutputRowsRead: 0, + schedulerSliceDurations: [], + }); + expect(afterSnapshot.rowAt(0)).toBe(beforeFirstRow); + }); + test("keeps the default cooperative work budget below the browser gate margin", () => { let tick = 0; let steps = 0; From f06e83b3088b6358920dfe983b8c2d8bf85cb2b3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 20:59:17 -0700 Subject: [PATCH 14/16] docs: publish canonical date contract --- .changeset/calendar-dates-format-natively.md | 8 + README.md | 1 + ROADMAP.md | 5 +- .../api/docs/rows/__tests__/dataset.test.ts | 51 +++- apps/website/app/api/docs/rows/dataset.ts | 78 ++---- .../docs/__tests__/date-formatting.types.tsx | 81 +++++++ apps/website/app/docs/_nav.ts | 4 + .../content/docs/grid/api-reference.mdx | 25 +- .../content/docs/grid/cell-presentations.mdx | 5 + .../content/docs/grid/cell-renderers.mdx | 66 ++--- apps/website/content/docs/grid/clipboard.mdx | 64 ++--- .../content/docs/grid/date-formatting.mdx | 228 ++++++++++++++++++ apps/website/content/docs/grid/editing.mdx | 38 ++- apps/website/content/docs/grid/export.mdx | 19 +- apps/website/content/docs/grid/filtering.mdx | 4 +- apps/website/content/docs/grid/grouping.mdx | 17 +- .../content/docs/grid/pretable-surface.mdx | 39 +-- apps/website/content/docs/grid/sorting.mdx | 3 +- .../examples/live-events-grid/columns.ts | 2 +- apps/website/e2e/docs.spec.ts | 25 ++ .../docs/__tests__/docs-api-surface.test.ts | 42 ++++ packages/core/core.api.md | 59 +++-- packages/react/react.api.md | 54 +++-- packages/row-model/src/column-types.ts | 67 +++-- 24 files changed, 731 insertions(+), 254 deletions(-) create mode 100644 .changeset/calendar-dates-format-natively.md create mode 100644 apps/website/app/docs/__tests__/date-formatting.types.tsx create mode 100644 apps/website/content/docs/grid/date-formatting.mdx diff --git a/.changeset/calendar-dates-format-natively.md b/.changeset/calendar-dates-format-natively.md new file mode 100644 index 000000000..a0d00b504 --- /dev/null +++ b/.changeset/calendar-dates-format-natively.md @@ -0,0 +1,8 @@ +--- +"@pretable/core": minor +"@pretable/react": minor +--- + +**Breaking:** Make date columns strict RFC 3339 full-date values and add native, +locale-aware date formatting. Applications must project Date, epoch, date-time, +or localized values to `YYYY-MM-DD | null` to retain built-in date processing. diff --git a/README.md b/README.md index c76031d9a..cfbf2f518 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ shared packages and can contend with each other when launched in parallel. - Getting started: [pretable.ai/docs/getting-started](https://pretable.ai/docs/getting-started) - Grid API reference: [pretable.ai/docs/grid/api-reference](https://pretable.ai/docs/grid/api-reference) - Number formatting: [locale-aware decimal, money, and accounting](https://pretable.ai/docs/grid/number-formatting) across cells, aggregates, and clipboard. +- Date formatting: [strict calendar dates with locale-aware display](https://pretable.ai/docs/grid/date-formatting) across cells, aggregates, copy, and CSV. - Streaming docs: [pretable.ai/docs/streaming](https://pretable.ai/docs/streaming) ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index 8b04b7da5..4a175cf05 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,8 +43,9 @@ and [interaction evidence](status/milestones/2026-08-16-s2-mount-and-interaction ## Now — correct the typed data contract - Ship canonical calendar-date semantics and native date formatting across the - incremental row model, React presentation, editing, copy, and CSV. See the - [planned design](docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md). + incremental row model, strict editing and filtering, valid-first sorting, + date extrema, React presentation, copy, and CSV. See the [planned + design](docs/superpowers/specs/2026-08-18-canonical-calendar-dates-row-model-design.md). - Preserve Pretable's stable scroll and structural efficiency while closing the measured local sort/filter interaction gap. Add evidence for product work, not finance-specific workload profiles. diff --git a/apps/website/app/api/docs/rows/__tests__/dataset.test.ts b/apps/website/app/api/docs/rows/__tests__/dataset.test.ts index 86b8e2a60..8861453d3 100644 --- a/apps/website/app/api/docs/rows/__tests__/dataset.test.ts +++ b/apps/website/app/api/docs/rows/__tests__/dataset.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import path from "node:path"; + import { describe, expect, test } from "vitest"; import { @@ -250,6 +253,43 @@ describe("date operators", () => { expect(row.placedAt <= "2026-04-30").toBe(true); } }); + + test("canonical low-year strings compare without native Date coercion", () => { + const rows = applyDocsQuery( + [ + { ...DOCS_ORDERS[0]!, id: "low", placedAt: "0050-01-02" }, + { ...DOCS_ORDERS[1]!, id: "later", placedAt: "0050-01-03" }, + ], + { + ...EMPTY_DOCS_QUERY, + filters: [ + { columnId: "placedAt", operator: "on", value: "0050-01-02" }, + ], + }, + ); + + expect(rows.map((row) => row.id)).toEqual(["low"]); + }); + + test("a semantically invalid string remains active and matches zero rows", () => { + expect(filterBy("placedAt", "on", "2026-02-30")).toEqual([]); + expect( + filterBy("placedAt", "dateBetween", ["2026-01-01", "2026-02-30"]), + ).toEqual([]); + }); +}); + +describe("canonical date fixture implementation", () => { + test("does not parse, trim, or coerce date-time values", () => { + const source = fs.readFileSync( + path.resolve(process.cwd(), "app/api/docs/rows/dataset.ts"), + "utf8", + ); + + expect(source).not.toMatch( + /Date\.parse|ISO_DATETIME_RE|toDayMs|utcDayOf|isoDayMs/, + ); + }); }); describe("selection operators", () => { @@ -442,13 +482,16 @@ describe("queries this fixture cannot answer", () => { test("a date range of the wrong length throws", () => { expect(() => filterBy("placedAt", "dateBetween", ["2026-01-01"])).toThrow( - /exactly two valid ISO dates/, + /exactly two string operands/, ); }); - test("an unparseable date operand throws", () => { - expect(() => filterBy("placedAt", "on", "the first of January")).toThrow( - /valid ISO date operand/, + test("a date operand of the wrong JavaScript type throws", () => { + expect(() => filterBy("placedAt", "on", 1_786_406_400_000)).toThrow( + /string operand/, + ); + expect(() => filterBy("placedAt", "on", new Date("2026-08-06Z"))).toThrow( + /string operand/, ); }); diff --git a/apps/website/app/api/docs/rows/dataset.ts b/apps/website/app/api/docs/rows/dataset.ts index c57b8ef14..e4774612f 100644 --- a/apps/website/app/api/docs/rows/dataset.ts +++ b/apps/website/app/api/docs/rows/dataset.ts @@ -1,3 +1,5 @@ +import { isValidDateValue } from "@pretable/core"; + /** A row of the docs' example order book. */ export interface DocsOrder { id: string; @@ -161,56 +163,6 @@ function isEmptyValue(value: unknown): boolean { ); } -const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; -const ISO_DATETIME_RE = - /^(\d{4}-\d{2}-\d{2})[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/i; - -function utcDayOf(value: number): number { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return Number.NaN; - date.setUTCHours(0, 0, 0, 0); - return date.getTime(); -} - -function isoDayMs(value: string): number { - if (!ISO_DATE_RE.test(value)) return Number.NaN; - const [year, month, day] = value.split("-").map(Number) as [ - number, - number, - number, - ]; - const result = Date.UTC(year, month - 1, day); - const roundTrip = new Date(result); - - return roundTrip.getUTCFullYear() === year && - roundTrip.getUTCMonth() === month - 1 && - roundTrip.getUTCDate() === day - ? result - : Number.NaN; -} - -/** - * The engine's UTC calendar-day policy (`toDayMs`), minus its pre-year-100 - * Gregorian shim — this order book is entirely modern, and a docs fixture that - * carried the shim would be copying code no example can reach. - */ -function toDayMs(value: unknown): number { - if (value instanceof Date) return utcDayOf(value.getTime()); - if (typeof value === "number") return utcDayOf(value); - if (typeof value !== "string") return Number.NaN; - - const trimmed = value.trim(); - const dateOnly = isoDayMs(trimmed); - if (!Number.isNaN(dateOnly)) return dateOnly; - - const parts = ISO_DATETIME_RE.exec(trimmed); - if (!parts || Number.isNaN(isoDayMs(parts[1] as string))) return Number.NaN; - - return parts[2] - ? utcDayOf(Date.parse(trimmed.replace(" ", "T"))) - : isoDayMs(parts[1] as string); -} - function columnTypeFor(columnId: string): DocsColumnType { const type = (DOCS_COLUMN_TYPES as Record)[columnId]; @@ -270,12 +222,12 @@ function assertUsable( if ( !Array.isArray(operands) || (operator === "dateBetween" && operands.length !== 2) || - operands.some((entry) => Number.isNaN(toDayMs(entry))) + operands.some((entry) => typeof entry !== "string") ) { throw new DocsQueryError( operator === "dateBetween" - ? `Filter on "${columnId}" needs a range of exactly two valid ISO dates.` - : `Filter on "${columnId}" needs a valid ISO date operand.`, + ? `Filter on "${columnId}" needs a range of exactly two string operands.` + : `Filter on "${columnId}" needs a string operand.`, ); } return; @@ -339,25 +291,25 @@ function matchesDate( cell: unknown, operand: unknown, ): boolean { - const day = toDayMs(cell); - if (Number.isNaN(day)) return false; + if (!isValidDateValue(cell)) return false; if (operator === "dateBetween") { - const range = operand as readonly [unknown, unknown]; - const low = toDayMs(range[0]); - const high = toDayMs(range[1]); - return day >= Math.min(low, high) && day <= Math.max(low, high); + const [first, second] = operand as readonly [string, string]; + if (!isValidDateValue(first) || !isValidDateValue(second)) return false; + const lower = first <= second ? first : second; + const upper = first <= second ? second : first; + return cell >= lower && cell <= upper; } - const other = toDayMs(operand); + if (!isValidDateValue(operand)) return false; switch (operator) { case "on": - return day === other; + return cell === operand; case "before": - return day < other; + return cell < operand; case "after": - return day > other; + return cell > operand; default: // Unreachable by construction: `assertUsable` rejects any operator // outside DOCS_FILTER_OPERATORS before dispatch. It stays as the failure diff --git a/apps/website/app/docs/__tests__/date-formatting.types.tsx b/apps/website/app/docs/__tests__/date-formatting.types.tsx new file mode 100644 index 000000000..06d1c247b --- /dev/null +++ b/apps/website/app/docs/__tests__/date-formatting.types.tsx @@ -0,0 +1,81 @@ +import { + createColumnHelper, + createLocalRowModel, + isValidDateValue, + type PretableDateFormatOptions, +} from "@pretable/core"; +import { PretableSurface } from "@pretable/react"; + +/** Compile-time fixture for every fence on `grid/date-formatting.mdx`. */ + +interface Invoice { + id: string; + due: string | null; +} + +interface Schedule { + id: string; + startsOn: string | null; +} + +// docs-fence: grid/date-formatting.mdx#Validate at the boundary +export function calendarDateOrNull(value: unknown): string | null { + if (value === null) return null; + if (!isValidDateValue(value)) throw new Error("Expected YYYY-MM-DD or null"); + return value; +} + +// docs-fence: grid/date-formatting.mdx#Rows mode +const column = createColumnHelper(); +const columns = [ + column.accessor("due", { + type: "date", + header: "Due", + aggregate: "max", + dateFormat: { dateStyle: "medium" }, + }), +] as const; + +export function InvoiceGrid({ rows }: { rows: readonly Invoice[] }) { + return ( + + ); +} + +// docs-fence: grid/date-formatting.mdx#Explicit-model presentation +const scheduleColumn = createColumnHelper(); +const modelColumns = [ + scheduleColumn.accessor("startsOn", { + type: "date", + aggregate: "min", + header: "Starts", + dateFormat: { year: "numeric", month: "short", day: "2-digit" }, + }), +] as const; +const model = createLocalRowModel({ + rows: [{ id: "s1", startsOn: "2026-08-18" }], + columns: modelColumns, +}); + +export const scheduleGrid = ( + +); + +const dateOptions = { + year: "numeric", + month: "short", + day: "2-digit", +} satisfies PretableDateFormatOptions; +void dateOptions; diff --git a/apps/website/app/docs/_nav.ts b/apps/website/app/docs/_nav.ts index 59401af90..eb50f188b 100644 --- a/apps/website/app/docs/_nav.ts +++ b/apps/website/app/docs/_nav.ts @@ -40,6 +40,10 @@ export const docsNav: DocsNavSection[] = [ title: "Number formatting", href: "/docs/grid/number-formatting", }, + { + title: "Date formatting", + href: "/docs/grid/date-formatting", + }, { title: "Column layout", href: "/docs/grid/column-layout" }, { title: "Cell renderers", href: "/docs/grid/cell-renderers" }, { diff --git a/apps/website/content/docs/grid/api-reference.mdx b/apps/website/content/docs/grid/api-reference.mdx index f5ea0a5e1..d5e610414 100644 --- a/apps/website/content/docs/grid/api-reference.mdx +++ b/apps/website/content/docs/grid/api-reference.mdx @@ -36,7 +36,12 @@ const columns = [ ] as const; ``` -The tuple preserves row, column-ID, value, filter, editor, formatter, and aggregate correlations. Column options include typed accessors, `header`, `type`, sorting and filtering behavior, layout fields, `numberFormat`, `format`, `aggregate`, `formatAggregate`, editing hooks, and React `render` / `renderEditor` callbacks. +The tuple preserves row, column-ID, value, filter, editor, formatter, and +aggregate correlations. Column options include typed accessors, `header`, +`type`, sorting and filtering behavior, layout fields, `dateFormat`, +`numberFormat`, `format`, `aggregate`, `formatAggregate`, editing hooks, and +React `render` / `renderEditor` callbacks. A `type: "date"` accessor accepts +only `string | null` values. ### Number formatting exports @@ -49,6 +54,23 @@ const accounting = numberFormats.accounting({ currency: "USD" }); Both helpers return native `Intl.NumberFormatOptions` and are exported from `@pretable/react` and `@pretable/core`. A surface's `locale` applies to cell display, inherited numeric aggregate display, and built-in clipboard text. See [Number formatting](/docs/grid/number-formatting). +### Date formatting exports + +```ts +import { + isValidDateValue, + type PretableDateFormatOptions, +} from "@pretable/react"; +``` + +`isValidDateValue(value)` narrows an exact, Gregorian `YYYY-MM-DD` string. +`PretableDateFormatOptions` allows only `localeMatcher`, `calendar`, +`numberingSystem`, `dateStyle`, `weekday`, `era`, `year`, `month`, `day`, and +`formatMatcher`; Pretable owns the UTC time zone and rejects every time field, +unknown key, and symbol key. Both exports are also available from +`@pretable/core`. See [Date formatting](/docs/grid/date-formatting) for storage, +query, aggregate, precedence, SSR, and migration rules. + ### Shared cell presentations `PretableBadge`, `PretableDelta`, `PretableEntity`, and `PretableStatus` are theme-aware React presentations for semantic badges, signed changes, primary/secondary entities, and labelled states. They do not replace typed values or formatting; pass already-formatted content where appropriate. See [Cell presentations](/docs/grid/cell-presentations) for each one's props. @@ -121,5 +143,6 @@ interface SerializeRangesArgs { - `getDensityHeights` — public density-aware sizing for custom renderers. - `serializeRanges` / `defaultCoerceForCopy` — clipboard primitives. - `numberFormats.money` / `numberFormats.accounting` — native numeric option helpers. +- `isValidDateValue` / `PretableDateFormatOptions` — strict calendar-date validation and native display options. For framework-independent control, see the [headless API](/docs/headless/api-reference). diff --git a/apps/website/content/docs/grid/cell-presentations.mdx b/apps/website/content/docs/grid/cell-presentations.mdx index 7c660ff79..6c3c6d776 100644 --- a/apps/website/content/docs/grid/cell-presentations.mdx +++ b/apps/website/content/docs/grid/cell-presentations.mdx @@ -30,6 +30,11 @@ render inside a virtualized body where the visible rows re-render on every scroll frame and on every streamed update, so anything that hooked, measured or memoized here would do it thousands of times a second for no gain. +The same `formattedValue` handoff works for canonical dates configured with +`dateFormat` and for numbers configured with `numberFormat`. These components +do not change the raw-value contract or the formatting precedence; they only +render the already-resolved text and application semantics. + ## The shared span contract Every one of these props interfaces extends diff --git a/apps/website/content/docs/grid/cell-renderers.mdx b/apps/website/content/docs/grid/cell-renderers.mdx index f0dacf6c7..5d95639c8 100644 --- a/apps/website/content/docs/grid/cell-renderers.mdx +++ b/apps/website/content/docs/grid/cell-renderers.mdx @@ -5,7 +5,7 @@ nav: Grid --- Each column decides for itself how its cells are displayed, through native -`numberFormat` configuration and three optional hooks on +`dateFormat` / `numberFormat` configuration and three optional hooks on `PretableColumn`. The grid below reaches for one of them — `render` — on every column it has: @@ -19,7 +19,7 @@ four components does. The three hooks are `format` (value → string), `render` (returns `ReactNode`), and `renderHeader` (returns `ReactNode`). A `format` callback wins over native -number formatting, and the resolved string reaches `render` as +date and number formatting, and the resolved string reaches `render` as `formattedValue` — the handoff the Day P&L column above is built on. Every cell is wrapped in `React.memo` with a custom equality check, so unchanged cells skip re-render even when other parts of the grid update. @@ -30,16 +30,18 @@ For every visible body cell, on every render: 1. **value extraction**: `value = column.value ? column.value(row) : row[column.id]`. 2. **format**: a column `format` callback wins. Otherwise a compiled - `numberFormat` handles `number` or `bigint` values. Values not handled by - either path use the default, which joins arrays with `", "` and stringifies - everything else. + `dateFormat` handles a canonical `YYYY-MM-DD` string, then a compiled + `numberFormat` handles a compatible `number` or `bigint`. Values not handled + by those paths use the default, which joins arrays with `", "` and + stringifies everything else. 3. **render**: if `column.render` is present, it returns the cell's ReactNode. Otherwise the grid-level `renderBodyCell` prop on `` applies. Otherwise `formattedValue` is rendered as plain text. ## `format` -Per-column string formatter. Use it for dates, statuses, and custom domain -values that don't need JSX. For JavaScript `number` and `bigint` values, prefer -the compiled [`numberFormat`](/docs/grid/number-formatting) column option. +Per-column string formatter. Use it for statuses and custom domain values that +don't need JSX. For canonical calendar dates, prefer compiled +[`dateFormat`](/docs/grid/date-formatting); for JavaScript `number` and `bigint` +values, prefer compiled [`numberFormat`](/docs/grid/number-formatting). ```tsx import { numberFormats, type PretableColumn } from "@pretable/react"; @@ -47,7 +49,7 @@ import { numberFormats, type PretableColumn } from "@pretable/react"; interface Order extends Record { id: string; total: number; - placedAt: Date; + placedAt: string | null; status: "pending" | "shipped" | "delivered"; } @@ -60,7 +62,8 @@ const columns: PretableColumn[] = [ { id: "placedAt", header: "Placed", - format: ({ value }) => (value as Date).toISOString().slice(0, 10), + type: "date", + dateFormat: { dateStyle: "medium" }, }, { id: "status", @@ -82,9 +85,10 @@ const columns: PretableColumn[] = [ ``` `format` is also used by `Cmd+C` copy serialization — set it once and both -display and clipboard get consistent output. It outranks `numberFormat` when a -column supplies both. Native number formatting is likewise shared by display -and copy without constructing a formatter inside the per-cell callback. +display, clipboard, and CSV get consistent output. It outranks `dateFormat` and +`numberFormat` when a column supplies them. Native date and number formatting +are likewise shared without constructing a formatter inside the per-cell +callback. ## `render` @@ -92,8 +96,8 @@ Per-column ReactNode renderer for full UI control. Use it when you need a badge, `render` receives `{ value, row, column, formattedValue, rowId, rowIndex, isFocused, isSelected, pinned }`. The `formattedValue` is the resolved string -from `format`, native `numberFormat`, or the default formatter, so renderers do -not need to repeat formatting. `pinned` is the column's live pin side (`"left" | +from `format`, native `dateFormat`, native `numberFormat`, or the default +formatter, so renderers do not need to repeat formatting. `pinned` is the column's live pin side (`"left" | "right" | null`) taken from the engine's column plan — use it instead of `column.pinned`, which only reflects what the `columns` prop declared and goes stale once a pin is set through controlled state, `grid.setColumnPinned`, or @@ -121,24 +125,24 @@ const columns: PretableColumn[] = [ Every cell is wrapped in `React.memo` with a custom equality check. The cell skips re-render when the following are all reference-equal between renders: -| Field | Source | -| ------------------- | -------------------------------------------------------- | -| `rowId` | the row's id | -| `columnId` | the column's id | -| `value` | result of `column.value(row)` (or `row[column.id]`) | -| `formattedValue` | result of `format`, compiled `numberFormat`, or fallback | -| `isFocused` | engine focus state for this cell | -| `isSelected` | derived from selection ranges | -| `pinned` | pin side from the engine's column plan | -| `renderRef` | `column.render` reference (or `null`) | -| `fallbackRenderRef` | grid-level `renderBodyCell` reference (or `null`) | +| Field | Source | +| ------------------- | -------------------------------------------------------------- | +| `rowId` | the row's id | +| `columnId` | the column's id | +| `value` | result of `column.value(row)` (or `row[column.id]`) | +| `formattedValue` | result of `format`, native date/number formatting, or fallback | +| `isFocused` | engine focus state for this cell | +| `isSelected` | derived from selection ranges | +| `pinned` | pin side from the engine's column plan | +| `renderRef` | `column.render` reference (or `null`) | +| `fallbackRenderRef` | grid-level `renderBodyCell` reference (or `null`) | `format` and `column.value` are **not** in the memo key. They run unconditionally at the parent every render — but their cost is bounded by the cheapness contract: - `column.value` is typically a property access. Nanoseconds. - `column.format` is typically a status lookup or a date/domain conversion. - Native number formatter instances are compiled per configured column, not - constructed inside the per-cell callback. + Native date and number formatter instances are compiled per configured + column, not constructed inside the per-cell callback. The memo bails out further down: if value didn't change AND `format` is pure, `formattedValue` is the same string, and the cell DOM is not re-rendered. @@ -241,8 +245,9 @@ The same applies when you are reading the DOM rather than styling it — in an e ## Cell editing Cell editing is supported through built-in typed editors and per-column -`renderEditor` overrides. Display `format` and `numberFormat` remain separate -from `formatEditValue`, which only produces the string used to seed an editor. +`renderEditor` overrides. Display `format`, `dateFormat`, and `numberFormat` +remain separate from `formatEditValue`, which only produces the string used to +seed an editor. Editor value inputs, `validate`, and `onCellEdit` receive raw values rather than formatted display strings. See [Editing](/docs/grid/editing) for parsing, validation, custom editors, and the controlled commit lifecycle. @@ -252,4 +257,5 @@ validation, custom editors, and the controlled commit lifecycle. - [Selection](/docs/grid/selection) — how `isSelected` is derived from cell-range selection. - [Keyboard](/docs/grid/keyboard) — how `isFocused` works. - [Clipboard](/docs/grid/clipboard) — `format` is reused as the copy serializer. +- [Date formatting](/docs/grid/date-formatting) — canonical values and native presentation. - [API reference](/docs/grid/api-reference) — full type signatures for `PretableColumn`, `PretableCellRenderInput`, `PretableHeaderRenderInput`, `PretableFormatInput`. diff --git a/apps/website/content/docs/grid/clipboard.mdx b/apps/website/content/docs/grid/clipboard.mdx index bd05adec8..9b0534f6b 100644 --- a/apps/website/content/docs/grid/clipboard.mdx +++ b/apps/website/content/docs/grid/clipboard.mdx @@ -32,19 +32,22 @@ makes. The default serializer emits tab-separated cells, newline-separated rows, with a blank line between blocks for multi-range selections. A column `format` -callback wins first; otherwise a configured native `numberFormat` handles -`number` and `bigint` values with the surface `locale`. With neither, clipboard -keeps its channel-specific default coercion: +callback wins first; otherwise native `dateFormat` handles canonical dates, +then `numberFormat` handles compatible `number` and `bigint` values with the +surface `locale`. With none of those, clipboard keeps its channel-specific +default coercion: - `null` / `undefined` → `""` -- `Date` → `value.toISOString()` +- application-owned `Date` object → `value.toISOString()` (generic fallback, + not the built-in calendar-date contract) - `string` / `number` / `boolean` / `bigint` → `String(value)` - plain object → `JSON.stringify(value)` (best-effort fallback) A range that spans [group rows](/docs/grid/grouping) serializes them too: the group's label in the derived group column, each aggregate column's -`formatAggregate` output or inherited native `numberFormat`, and an empty cell -everywhere else. +`formatAggregate` output or inherited native `dateFormat` / `numberFormat`, and +an empty cell everywhere else. Numeric `count` aggregates never inherit +`dateFormat`. ### Escaping @@ -59,7 +62,7 @@ This matters for wrapped/multi-line cells: a cell holding a newline would otherw This matches what Excel and Sheets accept on paste, so a copy out of a Pretable grid pastes cleanly into a spreadsheet even when only the plain-text flavor survives — though in practice a spreadsheet takes the [HTML flavor](#html-flavor) instead. Escaping is applied after your per-column [`format`](#per-column-format) or -native `numberFormat` runs, so output containing a tab or newline is still safe +native `dateFormat` / `numberFormat` runs, so output containing a tab or newline is still safe — don't pre-quote in `format`, or the quotes get escaped as literal content. If you write a fully custom serializer, you own the escaping. @@ -89,12 +92,14 @@ The `white-space:pre-wrap` on the table is not cosmetic. HTML collapses runs of Cell and header text is escaped — `&`, `<`, `>`, and `"` become entities — and then line breaks become `
`. Text is never interpreted as markup: - A cell value of `x` copies those literal characters. It does not paste as bold text. -- The same holds for [`format`](#per-column-format) and native `numberFormat` +- The same holds for [`format`](#per-column-format) and native `dateFormat` / + `numberFormat` output. A `format` callback returns **text**, not HTML. Returning `"x"` from it gets escaped exactly like any other value. - [`render`](/docs/grid/cell-renderers) is not consulted at all. It returns a `ReactNode` for on-screen display; the clipboard uses `format`, native - `numberFormat`, or its fallback coercion for both flavors. + `dateFormat`, native `numberFormat`, or its fallback coercion for both + flavors. ### Type hints @@ -130,18 +135,17 @@ To write TSV only, drop the `html` field in [`onCopy`](#grid-level-oncopy-overri ## Per-column `format` -For domain-specific formatting (a Date that should copy as `YYYY-MM-DD` instead -of full ISO, or a status enum that should copy as a label), supply `format` on -the column: +For domain-specific formatting, such as a status enum that should copy as a +label, supply `format` on the column. Canonical calendar dates should normally +use [`dateFormat`](/docs/grid/date-formatting): ```tsx const columns: PretableColumn[] = [ { - id: "timestamp", - header: "Time", - value: (r) => r.timestamp, - format: ({ value }) => - value instanceof Date ? value.toISOString().slice(0, 10) : String(value), + id: "status", + header: "Status", + value: (row) => row.status, + format: ({ value }) => (value === "open" ? "Open" : String(value)), }, // ... ]; @@ -152,13 +156,19 @@ default coercion runs. Return a string. The same callback drives [display rendering](/docs/grid/cell-renderers) and copy serialization, so a single definition keeps both channels in sync. -For native decimal, money, or accounting display, put `numberFormat` on the -column and pass `locale` to the surface. The built-in serializer uses the same -configuration and locale as display. If neither callback nor native -configuration applies, the unformatted fallback remains channel-specific: -clipboard keeps the coercion listed above, which is not promised to equal the -renderer fallback for every object or array. See -[Number formatting](/docs/grid/number-formatting). +For native calendar-date, decimal, money, or accounting display, put +`dateFormat` or `numberFormat` on the column and pass `locale` to the surface. +The built-in serializer uses the same configuration, cache, precedence, and +locale as display and CSV. If neither callback nor native configuration +applies, the unformatted fallback remains channel-specific: clipboard keeps +the coercion listed above, which is not promised to equal the renderer fallback +for every object or array. See [Date formatting](/docs/grid/date-formatting) +and [Number formatting](/docs/grid/number-formatting). + +A localized copied date is presentation text, not strict paste input. For +example, `Aug 18, 2026` may be useful in a spreadsheet, while built-in date +paste accepts only `2026-08-18` or an exact empty cell. Use custom copy and +paste hooks when those channels must round-trip symmetrically. ## Grid-level `onCopy` override @@ -205,7 +215,7 @@ import { serializeRanges } from "@pretable/react"; | `rowModelSnapshot` | `PretableRowModelSnapshot` | immutable indexed source used for bounded range reads | | `columns` | `readonly PretableColumn[]` | column definitions (including the row-select column) | | `copyWithHeaders` | `boolean` | mirror of the `copyWithHeaders` prop | -| `locale?` | `Intl.LocalesArgument \| undefined` | public presentation locale used by native number formatting | +| `locale?` | `Intl.LocalesArgument \| undefined` | public presentation locale used by native date and number formatting | | `scope` | `"all" \| "loaded"` | whether copied group aggregates fold every matching record or only the loaded window | `scope` only matters when the copied selection contains group rows. It is the same scope the grid shows on screen, forwarded so a custom serializer cannot label a partial figure as a whole one: `"loaded"` means the aggregates fold only the records currently loaded, which is what a [server-filtered grid](/docs/server-data/totals) holding a window onto a larger result set has. `serializeRanges` passes it to each column's `formatAggregate`, so the copied text matches the rendered group row. It defaults to `"all"` — a serializer you call by hand, outside the grid, is copying everything it was given. @@ -216,11 +226,11 @@ explicit value yourself when calling `serializeRanges` outside a surface if the output must be deterministic across runtimes. `defaultCoerceForCopy(value)` is the fallback value→string coercion (the `null` -/ `Date` / primitive / object rules listed under +/ application-owned `Date` / primitive / object rules listed under [Default TSV format](#default-tsv-format)). Reach for it after any custom formatting branches to preserve the built-in fallback. A hand-written serializer owns native formatting itself; call `serializeRanges(args)` when you -want Pretable to compile and apply each column's `numberFormat` with +want Pretable to compile and apply each column's `dateFormat` / `numberFormat` with `args.locale`: ```tsx diff --git a/apps/website/content/docs/grid/date-formatting.mdx b/apps/website/content/docs/grid/date-formatting.mdx new file mode 100644 index 000000000..52f0a4634 --- /dev/null +++ b/apps/website/content/docs/grid/date-formatting.mdx @@ -0,0 +1,228 @@ +--- +title: Date formatting +description: Strict calendar-date values with locale-aware native display. +nav: Grid +--- + +Pretable treats `type: "date"` as a calendar date, not an instant. The built-in +value is exactly `YYYY-MM-DD | null`: ten ASCII characters for a valid +proleptic-Gregorian date in years `0000` through `9999`, or `null` for empty. +It never guesses a time zone or accepts a JavaScript `Date`, epoch number, +date-time string, localized string, whitespace, partial date, or overflow such +as `2026-02-30`. + +Use an instant when the time and zone matter. Choose the application’s calendar +zone and project a canonical date before a date-only value reaches the grid. +Pretable deliberately does not make that domain decision for you. + +## Validate at the boundary + +`isValidDateValue` is exported from both `@pretable/core` and +`@pretable/react`. Validate remote or untyped data before storing it as a +built-in date value: + +```ts +import { isValidDateValue } from "@pretable/core"; + +export function calendarDateOrNull(value: unknown): string | null { + if (value === null) return null; + if (!isValidDateValue(value)) throw new Error("Expected YYYY-MM-DD or null"); + return value; +} +``` + +Validation does not normalize. A failed value remains application-owned data; +the grid’s ordinary fallback can render it, but built-in date processing will +not treat it as a date. + +## Rows mode + +`dateFormat` opts a canonical value into native `Intl.DateTimeFormat` display. +The typed helper keeps the stored value, column type, filter operands, and date +aggregate outputs correlated: + +```tsx +import { createColumnHelper } from "@pretable/core"; +import { PretableSurface } from "@pretable/react"; + +interface Invoice { + id: string; + due: string | null; +} + +const column = createColumnHelper(); +const columns = [ + column.accessor("due", { + type: "date", + header: "Due", + aggregate: "max", + dateFormat: { dateStyle: "medium" }, + }), +] as const; + +export function InvoiceGrid({ rows }: { rows: readonly Invoice[] }) { + return ( + + ); +} +``` + +`type: "date"` selects strict editing, filtering, sorting, grouping, and +aggregation. It does not format by itself. Conversely, `dateFormat` is a +presentation option: on any column it formats only canonical string values and +otherwise leaves the normal fallback in control. + +## Explicit-model presentation + +An explicit row model owns raw values and derivation. The typed column keeps its +presentation contract when the model is supplied to a surface: + +```tsx +import { createColumnHelper, createLocalRowModel } from "@pretable/core"; +import { PretableSurface } from "@pretable/react"; + +interface Schedule { + id: string; + startsOn: string | null; +} + +const column = createColumnHelper(); +const modelColumns = [ + column.accessor("startsOn", { + type: "date", + aggregate: "min", + header: "Starts", + dateFormat: { year: "numeric", month: "short", day: "2-digit" }, + }), +] as const; +const model = createLocalRowModel({ + rows: [{ id: "s1", startsOn: "2026-08-18" }], + columns: modelColumns, +}); + +export const scheduleGrid = ( + +); +``` + +Presentation-only model transitions, including `dateFormat`, do not invalidate +derived row work. With external filter or sort authority, Pretable still +publishes query state and formats the supplied rows locally; it does not +re-filter or reorder a server-controlled result. + +## Native option contract + +`PretableDateFormatOptions` is a strict allowlist over native options: +`localeMatcher`, `calendar`, `numberingSystem`, `dateStyle`, `weekday`, `era`, +`year`, `month`, `day`, and `formatMatcher`. Time fields, `timeStyle`, +`timeZone`, and `timeZoneName` are forbidden. Unknown or symbol keys are also +rejected at runtime, including forbidden keys whose value is `undefined`. + +Pretable internally anchors the canonical date at UTC before calling +`Intl.DateTimeFormat`; consumers cannot override that time zone. Native rules +still apply, including which granular fields may be combined with `dateStyle` +and how a non-Gregorian calendar represents low years. + +The surface `locale` is forwarded unchanged. For server rendering, provide the +same explicit locale on server and client. UTC removes host-zone drift, but +identical text also requires compatible `Intl`/ICU locale data. If environments +differ, use a deterministic custom `format` callback or accept the normal +hydration constraint for localized output. + +## Sorting, filtering, and grouping + +Without a custom comparator, canonical dates sort chronologically. Every +non-date value—including `null`, `undefined`, invalid strings, objects, and +numbers—forms one terminal rank and stays last in both ascending and descending +sorts. The date-specific terminal rank ignores the column’s `nulls` setting. +Equal dates and equal non-dates fall through to later sort keys and then stable +source order. Date-valued sibling groups use the same rule. A custom comparator +remains authoritative. + +Built-in date filters compare canonical strings only. Invalid or empty cells +never match. In controlled or headless query state, a semantically invalid +string operand remains active and matches zero rows; a wrong JavaScript operand +type is a structured query error. A `dateBetween` filter also matches zero rows +when either string bound is invalid. In the React filter menu, incomplete, +cleared, or invalid input removes the menu-owned filter instead of preserving a +stale valid value. + +## Aggregates + +Date columns support `min`, `max`, and `count`. `min` and `max` ignore empty or +noncanonical values and return a canonical string or `null`; `count` keeps its +existing numeric output. Date columns do not gain numeric `sum` or `avg`. + +`formatAggregate` receives the correlated date-extremum or numeric-count output. +A date extremum may inherit `dateFormat`. A numeric `count` may inherit +`numberFormat`; it never inherits `dateFormat`. + +## One value-formatting precedence + +Cells, copy, and CSV resolve a data value in the same order: + +1. `format` +2. canonical value plus `dateFormat` +3. compatible numeric value plus `numberFormat` +4. the channel’s existing fallback + +Group display, group copy, and group CSV use the aggregate equivalent: + +1. `formatAggregate` +2. canonical aggregate plus `dateFormat` +3. compatible numeric aggregate plus `numberFormat` +4. the aggregate fallback + +A custom cell renderer receives the resolved `formattedValue` but still owns +its rendered JSX. Export follows the value pipeline, not custom JSX. All +callbacks, row-model reads, transactions, editing, validation, and paste keep +the raw application value. + +Formatted copy is intentionally not a paste round trip. A localized value such +as `Aug 18, 2026` can be copied for people and CSV consumers, while the built-in +date paste parser accepts only exact canonical `2026-08-18` or an exact empty +cell (which becomes `null`). Use custom copy/paste hooks when an application +needs a different symmetric interchange format. + +## Migration + +This is a breaking correction for applications that previously supplied mixed +date representations: + +1. Store or project date-only fields as `YYYY-MM-DD | null` at the application + boundary. +2. Replace `Date`, epoch, date-time, and localized filter operands with + canonical strings. +3. Use `dateFormat` for localized presentation rather than changing stored + values. +4. Use custom format/edit/filter hooks only when the application intentionally + owns a different domain; display or edit hooks alone do not enable built-in + date sorting, filtering, grouping, or aggregation. + +Do not mechanically recommend `date.toISOString().slice(0, 10)`: that silently +chooses the UTC calendar day and may differ from the domain’s intended local +day. If source data is an instant, first choose and document the calendar-zone +policy, then validate and project the resulting full-date string before it +reaches Pretable. + +## See also + +- [Editing](/docs/grid/editing) +- [Filtering](/docs/grid/filtering) +- [Sorting](/docs/grid/sorting) +- [Grouping and aggregation](/docs/grid/grouping) +- [Clipboard](/docs/grid/clipboard) +- [CSV export](/docs/grid/export) +- [API reference](/docs/grid/api-reference) diff --git a/apps/website/content/docs/grid/editing.mdx b/apps/website/content/docs/grid/editing.mdx index a457cd61b..bc9a997f6 100644 --- a/apps/website/content/docs/grid/editing.mdx +++ b/apps/website/content/docs/grid/editing.mdx @@ -156,12 +156,12 @@ An `enum` column with **no** `options` gets the plain text editor and no strictn ### Dates -`type: "date"` opens a strict `YYYY-MM-DD` text field with a month-grid calendar in a popover anchored to the cell. The calendar starts weeks on **Monday**, marks today and the currently highlighted day, and dims days from the neighbouring months. A commit always produces the ISO string `YYYY-MM-DD` (or `null`) — the same shape the [date filter](/docs/grid/filtering) operators compare against. +`type: "date"` opens a strict `YYYY-MM-DD` text field with a month-grid calendar in a popover anchored to the cell. The calendar starts weeks on **Monday**, marks today and the currently highlighted day, and dims days from the neighbouring months. A commit always produces the canonical string `YYYY-MM-DD` (or `null`) — the same shape the [date filter](/docs/grid/filtering) operators compare against. - **Strict ISO only.** Locale formats (`08/06/2026`), unpadded parts (`2026-8-6`), and calendar overflow (`2026-02-30`, `2026-13-01`) are rejected on commit with an inline **"Use YYYY-MM-DD"**, and the editor stays open. Leap days are checked properly: `2024-02-29` is accepted, `2026-02-29` is not. - **An empty field commits `null`.** Clearing the field is how you clear a date cell. - **Typing retargets the calendar.** As soon as the field holds a complete valid date, the popover jumps to that month and highlights that day. -- **Month navigation.** `PageDown` / `PageUp` move forward and back a month, as do the **‹** and **›** buttons in the popover header. The day-of-month is clamped to the target month's length, so 31 January + 1 month is 28 (or 29) February. +- **Month navigation.** `PageDown` / `PageUp` move forward and back a month, as do the **‹** and **›** buttons in the popover header. The day-of-month is clamped to the target month's length, so 31 January + 1 month is 28 (or 29) February. Navigation also clamps at `0000-01-01` and `9999-12-31`; out-of-range calendar slots are disabled placeholders rather than wrapped dates. - **Commit keys.** `Enter` commits the highlighted day and moves **down**; `Tab` commits it and moves **right**; clicking a day commits it in place; `Escape` cancels. `Enter` only substitutes the calendar's day when the typed text is _itself_ a valid date — type garbage and press `Enter` and it still reaches the parser and is still rejected, rather than silently committing whatever month the calendar drifted to. - **Blur commits or reverts.** Clicking away with a valid (or empty) field commits; clicking away with an unparseable date cancels the edit rather than leaving a rejected value stuck open on the cell. - **Focus never leaves the field.** The popover is a passive surface: it takes no focus, so the day cells are announced through `aria-activedescendant` on the input rather than by moving focus into the grid. @@ -180,29 +180,19 @@ The field is a fixed ten-character date that opens fully selected, so caret move #### What the editor accepts from your cell values -The editor seeds itself from the cell value and normalises it to ISO. It accepts only values whose day is **unambiguous**, and it reads them exactly the way the [filter engine](/docs/grid/filtering) buckets the same value — so a cell reads the same day for every viewer, and editing it can't move a row out from under its own filter: - -| Cell value | Opens as | -| ---------------------------------------------- | ------------------------------------ | -| `"2026-08-06"` (strict ISO) | `2026-08-06` | -| `"2026-08-06T13:45:00"` (no zone) | `2026-08-06` — the literal date, UTC | -| `"2026-08-06 13:45:00"` (space separator) | `2026-08-06` — the literal date, UTC | -| `"2026-08-06T12:34:56Z"`, `"…T12:34:56+02:00"` | the **UTC** day of that instant | -| `new Date(Date.UTC(2026, 7, 6))` | `2026-08-06` | -| `1785974400000` (epoch milliseconds) | the UTC day of that instant | -| `"08/06/2026"`, `"2026-8-6"`, `"2026-02-30"` | not a date — see the callout below | - -A **zone-less** datetime — `"2026-08-06T00:00:00"`, the shape many JSON and SQL backends emit — is read as UTC, which means its literal date portion is taken. That's deterministic: every viewer sees `2026-08-06`, wherever they are. A datetime that _does_ carry a zone is resolved to its **UTC** day, so `"2026-08-06T00:00:00+02:00"` is 6 August in Berlin but buckets as `2026-08-05`. The separator may be `T` or a space, so the `"2026-08-06 13:45:00"` that MySQL, SQLite, Postgres-as-text and `pandas.to_csv` emit is read the same way. +The built-in date value is only a canonical `YYYY-MM-DD` string or `null`. +There is no normalization step for `Date`, epoch, date-time, padded, localized, +empty-string, whitespace, or `undefined` values. If runtime data contains one, +the editor preserves its raw text; an untouched blur cancels without parsing or +committing it. After the user changes the draft, the strict parser applies and +accepts only a canonical date or an exact empty draft (which commits `null`). - Locale strings like `"08/06/2026"` (6 August or 6 June, depending on who is - looking), unpadded parts like `"2026-8-6"`, and calendar overflow like - `"2026-02-30"` are **not** dates — to the editor _or_ the filter engine. - Rather than guess, both halves refuse: such a cell opens showing its raw text - and is rejected on commit with "Use YYYY-MM-DD", and it matches no date filter - at all (though `isEmpty` / `isNotEmpty` still see it as non-empty). Store - plain `YYYY-MM-DD` strings — or bridge with `parseEditValue` / - `formatEditValue` — if your data has shapes like these. + A display/edit bridge does not opt a different application value into built-in + date sorting, filtering, grouping, or aggregation. To use those behaviors, + project `YYYY-MM-DD | null` at the application boundary after choosing the + calendar-zone policy for any source instant. See [Date + formatting](/docs/grid/date-formatting#migration). Out of scope for the built-in editor, by design: time-of-day, date ranges, `min`/`max` bounds, and a configurable week start. Reach for [`renderEditor`](#custom-editors) — paired with `parseEditValue` and `formatEditValue` — when you need any of them. @@ -282,7 +272,7 @@ const columns: PretableColumn[] = [ | `commit` | `(direction?: PretableFocusDirection) => void` | commit the draft, optionally moving focus (`"down"`, `"right"`, …) | | `cancel` | `() => void` | discard the edit and restore the cell | -`parseEditValue(raw, input)` turns the editor's string draft into the value handed to `validate` and `onRowChange`, replacing the built-in type parsing entirely. `formatEditValue(value, input)` produces the initial string shown when the editor opens. Supply both when your stored value isn't a plain string (a `Date`, an enum) so the round-trip stays type-correct. +`parseEditValue(raw, input)` turns the editor's string draft into the value handed to `validate` and `onRowChange`, replacing the built-in type parsing entirely. `formatEditValue(value, input)` produces the initial string shown when the editor opens. Supply both when your stored value isn't a plain string (an application-owned instant `Date`, an enum) so the custom edit round-trip stays type-correct. For an instant, the application must choose its calendar-zone projection; these hooks do not make the value eligible for built-in date processing. `renderEditor` wins over the built-in editor for every column that opens one. The exception is `type: "boolean"` — boolean cells [toggle in place](#booleans) and never open an editor, so `renderEditor` is ignored there. diff --git a/apps/website/content/docs/grid/export.mdx b/apps/website/content/docs/grid/export.mdx index 014ed3cca..d600a8949 100644 --- a/apps/website/content/docs/grid/export.mdx +++ b/apps/website/content/docs/grid/export.mdx @@ -150,9 +150,10 @@ A cell beginning `=`, `+`, `-`, `@`, tab or carriage return is executed as a formula by Excel, Numbers and Sheets. Escaping is **on by default**, and it vouches on the value rather than on the column's declared type. -A genuine `number`, `bigint`, `boolean` or `Date` cannot begin a formula, so -those are exempt by their JavaScript type. Everything else is judged on the -string it produced. +A genuine `number`, `bigint`, `boolean` or application-owned `Date` object +cannot begin a formula, so those are exempt by their JavaScript type. +(`Date` here is generic formula-safety vouching, not a built-in calendar-date +value.) Everything else is judged on the string it produced. That distinction is the whole design. Gating on `column.type` looks equivalent and is not: a row is `Record`, so a string from an API sits @@ -202,12 +203,20 @@ empty string — Postgres writes `NULL` as a bare empty field and an empty strin as `""` — and it buys nothing against formula injection, which quoting has never prevented in any spreadsheet. -Values come from the column's configured formatter, the same one the grid -displays, so the file matches the screen. Note the consequence: a grouped number +Values use the same precedence the grid and clipboard use: `format`, then a +canonical value plus `dateFormat`, then a compatible numeric value plus +`numberFormat`, then the existing fallback. Group aggregates use +`formatAggregate`, then `dateFormat`, then `numberFormat`, then their fallback; +numeric `count` does not inherit `dateFormat`. The file therefore matches the +screen. Note the consequence: a grouped number like `1,234.57` contains the delimiter, so it is quoted, so Excel imports it as text and the column will not sum. That is the cost of honouring a format you configured. +A localized date in CSV is presentation text, not input to Pretable's strict +date paste parser. See [Date formatting](/docs/grid/date-formatting) for the raw +value boundary and copy/paste asymmetry. + ## Delivery `defaultSaveFile` builds a `Blob` and clicks a synthetic ``. diff --git a/apps/website/content/docs/grid/filtering.mdx b/apps/website/content/docs/grid/filtering.mdx index 011e27ec9..74965923f 100644 --- a/apps/website/content/docs/grid/filtering.mdx +++ b/apps/website/content/docs/grid/filtering.mdx @@ -64,8 +64,8 @@ Semantics worth knowing: - **Text operators are case-insensitive.** Both cell and value are lowercased before comparison. - **Boolean cells are coerced before matching.** `"true"` / `1` / `"1"` match **True**, `"false"` / `0` / `"0"` match **False**, and anything else falls back to plain truthiness — the same rule the [editable checkbox](/docs/grid/editing#booleans) renders from, so a cell that looks checked also matches the True filter. (`isEmpty` / `isNotEmpty` run before coercion, so `null` still reads as empty rather than as `false`.) A boolean column's `options` may relabel the two states (`{ value: "true", label: "Yes" }`), but their **values** must remain `"true"` and `"false"` — those are the only strings a coerced cell can equal, so any other value matches nothing. - **`between` / `dateBetween` are inclusive** and order-tolerant — swapped bounds are normalized. -- **Dates compare at day resolution** (calendar day, UTC), so `on` matches any timestamp within that day. Cells may be `YYYY-MM-DD` strings, ISO datetimes, `Date` instances, or epoch milliseconds; a **zone-less** datetime (`"2026-08-06T13:45:00"`, or the space-separated `"2026-08-06 13:45:00"` that SQL backends emit) is read as UTC — its literal date portion — while a zoned one buckets by the UTC day of that instant. Ambiguous shapes (`"08/06/2026"`, `"2026-8-6"`) and calendar overflow (`"2026-02-30"`) are not dates and match no date operator. The [date editor](/docs/grid/editing#dates) reads cell values by exactly the same rule, so editing a cell can't move a row out from under its own filter. -- **Blank operands are inactive.** A filter with an empty string, empty array, or `null` value is ignored — it's as if the column had no filter. +- **Dates compare canonical calendar values only.** Cells and operands are exact `YYYY-MM-DD` strings under Gregorian calendar rules; `null` is the canonical empty cell. Invalid or empty cells never match. A controlled/headless filter with a semantically invalid string stays active and matches zero rows, while a wrong JavaScript operand type is a structured query error. `dateBetween` is inclusive, order-tolerant, and zero-matches when either string bound is invalid. See [Date formatting](/docs/grid/date-formatting#sorting-filtering-and-grouping). +- **The menu removes unusable drafts.** Empty selections and incomplete range controls are inactive. For a date menu specifically, cleared, incomplete, or invalid visible input removes the menu-owned filter instead of leaving the previous valid one active. Application-controlled query strings are not silently removed. ## The built-in menu diff --git a/apps/website/content/docs/grid/grouping.mdx b/apps/website/content/docs/grid/grouping.mdx index 6fab4804c..4823d33ff 100644 --- a/apps/website/content/docs/grid/grouping.mdx +++ b/apps/website/content/docs/grid/grouping.mdx @@ -114,9 +114,15 @@ const rowModel = createLocalRowModel({ While grouping is active, the surface derives one tree column for the group label, twisty, and child count. Configure it with `groupColumn`; it is presentation state, not a member of the model's typed column tuple. -## Aggregates and number formatting +## Aggregates and native formatting -Numeric columns support `"sum"`, `"avg"`, `"min"`, `"max"`, and `"count"`; other values support `"count"`. `formatAggregate` receives the inferred aggregate output separately from the leaf-row `format` callback — the panel example above already uses it on **Shares** and **Market value**. +Numeric columns support `"sum"`, `"avg"`, `"min"`, `"max"`, and `"count"`. +Canonical date columns support `"min"`, `"max"`, and `"count"`; other values +support `"count"`. Date extrema ignore invalid/empty values and return a +canonical string or `null`, while every `count` remains numeric. +`formatAggregate` receives the correlated output separately from the leaf-row +`format` callback — the panel example above already uses it on **Shares** and +**Market value**. When `formatAggregate` is absent, a numeric aggregate inherits the column's `numberFormat` for display and built-in clipboard output: @@ -130,6 +136,12 @@ column.accessor("amount", { }); ``` +A date `min` or `max` may likewise inherit its column's `dateFormat`. A numeric +`count` may inherit `numberFormat`; it never inherits `dateFormat`. +`formatAggregate` wins over either native formatter. Group display, copy, and +CSV share that same precedence. See [Date +formatting](/docs/grid/date-formatting#aggregates). + Custom aggregators are mergeable reducers. `init` creates an accumulator, `accumulate` adds one leaf value, `merge` combines partial accumulators, and `finalize` creates the displayed value. Keep `merge` associative and deterministic. ```ts @@ -219,5 +231,6 @@ Pretable does not currently expose tree-data input, pivoting, total rows, or per - [Filtering](/docs/grid/filtering) — filter operators and aggregate population. - [Sorting](/docs/grid/sorting) — leaf ordering and group-level direction. - [Number formatting](/docs/grid/number-formatting) — locale-aware leaf and aggregate values. +- [Date formatting](/docs/grid/date-formatting) — canonical dates, date extrema, and locale-aware presentation. - [``](/docs/grid/pretable-surface) — complete surface props. - [Grid API reference](/docs/grid/api-reference) — query, expansion, and aggregate types. diff --git a/apps/website/content/docs/grid/pretable-surface.mdx b/apps/website/content/docs/grid/pretable-surface.mdx index 0417cfe49..6a08d0e2c 100644 --- a/apps/website/content/docs/grid/pretable-surface.mdx +++ b/apps/website/content/docs/grid/pretable-surface.mdx @@ -4,7 +4,9 @@ description: "The full React grid surface with declarative and explicit row-mode nav: Grid --- -`` is the production grid component: indexed rendering, selection, keyboard navigation, editing, clipboard, filters, sorting, grouping, number formatting, and custom presentation hooks. +`` is the production grid component: indexed rendering, +selection, keyboard navigation, editing, clipboard, filters, sorting, grouping, +native date and number formatting, and custom presentation hooks. ## Declarative rows mode @@ -24,7 +26,7 @@ interface Event { const column = createColumnHelper(); const columns = [ - column.accessor("timestamp", { type: "date", header: "Time" }), + column.accessor("timestamp", { type: "text", header: "Time" }), column.accessor("message", { type: "text", header: "Message" }), ] as const; @@ -65,22 +67,28 @@ The modes are mutually exclusive: do not pass `rows` or `getRowId` with `model`. ## Configuration -| Area | Props | -| ------------------- | ---------------------------------------------------------------------------------- | -| Geometry | `viewportHeight`, `viewportStyle`, `overscan` | -| Rendering | `renderBodyCell`, `renderHeaderCell`, row/cell/header class and prop callbacks | -| Number presentation | `locale`; column `numberFormat`, `format`, and `formatAggregate` | -| Controlled UI | `state`, `onSelectionChange`, `onFocusChange` | -| Row selection | `rowSelectionColumn`, `state.rowSelection`, `onRowSelectionChange` — its own slice | -| Rows-mode query | the exact `query`, `onQueryChange` pair | -| Editing | rows mode: `onRowChange`; model mode: `beforeRowChange`; shared: `onPaste` | -| Clipboard | `onCopy`, `copyToClipboard`, `copyWithHeaders`, `messages` | -| Grouping | `groupPanel`, `groupColumn`, `hideGroupedColumns`, `initialExpansion` | -| Observation | `onGridReady`, `onTelemetryChange` | +| Area | Props | +| ------------------ | ---------------------------------------------------------------------------------- | +| Geometry | `viewportHeight`, `viewportStyle`, `overscan` | +| Rendering | `renderBodyCell`, `renderHeaderCell`, row/cell/header class and prop callbacks | +| Value presentation | `locale`; column `dateFormat`, `numberFormat`, `format`, and `formatAggregate` | +| Controlled UI | `state`, `onSelectionChange`, `onFocusChange` | +| Row selection | `rowSelectionColumn`, `state.rowSelection`, `onRowSelectionChange` — its own slice | +| Rows-mode query | the exact `query`, `onQueryChange` pair | +| Editing | rows mode: `onRowChange`; model mode: `beforeRowChange`; shared: `onPaste` | +| Clipboard | `onCopy`, `copyToClipboard`, `copyWithHeaders`, `messages` | +| Grouping | `groupPanel`, `groupColumn`, `hideGroupedColumns`, `initialExpansion` | +| Observation | `onGridReady`, `onTelemetryChange` | Rows-mode edit callbacks are proposals: update your `rows` value and let the next prop reconcile. Explicit-model edits validate the batch and publish one row-model transaction. -Native `numberFormat` options and `locale` format numeric cells, inherited group aggregates, and built-in clipboard text from the same raw value. `format` and `formatAggregate` remain the custom overrides. See [Number formatting](/docs/grid/number-formatting). +Native `dateFormat` / `numberFormat` options and `locale` format compatible +cells, inherited group aggregates, built-in clipboard text, and CSV through one +precedence pipeline. `format` and `formatAggregate` remain the custom +overrides. Canonical date behavior is presentation-only here: raw values stay +`YYYY-MM-DD | null`, and external query authority is never reapplied locally. +See [Date formatting](/docs/grid/date-formatting) and [Number +formatting](/docs/grid/number-formatting). For common semantic presentations, `PretableBadge`, `PretableDelta`, `PretableEntity`, and `PretableStatus` provide theme-aware contrast, direction markers, primary/secondary text, and state dots without rebuilding cell behavior. See [Cell presentations](/docs/grid/cell-presentations). @@ -123,5 +131,6 @@ Use the `rows` prop for normal React updates. For high-frequency streams, own a - [Clipboard](/docs/grid/clipboard) - [Custom rendering](/docs/grid/custom-rendering) - [Number formatting](/docs/grid/number-formatting) +- [Date formatting](/docs/grid/date-formatting) - [Streaming](/docs/streaming) - [API reference](/docs/grid/api-reference) diff --git a/apps/website/content/docs/grid/sorting.mdx b/apps/website/content/docs/grid/sorting.mdx index bfcf72ba2..9b656aedd 100644 --- a/apps/website/content/docs/grid/sorting.mdx +++ b/apps/website/content/docs/grid/sorting.mdx @@ -25,7 +25,8 @@ type Sort = PretableSortEntry[]; - **Index = priority.** `sort[0]` is the primary key, `sort[1]` breaks its ties, and so on. - **`[]` means unsorted** — rows appear in source order. - **Sorting is stable.** Rows tied on every key keep their source order. -- **Comparison keys on the values.** When every cell value in a column is a number, the column compares numerically; otherwise values are stringified and compared locale-aware (numeric-aware, case-insensitive). +- **Comparison keys on the column type and values.** Number columns compare numerically; generic values use the existing locale-aware string comparison. A custom column comparator remains authoritative. +- **Date columns are valid-first.** Canonical `YYYY-MM-DD` values compare chronologically. Every non-date value, including `null`, forms one terminal rank that stays last in ascending and descending order and ignores the date column's `nulls` setting. Ties fall through to later keys, then stable source order. See [Date formatting](/docs/grid/date-formatting#sorting-filtering-and-grouping). - Unknown column ids are rejected with a typed `invalid-query` error. The surface never emits sort entries for `sortable: false` columns; clicks on those headers are no-ops. Under [row grouping](/docs/grid/grouping#sorting-under-grouping) the sort is applied to the whole row set first and then bucketed, so rows sort **within** their group; sibling groups order by their key value, ascending unless a sort entry on that level's column says otherwise. diff --git a/apps/website/content/examples/live-events-grid/columns.ts b/apps/website/content/examples/live-events-grid/columns.ts index cae77779f..ec4b91e58 100644 --- a/apps/website/content/examples/live-events-grid/columns.ts +++ b/apps/website/content/examples/live-events-grid/columns.ts @@ -5,6 +5,6 @@ import type { EventRow } from "./data"; const column = createColumnHelper(); export const columns = [ - column.accessor("timestamp", { type: "date", header: "Time" }), + column.accessor("timestamp", { type: "text", header: "Time" }), column.accessor("message", { type: "text", header: "Message" }), ] as const; diff --git a/apps/website/e2e/docs.spec.ts b/apps/website/e2e/docs.spec.ts index abdfa012d..30e195721 100644 --- a/apps/website/e2e/docs.spec.ts +++ b/apps/website/e2e/docs.spec.ts @@ -34,6 +34,31 @@ test("number formatting page renders its active nav entry", async ({ await expect(active).toHaveAttribute("href", "/docs/grid/number-formatting"); }); +test("date formatting page renders the canonical public contract", async ({ + page, +}) => { + await page.goto("/docs/grid/date-formatting", { + waitUntil: "domcontentloaded", + }); + await expect(page.getByRole("heading", { level: 1 })).toHaveText( + "Date formatting", + ); + await expect( + page.getByText("YYYY-MM-DD | null", { exact: true }).first(), + ).toBeVisible(); + await expect( + page.getByText("PretableDateFormatOptions", { exact: true }).first(), + ).toBeVisible(); + await expect( + page.getByText("isValidDateValue", { exact: true }).first(), + ).toBeVisible(); + const active = page.locator( + 'nav[aria-label="Docs sections"] a[aria-current="page"]', + ); + await expect(active).toHaveCount(1); + await expect(active).toHaveAttribute("href", "/docs/grid/date-formatting"); +}); + test("row grouping page renders its nav entry and live example", async ({ page, }) => { diff --git a/apps/website/lib/docs/__tests__/docs-api-surface.test.ts b/apps/website/lib/docs/__tests__/docs-api-surface.test.ts index 91aee1627..695c344be 100644 --- a/apps/website/lib/docs/__tests__/docs-api-surface.test.ts +++ b/apps/website/lib/docs/__tests__/docs-api-surface.test.ts @@ -2025,6 +2025,7 @@ const FIXTURE_SUFFIX = ".types.tsx"; const FIXTURE_FILES = [ "cell-presentations.types.tsx", "csv-export.types.tsx", + "date-formatting.types.tsx", "headless-getting-started.types.tsx", "server-data.types.tsx", ]; @@ -2291,6 +2292,9 @@ const FENCE_RENAMES: Record> = { // `cell-presentations.types.tsx` transcribes four fences into one module, and // the `PretableDelta` one declares the generic `columns`. "grid/cell-presentations.mdx#PretableDelta": { columns: "deltaColumns" }, + "grid/date-formatting.mdx#Explicit-model presentation": { + column: "scheduleColumn", + }, }; function renameTokens( @@ -2715,6 +2719,44 @@ function shippedThemeFiles(): string[] { // --------------------------------------------------------------------------- describe("docs API surface matches the generated API reports", () => { + test("the canonical date guide is routed, navigable, and names the public contract", () => { + const page = PAGES.find( + (candidate) => candidate.rel === "grid/date-formatting.mdx", + ); + const nav = fs.readFileSync( + path.join(REPO_ROOT, "apps/website/app/docs/_nav.ts"), + "utf8", + ); + + expect(page, "missing grid/date-formatting.mdx").toBeDefined(); + expect(nav).toContain('title: "Date formatting"'); + expect(nav).toContain('href: "/docs/grid/date-formatting"'); + expect(page?.raw).toContain("YYYY-MM-DD"); + expect(page?.raw).toContain("dateFormat"); + expect(page?.raw).toContain("PretableDateFormatOptions"); + expect(page?.raw).toContain("isValidDateValue"); + }); + + test("the live-events instant is not declared as a calendar-date column", () => { + const surface = PAGES.find( + (candidate) => candidate.rel === "grid/pretable-surface.mdx", + ); + const exampleColumns = fs.readFileSync( + path.join( + REPO_ROOT, + "apps/website/content/examples/live-events-grid/columns.ts", + ), + "utf8", + ); + + expect(surface?.raw).toContain( + 'column.accessor("timestamp", { type: "text", header: "Time" })', + ); + expect(exampleColumns).toContain( + 'column.accessor("timestamp", { type: "text", header: "Time" })', + ); + }); + test("the docs corpus is non-empty and the reports are readable", () => { // Fail closed: every check below is vacuously true over an empty corpus, // and a moved content directory or renamed report would silently pass. diff --git a/packages/core/core.api.md b/packages/core/core.api.md index 629e8e8bb..6840ff7a4 100644 --- a/packages/core/core.api.md +++ b/packages/core/core.api.md @@ -19,8 +19,9 @@ export interface AutosizeOptions { // @public (undocumented) export type ColumnAggregateValueOf> = TColumns extends readonly (infer TColumn)[] ? TColumn extends { readonly id: TColumnId; + readonly type: infer TType extends PretableColumnType; readonly aggregate?: infer TAggregate; -} ? PretableAggregateOutputOf : never : never; +} ? PretableAggregateOutputOf : never : never; // @public (undocumented) export type ColumnAlign = "start" | "center" | "end"; @@ -165,6 +166,9 @@ export type FilterValue = string | number | readonly [number, number] | readonly // @public export const GROUP_COLUMN_ID = "__pretable_group__"; +// @public (undocumented) +export function isValidDateValue(value: unknown): value is string; + // @public export const numberFormats: { readonly money: (options: PretableCurrencyFormatOptions) => Intl.NumberFormatOptions; @@ -180,22 +184,24 @@ export interface PretableAggregateFormatInput { } // @public (undocumented) -export type PretableAggregateOutputOf = TAggregate extends { +export type PretableAggregateOutputOf = TAggregate extends { readonly finalize: (accumulator: never) => infer TOutput; -} ? TOutput : TAggregate extends "sum" | "avg" | "min" | "max" | "count" ? number | null : never; +} ? TOutput : TAggregate extends "min" | "max" ? TType extends "date" ? string | null : number | null : TAggregate extends "sum" | "avg" | "count" ? number | null : never; // @public (undocumented) export type PretableAggregatesFor = Prettify<{ readonly [TColumn in TColumns extends readonly (infer TItem)[] ? TItem : never as TColumn extends { readonly id: infer TId extends string; + readonly type: PretableColumnType; readonly aggregate?: infer TAggregate; } ? [TAggregate] extends [undefined] ? never : TId : never]: TColumn extends { + readonly type: infer TType extends PretableColumnType; readonly aggregate?: infer TAggregate; - } ? PretableAggregateOutputOf : never; + } ? PretableAggregateOutputOf : never; }>; // @public (undocumented) -export type PretableAggregateSpec = PretableBuiltinAggregate | PretableCompatibleAggregator; +export type PretableAggregateSpec = PretableBuiltinAggregate | PretableCompatibleAggregator; // @public (undocumented) export interface PretableAggregator { @@ -211,7 +217,7 @@ export interface PretableAggregator = "count" | ([NonNullable] extends [never] ? never : NonNullable extends number ? "sum" | "avg" | "min" | "max" : never); +export type PretableBuiltinAggregate = "count" | (TType extends "number" ? NonNullable extends number ? "sum" | "avg" | "min" | "max" : never : TType extends "date" ? NonNullable extends string ? "min" | "max" : never : never); // @public export interface PretableCellAddress { @@ -278,6 +284,7 @@ export interface PretableChangeSet { export interface PretableColumn { aggregate?: unknown; align?: ColumnAlign; + dateFormat?: PretableDateFormatOptions; // (undocumented) editable?: boolean | ((input: PretableEditInput) => boolean | Promise); // (undocumented) @@ -377,10 +384,11 @@ export interface PretableColumnDefinition number; + readonly dateFormat?: PretableDateFormatOptions; // (undocumented) readonly format?: (input: PretableFormatInput>) => string; // (undocumented) - readonly formatAggregate?: (input: PretableAggregateFormatInput, PretableColumnDefinition>) => string; + readonly formatAggregate?: (input: PretableAggregateFormatInput, PretableColumnDefinition>) => string; // (undocumented) readonly header?: string; // (undocumented) @@ -399,12 +407,12 @@ export interface PretableColumnDerivation; + readonly aggregate: PretableCompatibleAggregateSpec; }; // (undocumented) readonly accessor: (row: TRow) => TValue; // (undocumented) - readonly aggregate?: PretableCompatibleAggregateSpec; + readonly aggregate?: PretableCompatibleAggregateSpec; // (undocumented) readonly compare?: (left: TValue, right: TValue) => number; // (undocumented) @@ -418,40 +426,42 @@ export interface PretableColumnDerivation { // (undocumented) - accessor unknown, const TType extends (unknown extends NoInfer ? never : PretableColumnTypeFor>) | ([TValue] extends [PretableUninferredColumnValue] ? [ReturnType] extends [never] ? never : PretableColumnType : never), TValue = PretableUninferredColumnValue, const TAggregate extends PretableAggregateSpec> | ([TValue] extends [PretableUninferredColumnValue] ? [ReturnType] extends [never] ? never : PretableAggregateSpec | "sum" | "avg" | "min" | "max" : never) | undefined = undefined>(id: TId, accessor: TAccessor & ((row: TRow) => TValue), options: { + accessor unknown, const TType extends (unknown extends NoInfer ? never : PretableColumnTypeFor>) | ([TValue] extends [PretableUninferredColumnValue] ? [ReturnType] extends [never] ? never : 0 extends 1 & ReturnType ? Exclude : PretableColumnType : never), TValue = PretableUninferredColumnValue, const TAggregate extends PretableAggregateSpec, TType> | ([TValue] extends [PretableUninferredColumnValue] ? [ReturnType] extends [never] ? never : PretableAggregateSpec | (TType extends "number" ? "sum" | "avg" | "min" | "max" : TType extends "date" ? "min" | "max" : never) : never) | undefined = undefined>(id: TId, accessor: TAccessor & ((row: TRow) => TValue), options: { readonly type: TType; readonly header?: string; readonly compare?: (left: TValue, right: TValue) => number; readonly aggregate?: TAggregate; readonly numberFormat?: Intl.NumberFormatOptions; + readonly dateFormat?: PretableDateFormatOptions; readonly format?: (input: { readonly value: TValue; readonly row: TRow; readonly column: PretableColumnCallbackContext; }) => string; readonly formatAggregate?: (input: { - readonly value: PretableAggregateOutputOf; + readonly value: PretableAggregateOutputOf; readonly column: PretableColumnCallbackContext; }) => string; }): PretableColumnDefinition, TType, TAggregate> & PretableColumnAccessorKind<"computed">; // (undocumented) - accessor, const TType extends PretableColumnTypeFor, const TAggregate extends PretableAggregateSpec | undefined = undefined>(key: TKey, options: PretableColumnOptions): PretableColumnDefinition & PretableColumnAccessorKind<"direct">; + accessor, const TType extends PretableColumnTypeFor, const TAggregate extends PretableAggregateSpec | undefined = undefined>(key: TKey, options: PretableColumnOptions): PretableColumnDefinition & PretableColumnAccessorKind<"direct">; } // @public (undocumented) -export type PretableColumnOptions, TAggregate extends PretableAggregateSpec | undefined> = { +export type PretableColumnOptions, TAggregate extends PretableAggregateSpec | undefined> = { readonly type: TType; readonly header?: string; readonly compare?: (left: TValue, right: TValue) => number; readonly aggregate?: TAggregate; readonly numberFormat?: Intl.NumberFormatOptions; + readonly dateFormat?: PretableDateFormatOptions; readonly format?: (input: { readonly value: TValue; readonly row: TRow; readonly column: PretableColumnCallbackContext; }) => string; readonly formatAggregate?: (input: { - readonly value: PretableAggregateOutputOf; + readonly value: PretableAggregateOutputOf; readonly column: PretableColumnCallbackContext; }) => string; }; @@ -460,10 +470,14 @@ export type PretableColumnOptions = [TValue] extends [never] ? never : [NonNullable] extends [never] ? Exclude : NonNullable extends number ? "number" : NonNullable extends boolean ? "boolean" : NonNullable extends Date ? "date" : NonNullable extends string ? "text" | "enum" | "date" : PretableColumnType; +export type PretableColumnTypeFor = 0 extends 1 & TValue ? Exclude : [TValue] extends [never] ? never : [NonNullable] extends [never] ? Exclude : NonNullable extends number ? "number" : NonNullable extends boolean ? "boolean" : NonNullable extends string ? "text" | "enum" | ([TValue] extends [string | null] ? "date" : never) : Exclude; // @public (undocumented) -export type PretableCompatibleAggregateSpec = [TAggregate] extends [undefined] ? undefined : ([PretableAggregateOutputOf] extends [number | null] ? [number | null] extends [PretableAggregateOutputOf] ? PretableBuiltinAggregate : never : never) | PretableCompatibleAggregator>; +export type PretableCompatibleAggregateSpec = [TAggregate] extends [undefined] ? undefined : (PretableBuiltinAggregate extends infer TName ? TName extends PretableBuiltinAggregate ? [PretableAggregateOutputOf] extends [ +PretableAggregateOutputOf +] ? [PretableAggregateOutputOf] extends [ +PretableAggregateOutputOf +] ? TName : never : never : never : never) | PretableCompatibleAggregator>; // @public (undocumented) export interface PretableCompatibleAggregator { @@ -506,6 +520,11 @@ export interface PretableDataRow = { readonly [K in keyof TColumns]: TColumns[K] extends { @@ -539,9 +558,7 @@ export class PretableDisposedModelError extends PretableRowModelError { export type PretableDistinctColumnIdOf = TColumns extends readonly (infer TColumn)[] ? TColumn extends { readonly id: infer TColumnId extends string; readonly accessor: (...args: never[]) => infer TValue; -} ? [TValue] extends [ -string | number | bigint | boolean | Date | null | undefined -] ? [TValue] extends [never] ? never : TColumnId : never : never : never; +} ? [TValue] extends [string | number | bigint | boolean | null | undefined] ? [TValue] extends [never] ? never : TColumnId : never : never : never; // @public (undocumented) export interface PretableDistinctValueOptions { @@ -672,7 +689,7 @@ export type PretableFilterFor = TColumns extends readonly (infer TColu }) : never : never; // @public -export type PretableFilterOperandFor = TType extends "text" ? string : TType extends "number" ? number : TType extends "date" ? string | number | Date : TType extends "boolean" ? boolean : [Extract, string>] extends [never] ? string : Extract, string>; +export type PretableFilterOperandFor = TType extends "text" ? string : TType extends "number" ? number : TType extends "date" ? string : TType extends "boolean" ? boolean : [Extract, string>] extends [never] ? string : Extract, string>; // @public export type PretableFocusDirection = "up" | "down" | "left" | "right"; @@ -801,7 +818,7 @@ export type PretableGroupId = string & { }; // @public -export type PretableGroupKey = string | number | bigint | boolean | Date | null | undefined; +export type PretableGroupKey = string | number | bigint | boolean | null | undefined; // @public (undocumented) export type PretableGroupRow = { diff --git a/packages/react/react.api.md b/packages/react/react.api.md index 9fb99f7e6..d9bf27bb0 100644 --- a/packages/react/react.api.md +++ b/packages/react/react.api.md @@ -195,6 +195,9 @@ export type FilterOperator = "contains" | "notContains" | "equals" | "notEquals" // @public (undocumented) export type FilterValue = string | number | readonly [number, number] | readonly [string, string] | readonly string[] | null; +// @public (undocumented) +export function isValidDateValue(value: unknown): value is string; + // @beta export function LabeledGridSurface { } // @public (undocumented) -export type PretableAggregateOutputOf = TAggregate extends { +export type PretableAggregateOutputOf = TAggregate extends { readonly finalize: (accumulator: never) => infer TOutput; -} ? TOutput : TAggregate extends "sum" | "avg" | "min" | "max" | "count" ? number | null : never; +} ? TOutput : TAggregate extends "min" | "max" ? TType extends "date" ? string | null : number | null : TAggregate extends "sum" | "avg" | "count" ? number | null : never; // @public (undocumented) export type PretableAggregatesFor = Prettify<{ readonly [TColumn in TColumns extends readonly (infer TItem)[] ? TItem : never as TColumn extends { readonly id: infer TId extends string; + readonly type: PretableColumnType; readonly aggregate?: infer TAggregate; } ? [TAggregate] extends [undefined] ? never : TId : never]: TColumn extends { + readonly type: infer TType extends PretableColumnType; readonly aggregate?: infer TAggregate; - } ? PretableAggregateOutputOf : never; + } ? PretableAggregateOutputOf : never; }>; // @public (undocumented) -export type PretableAggregateSpec = PretableBuiltinAggregate | PretableCompatibleAggregator; +export type PretableAggregateSpec = PretableBuiltinAggregate | PretableCompatibleAggregator; // @public (undocumented) export interface PretableAggregator { @@ -437,7 +442,7 @@ export interface PretableBaseProps = "count" | ([NonNullable] extends [never] ? never : NonNullable extends number ? "sum" | "avg" | "min" | "max" : never); +export type PretableBuiltinAggregate = "count" | (TType extends "number" ? NonNullable extends number ? "sum" | "avg" | "min" | "max" : never : TType extends "date" ? NonNullable extends string ? "min" | "max" : never : never); // @public export interface PretableCellAddress { @@ -547,6 +552,7 @@ export interface PretableColumn { // (undocumented) aggregate?: unknown; align?: ColumnAlign; + dateFormat?: PretableDateFormatOptions; // (undocumented) editable?: boolean | ((input: { rowId: string; @@ -669,10 +675,11 @@ export interface PretableColumnDefinition number; + readonly dateFormat?: PretableDateFormatOptions; // (undocumented) readonly format?: (input: PretableFormatInput>) => string; // (undocumented) - readonly formatAggregate?: (input: PretableAggregateFormatInput, PretableColumnDefinition>) => string; + readonly formatAggregate?: (input: PretableAggregateFormatInput, PretableColumnDefinition>) => string; // (undocumented) readonly header?: string; // (undocumented) @@ -691,12 +698,12 @@ export interface PretableColumnDerivation; + readonly aggregate: PretableCompatibleAggregateSpec; }; // (undocumented) readonly accessor: (row: TRow) => TValue; // (undocumented) - readonly aggregate?: PretableCompatibleAggregateSpec; + readonly aggregate?: PretableCompatibleAggregateSpec; // (undocumented) readonly compare?: (left: TValue, right: TValue) => number; // (undocumented) @@ -727,18 +734,19 @@ export interface PretableColumnEditInput, TAggregate extends PretableAggregateSpec | undefined> = { +export type PretableColumnFactoryOptions, TAggregate extends PretableAggregateSpec | undefined> = { readonly type: TType; readonly compare?: (left: TValue, right: TValue) => number; readonly aggregate?: TAggregate; readonly numberFormat?: Intl.NumberFormatOptions; + readonly dateFormat?: PretableDateFormatOptions; readonly format?: (input: { readonly value: TValue; readonly row: TRow; readonly column: PretableReactColumnContext; }) => string; readonly formatAggregate?: (input: { - readonly value: PretableAggregateOutputOf; + readonly value: PretableAggregateOutputOf; readonly column: PretableReactColumnContext; }) => string; } & PretableColumnPresentation>; @@ -780,6 +788,9 @@ export type PretableColumnRowId = TRow extends { // @public (undocumented) export type PretableColumnType = "text" | "number" | "date" | "enum" | "boolean"; +// @public (undocumented) +export type PretableColumnTypeFor = 0 extends 1 & TValue ? Exclude : [TValue] extends [never] ? never : [NonNullable] extends [never] ? Exclude : NonNullable extends number ? "number" : NonNullable extends boolean ? "boolean" : NonNullable extends string ? "text" | "enum" | ([TValue] extends [string | null] ? "date" : never) : Exclude; + // @public export type PretableColumnValue = TColumn extends { readonly accessor: (row: object) => infer TValue; @@ -806,7 +817,11 @@ export interface PretableColumnVisualPresentation { } // @public (undocumented) -export type PretableCompatibleAggregateSpec = [TAggregate] extends [undefined] ? undefined : ([PretableAggregateOutputOf] extends [number | null] ? [number | null] extends [PretableAggregateOutputOf] ? PretableBuiltinAggregate : never : never) | PretableCompatibleAggregator>; +export type PretableCompatibleAggregateSpec = [TAggregate] extends [undefined] ? undefined : (PretableBuiltinAggregate extends infer TName ? TName extends PretableBuiltinAggregate ? [PretableAggregateOutputOf] extends [ +PretableAggregateOutputOf +] ? [PretableAggregateOutputOf] extends [ +PretableAggregateOutputOf +] ? TName : never : never : never : never) | PretableCompatibleAggregator>; // @public (undocumented) export interface PretableCompatibleAggregator { @@ -912,6 +927,11 @@ export type PretableDataState = message?: string; }; +// @public +export type PretableDateFormatOptions = { + [TKey in keyof Intl.DateTimeFormatOptions]?: TKey extends "localeMatcher" | "calendar" | "numberingSystem" | "dateStyle" | "weekday" | "era" | "year" | "month" | "day" | "formatMatcher" ? Intl.DateTimeFormatOptions[TKey] : never; +}; + // @public export function PretableDelta(input: PretableDeltaProps): react.JSX.Element; @@ -956,9 +976,7 @@ export interface PretableDisposable { export type PretableDistinctColumnIdOf = TColumns extends readonly (infer TColumn)[] ? TColumn extends { readonly id: infer TColumnId extends string; readonly accessor: (...args: never[]) => infer TValue; -} ? [TValue] extends [ -string | number | bigint | boolean | Date | null | undefined -] ? [TValue] extends [never] ? never : TColumnId : never : never : never; +} ? [TValue] extends [string | number | bigint | boolean | null | undefined] ? [TValue] extends [never] ? never : TColumnId : never : never : never; // @public (undocumented) export interface PretableDistinctValueOptions { @@ -1136,7 +1154,7 @@ export type PretableFilterFor = TColumns extends readonly (infer TColu }) : never : never; // @public -export type PretableFilterOperandFor = TType extends "text" ? string : TType extends "number" ? number : TType extends "date" ? string | number | Date : TType extends "boolean" ? boolean : [Extract, string>] extends [never] ? string : Extract, string>; +export type PretableFilterOperandFor = TType extends "text" ? string : TType extends "number" ? number : TType extends "date" ? string : TType extends "boolean" ? boolean : [Extract, string>] extends [never] ? string : Extract, string>; // @public export type PretableFocusDirection = "up" | "down" | "left" | "right"; @@ -1281,7 +1299,7 @@ export type PretableGroupId = string & { }; // @public -export type PretableGroupKey = string | number | bigint | boolean | Date | null | undefined; +export type PretableGroupKey = string | number | bigint | boolean | null | undefined; // @public (undocumented) export type PretableGroupRow = { @@ -1646,7 +1664,7 @@ export interface PretableReactColumnContext, TAggregate extends PretableAggregateSpec | undefined, TDirect extends boolean> = Omit, "header" | "accessorKey"> & PretableColumnPresentation> & PretableColumnAccessorKind & (TDirect extends true ? { +export type PretableReactColumnDefinition, TAggregate extends PretableAggregateSpec | undefined, TDirect extends boolean> = Omit, "header" | "accessorKey"> & PretableColumnPresentation> & PretableColumnAccessorKind & (TDirect extends true ? { readonly accessorKey: TId; } : { readonly accessorKey?: undefined; @@ -1660,7 +1678,7 @@ export type PretableReactColumns = { }; // @public -export type PretableReactColumnTypeFor = [TValue] extends [never] ? never : [NonNullable] extends [never] ? Exclude : NonNullable extends number ? "number" : NonNullable extends boolean ? "boolean" : NonNullable extends Date ? "date" : NonNullable extends string ? "text" | "enum" | "date" : PretableColumnType; +export type PretableReactColumnTypeFor = PretableColumnTypeFor; // @public export type PretableReactGrid> = { diff --git a/packages/row-model/src/column-types.ts b/packages/row-model/src/column-types.ts index 712ff2852..e18892292 100644 --- a/packages/row-model/src/column-types.ts +++ b/packages/row-model/src/column-types.ts @@ -210,25 +210,22 @@ export interface PretableColumnDefinition< }; } -type IsAny = 0 extends 1 & TValue ? true : false; - /** @public */ -export type PretableColumnTypeFor = - IsAny extends true - ? Exclude - : [TValue] extends [never] - ? never - : [NonNullable] extends [never] - ? Exclude - : NonNullable extends number - ? "number" - : NonNullable extends boolean - ? "boolean" - : NonNullable extends string - ? | "text" - | "enum" - | ([TValue] extends [string | null] ? "date" : never) - : Exclude; +export type PretableColumnTypeFor = 0 extends 1 & TValue + ? Exclude + : [TValue] extends [never] + ? never + : [NonNullable] extends [never] + ? Exclude + : NonNullable extends number + ? "number" + : NonNullable extends boolean + ? "boolean" + : NonNullable extends string + ? | "text" + | "enum" + | ([TValue] extends [string | null] ? "date" : never) + : Exclude; /** @public */ export interface PretableColumnCallbackContext< @@ -296,7 +293,7 @@ export interface PretableColumnHelper { | ([TValue] extends [PretableUninferredColumnValue] ? [ReturnType] extends [never] ? never - : IsAny> extends true + : 0 extends 1 & ReturnType ? Exclude : PretableColumnType : never), @@ -643,24 +640,6 @@ export interface PretableCompatibleAggregator< }["bivarianceHack"]; } -type CompatibleBuiltinAggregate< - TValue, - TType extends PretableColumnType, - TAggregate, - TName extends PretableBuiltinAggregate = - PretableBuiltinAggregate, -> = TName extends unknown - ? [PretableAggregateOutputOf] extends [ - PretableAggregateOutputOf, - ] - ? [PretableAggregateOutputOf] extends [ - PretableAggregateOutputOf, - ] - ? TName - : never - : never - : never; - /** @public */ export type PretableCompatibleAggregateSpec< TRow extends object, @@ -669,7 +648,19 @@ export type PretableCompatibleAggregateSpec< TAggregate, > = [TAggregate] extends [undefined] ? undefined - : | CompatibleBuiltinAggregate + : | (PretableBuiltinAggregate extends infer TName + ? TName extends PretableBuiltinAggregate + ? [PretableAggregateOutputOf] extends [ + PretableAggregateOutputOf, + ] + ? [PretableAggregateOutputOf] extends [ + PretableAggregateOutputOf, + ] + ? TName + : never + : never + : never + : never) | PretableCompatibleAggregator< TRow, TValue, From 21ce7743318ddcc0668354ea9f87b9442c6d020e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 21:03:43 -0700 Subject: [PATCH 15/16] test(react): await external authority headers --- .../src/__tests__/external-filter-authority.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react/src/__tests__/external-filter-authority.test.tsx b/packages/react/src/__tests__/external-filter-authority.test.tsx index ad1586511..0e55b4c82 100644 --- a/packages/react/src/__tests__/external-filter-authority.test.tsx +++ b/packages/react/src/__tests__/external-filter-authority.test.tsx @@ -211,10 +211,12 @@ describe("external filter authority suppresses local filtering", () => { await expect .poll(() => renderedRowIds()) .toEqual(LOADED.map((row) => row.id)); - expect(reportedHeaderState()).toEqual({ - ariaSort: "ascending", - filterActive: "true", - }); + await expect + .poll(() => reportedHeaderState()) + .toEqual({ + ariaSort: "ascending", + filterActive: "true", + }); }); it("leaves an explicitly-owned model filtering locally", async () => { From 68df10803a8714b8f0c1ee4dfd9240ee937ee5ea Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 18 Aug 2026 21:18:20 -0700 Subject: [PATCH 16/16] fix(react): preserve edit session ownership --- .../src/__tests__/date-cell-editor.test.tsx | 46 ++++++ .../pretable-surface-editing.test.tsx | 144 ++++++++++++++++++ packages/react/src/editors/DateCellEditor.tsx | 9 +- packages/react/src/pretable-surface.tsx | 18 ++- 4 files changed, 213 insertions(+), 4 deletions(-) diff --git a/packages/react/src/__tests__/date-cell-editor.test.tsx b/packages/react/src/__tests__/date-cell-editor.test.tsx index a42dea15d..1059a0da9 100644 --- a/packages/react/src/__tests__/date-cell-editor.test.tsx +++ b/packages/react/src/__tests__/date-cell-editor.test.tsx @@ -248,6 +248,52 @@ describe("DateCellEditor (via dispatcher)", () => { expect(commit).toHaveBeenCalledWith(); }); + it("treats a type-to-replace seed as a user change for custom parser blur", () => { + const commit = vi.fn(); + const cancel = vi.fn(); + render( + `parsed:${draft}`, + }, + commit, + cancel, + })} + />, + ); + + fireEvent.blur(screen.getByRole("textbox")); + + expect(commit).toHaveBeenCalledWith(); + expect(cancel).not.toHaveBeenCalled(); + }); + + it("still cancels an incomplete built-in type-to-replace seed on blur", () => { + const commit = vi.fn(); + const cancel = vi.fn(); + render( + , + ); + + fireEvent.blur(screen.getByRole("textbox")); + + expect(cancel).toHaveBeenCalledWith(); + expect(commit).not.toHaveBeenCalled(); + }); + it("synchronizes the calendar and selection to a controlled canonical rerender", () => { const input = makeInput(); const view = render(); diff --git a/packages/react/src/__tests__/pretable-surface-editing.test.tsx b/packages/react/src/__tests__/pretable-surface-editing.test.tsx index 020f597ee..2b61df602 100644 --- a/packages/react/src/__tests__/pretable-surface-editing.test.tsx +++ b/packages/react/src/__tests__/pretable-surface-editing.test.tsx @@ -315,6 +315,46 @@ describe("PretableSurface editing", () => { expect(box.selectionEnd).toBe(1); }); + it("commits a typed date replacement through a custom parser on blur", async () => { + const parseEditValue = vi.fn((draft: string) => `parsed:${draft}`); + const onRowChange = vi.fn(); + render( + + ariaLabel="people" + columns={[ + { + id: "name", + header: "Date", + type: "date", + editable: true, + parseEditValue, + }, + ]} + rows={[{ id: "r1", name: "2026-08-06" }]} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={onRowChange} + />, + ); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "x" }); + const box = screen.getByRole("textbox"); + expect(box).toHaveValue("x"); + + fireEvent.blur(box); + await flush(); + + expect(parseEditValue).toHaveBeenCalledWith("x", expect.any(Object)); + expect(onRowChange).toHaveBeenCalledWith( + expect.objectContaining({ + rowId: "r1", + columnId: "name", + value: "parsed:x", + }), + ); + }); + it("keeps a public replacement open when a stale editable gate resolves false", async () => { let allow!: (value: boolean) => void; let publicGrid!: PretableSurfaceGrid< @@ -455,6 +495,43 @@ describe("PretableSurface editing", () => { expect(screen.getByRole("textbox")).toHaveValue("replacement"); }); + it("closes orphaned checking state when its controller is replaced", async () => { + let allow!: (value: boolean) => void; + const onRowChange = vi.fn(); + const firstColumns: PretableColumn[] = [ + { + id: "name", + header: "Name", + editable: () => new Promise((resolve) => (allow = resolve)), + }, + ]; + const surface = (columns: PretableColumn[]) => ( + + ariaLabel="people" + columns={columns} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={onRowChange} + /> + ); + const view = render(surface(firstColumns)); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + expect(cell).toHaveAttribute("data-pretable-edit-status", "checking"); + + view.rerender( + surface([{ id: "name", header: "Replacement name", editable: true }]), + ); + + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + allow(true); + await flush(); + expect(onRowChange).not.toHaveBeenCalled(); + expect(firstNameCell()).not.toHaveAttribute("data-pretable-edit-status"); + }); + it("does not continue validation after its controller is replaced", async () => { let finishValidation!: (value: true) => void; const onRowChange = vi.fn(); @@ -489,10 +566,77 @@ describe("PretableSurface editing", () => { view.rerender( surface([{ id: "name", header: "Replacement name", editable: true }]), ); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); finishValidation(true); await flush(); expect(onRowChange).not.toHaveBeenCalled(); + expect(firstNameCell()).not.toHaveAttribute("data-pretable-edit-status"); + }); + + it("closes orphaned saving state when its controller is replaced", async () => { + let rejectSave!: (error: Error) => void; + const onRowChange = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectSave = reject; + }), + ); + const surface = (header: string) => ( + + ariaLabel="people" + columns={[{ id: "name", header, editable: true }]} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={onRowChange} + /> + ); + const view = render(surface("Name")); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "stale save" }, + }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + expect(onRowChange).toHaveBeenCalledOnce(); + + view.rerender(surface("Replacement name")); + + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + rejectSave(new Error("stale save failed")); + await flush(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(firstNameCell()).not.toHaveAttribute("data-pretable-edit-status"); + }); + + it("preserves a nonpending edit when its controller is replaced", () => { + const surface = (header: string) => ( + + ariaLabel="people" + columns={[{ id: "name", header, editable: true }]} + rows={ROWS} + getRowId={(row) => row.id} + viewportHeight={300} + onRowChange={vi.fn()} + /> + ); + const view = render(surface("Name")); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "in progress" }, + }); + + view.rerender(surface("Replacement name")); + + expect(screen.getByRole("textbox")).toHaveValue("in progress"); + expect(firstNameCell()).toHaveAttribute( + "data-pretable-edit-status", + "editing", + ); }); it("does not let an old save rejection mark a replacement edit", async () => { diff --git a/packages/react/src/editors/DateCellEditor.tsx b/packages/react/src/editors/DateCellEditor.tsx index 8d6b730fb..906397de9 100644 --- a/packages/react/src/editors/DateCellEditor.tsx +++ b/packages/react/src/editors/DateCellEditor.tsx @@ -33,13 +33,16 @@ interface DateEditorState { readonly userDraft: unknown; } -const initialState = (draft: unknown): DateEditorState => { +const initialState = ( + draft: unknown, + seededFromTyping: boolean, +): DateEditorState => { const canonical = isValidDateValue(draft) ? draft : null; return { observedDraft: draft, cursor: canonical ?? todayIso(), selected: canonical, - userModified: false, + userModified: seededFromTyping, userDraft: draft, }; }; @@ -50,7 +53,7 @@ export function DateCellEditor({ input }: { input: PretableEditorInput }) { const anchorRef = useRef(null); const [rect, setRect] = useState(null); const [storedState, setStoredState] = useState(() => - initialState(input.draft), + initialState(input.draft, input.seededFromTyping ?? false), ); let state = storedState; diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index 93145508f..f735d8b84 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -1980,6 +1980,7 @@ export function PretableSurface< cancel(): void; invalidate(): void; } | null>(null); + const replacedControllerSessionTokenRef = useRef(null); const beginEditWithSession = useCallback( ( input: Parameters[0], @@ -3350,14 +3351,29 @@ export function PretableSurface< }); useLayoutEffect(() => { + const replacedSessionToken = replacedControllerSessionTokenRef.current; + replacedControllerSessionTokenRef.current = null; editControllerRef.current = editController; + const editing = grid.getSnapshot().editing; + if ( + replacedSessionToken !== null && + replacedSessionToken === editSessionRef.current.activeToken && + editing !== null && + (editing.status === "checking" || + editing.status === "validating" || + editing.status === "saving") + ) { + endEditSession(); + } return () => { editController.invalidate(); if (editControllerRef.current === editController) { + replacedControllerSessionTokenRef.current = + editSessionRef.current.activeToken; editControllerRef.current = null; } }; - }, [editController]); + }, [editController, endEditSession, grid]); useLayoutEffect(() => { const pending = pendingRowsEditRef.current;