From 5ad78fdecc321db16d5257be8030e11dc40d2c57 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 04:50:02 +0000 Subject: [PATCH] fix(core,fields): render a dataset measure over a date field as a date `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`, placed before `format` was ever read. A `min` / `max` measure over a date or datetime field therefore printed its stored value verbatim -- a 24-character ISO string in the KPI tile's `text-2xl font-semibold`, wrapping to two lines -- and the `format` that `DatasetMeasureSchema` accepts could never be read on that path. A date-shaped value now routes to the date display path ahead of that short-circuit, so all four dataset-bound surfaces are served at once. No second date formatter was written. `formatDate`, `formatDateTime`, `formatRelativeDate` and `DateDisplayOptions` MOVED from `@object-ui/fields`' barrel down into `@object-ui/core` (`utils/date-display.ts`) -- the same remedy objectui#4576 applied to `formatDisplayNumber`, for the same reason: `core` is the React-free engine and could not import from a React package, so the alternative was a parallel date convention in `dataset-format.ts`, which is the drift #4576 already paid for once in percent. `@object-ui/fields` re-exports all four names unchanged and a reference-identity test pins that the cell renderer and the measure formatter call the same function object. Numeric measures are byte-identical: 33,696 argument forms compared against a verbatim copy of the pre-fix function, and the only values that moved were the four ISO-shaped, parseable ones. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- .changeset/7178-format-measure-date.md | 45 +++++ packages/core/src/index.ts | 5 + .../__tests__/dataset-format.date.test.ts | 167 ++++++++++++++++++ packages/core/src/utils/dataset-format.ts | 101 ++++++++++- packages/core/src/utils/date-display.ts | 162 +++++++++++++++++ .../date-display.reexport-identity.test.ts | 61 +++++++ packages/fields/src/index.tsx | 133 ++------------ .../DatasetWidget.dateMeasure.test.tsx | 112 ++++++++++++ 8 files changed, 665 insertions(+), 121 deletions(-) create mode 100644 .changeset/7178-format-measure-date.md create mode 100644 packages/core/src/utils/__tests__/dataset-format.date.test.ts create mode 100644 packages/core/src/utils/date-display.ts create mode 100644 packages/fields/src/__tests__/date-display.reexport-identity.test.ts create mode 100644 packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx diff --git a/.changeset/7178-format-measure-date.md b/.changeset/7178-format-measure-date.md new file mode 100644 index 0000000000..e5426cc40a --- /dev/null +++ b/.changeset/7178-format-measure-date.md @@ -0,0 +1,45 @@ +--- +'@object-ui/core': patch +'@object-ui/fields': patch +--- + +Render a dataset measure over a date field as a date (objectui#7178, maintainer +ruling 2026-09-02, director summon #8 — option A). + +`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`, +placed **before** `format` was ever read. So a `min` / `max` measure over a date +or datetime field printed its stored value verbatim — a 24-character ISO string +in the KPI tile's `text-2xl font-semibold`, wrapping to two lines — and the +`format` that `DatasetMeasureSchema` accepts was unreachable for those values. +A date-shaped value now routes to the date display path before that +short-circuit, so all four dataset-bound surfaces are served at once: the metric +tile, chart values, dataset table cells, and the metadata-admin dataset preview. + +`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec` +narrows. `PivotTable` takes a `number` outright and is unchanged. + +**No second date formatter was written.** `formatDate`, `formatDateTime`, +`formatRelativeDate` and `DateDisplayOptions` MOVED from `@object-ui/fields`' +barrel down into `@object-ui/core` (`utils/date-display.ts`), which is the same +remedy objectui#4576 applied to `formatDisplayNumber` and for the same reason: +`core` is the React-free engine and could not import from a React package, so +the alternative was a parallel date convention in `dataset-format.ts` — exactly +the drift that once had a list cell rendering `1.234,5 %` beside a dashboard +measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged, +so no consumer's import path or behaviour changes, and a reference-identity test +pins that the cell renderer and the measure formatter call the same function. + +**What `format` can say for a date measure, measured rather than assumed.** The +shared date path takes a named STYLE, not a date pattern: `'short'` and +`'relative'` are honoured — the same words `DateCellRenderer` honours from +`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale +default. That limit is unchanged by this release (`plugin-dashboard`'s +`recordFields` already routed a date-shaped `format` into the same style slot) +and is now pinned by a test instead of being silent. + +**Numeric measures are byte-identical.** 33,696 argument forms +(value × format × currency × percentScale × locale) were compared against a +verbatim copy of the pre-fix function: the only values that moved were the four +ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`, +`'2026'`), the nullish em dash, arbitrary prose and non-strings all render +exactly as before. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 73b7b1fadd..4b84a7d034 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js'; // `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n` // re-exports these names unchanged, so both import paths name the same symbol. export * from './utils/number-display.js'; +// The ONE date-display path (objectui#7178) — the same story one type over. +// It lived in `@object-ui/fields`' React barrel, which `dataset-format` +// below could not import, so a date-valued measure rendered its raw ISO +// string. `@object-ui/fields` re-exports these names unchanged. +export * from './utils/date-display.js'; export * from './utils/dataset-format.js'; // Pivot lookup-key encoders, shared by every cross-tab renderer so the // dashboard widget and the report renderer key their buckets identically diff --git a/packages/core/src/utils/__tests__/dataset-format.date.test.ts b/packages/core/src/utils/__tests__/dataset-format.date.test.ts new file mode 100644 index 0000000000..28e3dfffe6 --- /dev/null +++ b/packages/core/src/utils/__tests__/dataset-format.date.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7178 — a dataset measure over a date field renders as a date. + * + * `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`, + * placed BEFORE `format` was read. So a `min` / `max` over a date or datetime + * field printed its stored value verbatim — a 24-character ISO string in the + * KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the + * `format` that `DatasetMeasureSchema` accepts was unreachable for it. + * + * ── What these cases pin, and what they deliberately do NOT ───────────────── + * The fix is a ROUTE, not a formatter: every byte of date rendering comes from + * `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` / + * `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s + * date cells and `ObjectGantt`'s tooltips call. So the cases below assert + * AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own + * output is the contract — a literal would pin this file's opinion of a date, + * which is exactly the second convention objectui#4576 says not to create. + * (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered + * surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts` + * pins that the cell's `formatDate` and this one are the same function.) + * + * ── Directions, predicted in writing BEFORE the run ───────────────────────── + * the two date cases RED pre-fix — they render the raw ISO string + * the `format`-as-style cases RED pre-fix — `format` was never read here + * ⭐ every must-not-move case GREEN on both sides. These are the guard, + * not the fix: the new branch runs ahead of + * the `String(v)` short-circuit, so it has to + * be shown NOT to capture numbers, numeric + * strings, the nullish em dash, or prose. + * + * The full before/after sweep is recorded in the PR: 33,696 argument forms + * (value x format x currency x percentScale x locale) compared against a + * verbatim copy of the pre-fix function, 4 distinct values moved — every one + * of them ISO-shaped and parseable — and 0 expected movers missed. + */ + +import { describe, it, expect } from 'vitest'; + +import { formatMeasure } from '../dataset-format.js'; +import { formatDate, formatDateTime } from '../date-display.js'; + +/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */ +const ISO_DATE = '2024-07-04'; +const ISO_DATETIME = '2024-07-04T07:00:00.000Z'; +const EN = 'en-US'; + +describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => { + it('renders a datetime measure as a datetime, not as its raw ISO string', () => { + const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN); + expect(out).not.toBe(ISO_DATETIME); + expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN })); + }); + + it('renders a date-only measure as a date, not as its raw ISO string', () => { + const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN); + expect(out).not.toBe(ISO_DATE); + expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN })); + }); + + it('accepts the space-separated ISO spelling a backend may send', () => { + const spaced = '2024-07-04 07:00:00'; + expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe( + formatDateTime(spaced, { locale: EN }), + ); + }); + + it('follows the display locale, like every other measure', () => { + const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE'); + const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN); + expect(de).not.toBe(en); + expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' })); + }); +}); + +/** + * The finding's reusable half: `DatasetMeasureSchema.format` is an open string + * that parses and, on this path, could never be read. It is read now — as the + * shared path's STYLE vocabulary, which is what that path actually accepts. + * A date PATTERN is not part of that vocabulary, and these cases say so out + * loud rather than leaving it silent, because "silent" is the whole card. + */ +describe('what `format` can and cannot say for a date measure (objectui#7178)', () => { + it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => { + expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe( + formatDate(ISO_DATE, 'short', { locale: EN }), + ); + expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe( + formatMeasure(ISO_DATE, undefined, undefined, undefined, EN), + ); + }); + + it('honours `relative`', () => { + expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe( + formatDate(ISO_DATE, 'relative', { locale: EN }), + ); + }); + + it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => { + // Measured, not assumed, and stated in the PR body: the shared date path + // takes a named style, not a pattern. This case exists so the limit is + // pinned rather than rediscovered — and so that teaching the shared path a + // pattern grammar later has a test that must be updated deliberately. + const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN); + expect(patterned).not.toBe('2024-07-04'); + expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN)); + }); +}); + +/** + * ⭐ The regression guard. The date branch runs ahead of the `String(v)` + * short-circuit, so every one of these has to be shown NOT to reach it. + */ +describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => { + it('formats numbers through the numeral path, untouched', () => { + expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5'); + expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50'); + expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%'); + expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42'); + expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026'); + }); + + it('⭐ never reads a NUMERIC STRING as a date', () => { + // `1751612400000` is epoch milliseconds for a real instant, and `2026` is a + // year. Both are what a count or an id looks like coming back from a + // measure, and `Date.parse` would take either if it were asked. + for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) { + expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v); + expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v); + } + }); + + it('keeps the nullish em dash', () => { + expect(formatMeasure(null)).toBe('—'); + expect(formatMeasure(undefined)).toBe('—'); + expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—'); + }); + + it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => { + for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) { + expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v)); + } + expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true'); + }); + + it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => { + // `formatDate` answers `—` for an unparseable value. Routing one there + // would REPLACE a raw string the author can still debug with a dash that + // says nothing, so the parse guard keeps these on the old path. + for (const v of ['2026-13-45', '2024-07-04T99:99']) { + expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v); + } + }); + + it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => { + // `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every + // date surface in the repo builds its `Date` the same way, so this renders + // as the same day a list cell shows for the same stored string. Pinned + // because it is the one place agreement looks like a bug. + const rolled = '2024-02-30'; + expect(Number.isNaN(Date.parse(rolled))).toBe(false); + expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe( + formatDate(rolled, undefined, { locale: EN }), + ); + }); +}); diff --git a/packages/core/src/utils/dataset-format.ts b/packages/core/src/utils/dataset-format.ts index f6315740c3..4c176afe22 100644 --- a/packages/core/src/utils/dataset-format.ts +++ b/packages/core/src/utils/dataset-format.ts @@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts'; import type { PercentScale } from '@objectstack/spec/data'; import { formatDisplayNumber, type DisplayNumberFormatOptions } from './number-display.js'; +import { formatDate, formatDateTime } from './date-display.js'; /** * Column metadata the analytics server returns alongside the rows — the spec's @@ -130,6 +131,71 @@ function formatNumberInLocale( return formatDisplayNumber(value, { ...options, locale }); } +/** + * ISO calendar date with NO time part — `2026-07-04`. + * + * Deliberately anchored at both ends and deliberately narrower than + * `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years + * (`2026`) and — the one that matters here — plain NUMERALS. A measure value + * is untyped by the time it reaches {@link formatMeasure}, so the shape test + * IS the type test, and a loose one would capture the numeric strings and + * counts this formatter must leave exactly as they are. + */ +const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with + * a space separator, with or without seconds/offset. Matched only as far as + * `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant + * still falls through untouched. + */ +const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/; + +/** + * Render a date-shaped measure value through the display path a list cell + * already uses for that field, or return `undefined` to say "not a date" and + * leave the caller's own fallthrough in charge. + * + * ── Why this is a ROUTE and not a formatter (objectui#7178) ── + * Every line of date rendering below happens in `./date-display.ts`, which is + * where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what + * `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips + * call. Writing the date convention HERE instead is the mistake this file's + * own header is about: objectui#4576, where a percent convention had one copy + * in a list cell and another in this function, both correct, and a German + * session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One + * date convention, one home, nothing to drift. + * + * The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch + * verbatim, because that site is already the repo's answer to this exact + * situation: an ISO string arriving with NO field type attached. A measure is + * that situation by construction — the analytics result carries a column, not + * a field definition — so the absolute locale form is taken rather than + * `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the + * value is a `date` FIELD in a row. + * + * `format` is threaded into `formatDate`'s STYLE parameter, which is the same + * mapping `DateCellRenderer` makes from `field.format` and the same one + * `plugin-dashboard`'s `recordFields` already makes for a date-shaped format. + * That vocabulary is `'short'` and `'relative'`; a date PATTERN such as + * `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the + * `format` note on {@link formatMeasure}. + */ +function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined { + if (typeof v !== 'string') return undefined; + // `Date.parse` guards both arms so a well-shaped impossible date + // (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for + // an unparseable value — it keeps falling through to `String(v)`, exactly as + // it does today. Only a value that is genuinely a date changes. + if (ISO_DATE_ONLY_RE.test(v)) { + return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale }); + } + if (ISO_DATETIME_RE.test(v)) { + return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale }); + } + return undefined; +} + /** * Format a MEASURE value. Currency comes from the field's declared `currency` * (locale-correct symbol via `Intl`), NOT from a "$" baked into the format @@ -169,6 +235,28 @@ function formatNumberInLocale( * to carry is gone, and with it the drift that duplicate produced — see the * percent note in the body, and {@link percentDisplayValue}'s promise, which * is true again. + * + * ── Date-valued measures (objectui#7178) ── + * `min` / `max` over a date or datetime field is a legitimate measure, and it + * used to render its stored value verbatim: the guard above returned + * `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile + * showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two + * lines. A date-shaped value now routes through {@link formatMeasureDate} to + * the display path list cells use; nothing about the numeric path moved. + * + * ⚠️ What `format` can and cannot say for those values, measured rather than + * assumed. The shared date path takes a named STYLE, not a date pattern: + * `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other + * string falls to its default locale-medium branch. So `format: 'short'` and + * `format: 'relative'` are honoured — the same words `DateCellRenderer` + * honours from `field.format` for the same field — while a PATTERN like + * `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and + * renders the locale default. That last part is not new behaviour introduced + * here: `plugin-dashboard`'s `recordFields` already routes a date-shaped + * `format` into the same style slot and gets the same locale default. Closing + * it would mean teaching the shared path a pattern grammar, which is a change + * to the path itself and to every list cell that reads it — not a measure + * concern, and not this card. */ export function formatMeasure( v: unknown, @@ -178,7 +266,18 @@ export function formatMeasure( locale?: string, ): string { if (v == null) return '—'; - if (typeof v !== 'number') return String(v); + if (typeof v !== 'number') { + // Ahead of the `String(v)` short-circuit, which is what made `format` dead + // on this path and printed a `min`/`max` over a datetime as a raw + // 24-character ISO string (objectui#7178). Deliberately NOT ahead of the + // `typeof` test itself: above that line the argument can be a bare number, + // and any date test generous enough to consider one would have to decide + // whether `1751612400000` is epoch milliseconds — which is how a measure + // that counts things starts rendering as a date. Numbers reach the numeral + // formatter below byte for byte as before. + const asDate = formatMeasureDate(v, format, locale); + return asDate ?? String(v); + } const decimals = format ? (format.split('.')[1]?.match(/0/g)?.length ?? 0) : undefined; diff --git a/packages/core/src/utils/date-display.ts b/packages/core/src/utils/date-display.ts new file mode 100644 index 0000000000..b924b02847 --- /dev/null +++ b/packages/core/src/utils/date-display.ts @@ -0,0 +1,162 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * date-display - the ONE date/datetime display path behind every field cell, + * grid card, gantt tooltip and dataset measure in the console. + * + * These functions are unchanged; what changed is where they live. They were + * written in `@object-ui/fields`' barrel (`packages/fields/src/index.tsx`), + * which is a React package, so `@object-ui/core` could not reach them - and + * `core`'s `utils/dataset-format.ts` is exactly the caller that needed them: + * `formatMeasure` returned `String(v)` for any non-numeric value, so a `min` / + * `max` measure over a date field rendered its raw 24-character ISO string on + * the metric tile, in chart values, in dataset table cells and in the + * metadata-admin dataset preview (objectui#7178). + * + * The fix is the move, NOT a second formatter here. That choice is the whole + * point, and it is the lesson of objectui#4576: when the percent convention + * was duplicated across this same package boundary, a list cell and a + * dashboard measure drifted apart (`1.234,5 %` beside `1.234,5%` in a German + * session) while both were "correct". `dataset-format.ts`'s own header records + * that history, and `number-display.ts` next door is the same remedy applied + * to numbers: the pure function moves DOWN into the React-free engine and the + * upper package re-exports it, so there is one home and nothing to drift. + * + * `@object-ui/fields` re-exports every symbol below under its original name, + * so `formatDate` / `formatDateTime` / `formatRelativeDate` / + * `DateDisplayOptions` keep working unchanged for `ObjectGrid`, `ObjectGantt`, + * `plugin-dashboard`'s `recordFields` and the `date` cell renderer. + * + * Pure by construction (no React, no i18n): the only ambient inputs are `Intl` + * and the clock, and the one phrase `Intl` cannot produce ("Overdue Nd") comes + * in through the INJECTED `options.t`, the same way `buildDatasetFieldHelpers` + * in `dataset-format.ts` takes `fieldLabel`. + */ + +/** Options shared by {@link formatDate} / {@link formatRelativeDate}. */ +export interface DateDisplayOptions { + dueLike?: boolean; + /** BCP-47 display locale (ADR-0053 tenant default); falls back to the runtime locale. */ + locale?: string; + /** i18n translate fn for phrases `Intl` can't produce (the "Overdue Nd" wording). */ + t?: (key: string, params?: Record) => string; +} + +/** + * Localized day-granularity relative phrase ("Tomorrow", "3 days ago", "明天", + * "3天前"), sentence-cased for locales whose `Intl` output starts lowercase. + */ +function formatRelativeDays(diffDays: number, locale?: string): string { + try { + const phrase = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diffDays, 'day'); + return phrase.charAt(0).toUpperCase() + phrase.slice(1); + } catch { + // Invalid locale tag — degrade to English rather than crash the cell. + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Tomorrow'; + if (diffDays === -1) return 'Yesterday'; + return diffDays > 0 ? `In ${diffDays} days` : `${Math.abs(diffDays)} days ago`; + } +} + +/** + * Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"), + * localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040). + * + * `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at` + * isn't overdue, only a past due/deadline-semantic field is. Non-due-like + * past dates render as plain "N days ago" instead. The overdue phrase has no + * `Intl` equivalent, so it resolves through `options.t` (key + * `fields.relativeDate.overdue`) with an English fallback. + */ +export function formatRelativeDate(value: string | Date | number, options?: DateDisplayOptions): string { + if (value === null || value === undefined || value === '') return '—'; + const date = value instanceof Date ? value : new Date(value as any); + if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; + + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const diffMs = startOfDate.getTime() - startOfToday.getTime(); + const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); + + // Beyond the ±7-day window, fall back to the absolute (already localized) form. + if (diffDays < -7 || diffDays > 7) return formatDate(date, undefined, options); + + if (diffDays < -1 && options?.dueLike) { + const absDays = Math.abs(diffDays); + const key = 'fields.relativeDate.overdue'; + const translated = options.t?.(key, { count: absDays }); + return translated && translated !== key ? translated : `Overdue ${absDays}d`; + } + return formatRelativeDays(diffDays, options?.locale); +} + +/** + * Format date value + */ +export function formatDate(value: string | Date | number, style?: string, options?: DateDisplayOptions): string { + if (value === null || value === undefined || value === '') return '—'; + const date = value instanceof Date ? value : new Date(value as any); + if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; + + if (style === 'short') { + // Compact format for mobile: "Jan 15, '24" / "1月 15, '24". + // Only the MONTH token is localized: the surrounding compact shape (day, + // apostrophe + 2-digit year) is a deliberate fixed layout for narrow + // cards, not a locale-derived one. The tag comes from `options.locale` + // like the default branch below — hardcoding `'en-US'` here made this the + // one branch that ignored a locale its caller had threaded (objectui#4272). + const month = date.toLocaleDateString(options?.locale, { month: 'short' }); + const day = date.getDate(); + const year = String(date.getFullYear()).slice(-2); + return `${month} ${day}, '${year}`; + } + + if (style === 'relative') { + return formatRelativeDate(date, options); + } + + // Default format: locale-aware human-readable. Drop the year when it + // matches the current year — Salesforce / HubSpot / Linear all do this + // because the year is rarely useful for in-progress records and the + // verbose "2026年7月21日" form crowds cards and table cells. Past- / + // future-year dates keep the year so users can disambiguate. + const isCurrentYear = date.getFullYear() === new Date().getFullYear(); + return date.toLocaleDateString(options?.locale, { + year: isCurrentYear ? undefined : 'numeric', + month: 'short', + day: 'numeric', + }); +} + +/** + * Format datetime value. + * + * `options` mirrors {@link formatDate}'s and is optional, so an existing + * caller that passes nothing keeps the exact runtime-default behavior it had. + * Before objectui#4272 the parameter did not exist at all, which meant no + * caller could localize this function however hard it tried — it always handed + * `Intl` an `undefined` tag, i.e. the MACHINE's locale, which is neither of + * the repo's two locale channels. Callers should pass the tag from + * `useDisplayLocale()`. + */ +export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string { + if (value === null || value === undefined || value === '') return '—'; + const date = value instanceof Date ? value : new Date(value as any); + if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; + + return date.toLocaleDateString(options?.locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/packages/fields/src/__tests__/date-display.reexport-identity.test.ts b/packages/fields/src/__tests__/date-display.reexport-identity.test.ts new file mode 100644 index 0000000000..24642f215f --- /dev/null +++ b/packages/fields/src/__tests__/date-display.reexport-identity.test.ts @@ -0,0 +1,61 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7178 — the down-move's safety net, and the pin that makes the + * ruling's "no second date formatter" mechanical instead of aspirational. + * + * `formatDate`, `formatDateTime`, `formatRelativeDate` and `DateDisplayOptions` + * moved from this package's barrel into `@object-ui/core`'s + * `utils/date-display.ts`, because `core`'s `formatMeasure` needed exactly this + * path and, as the React-free engine, could not import from a React package. + * This barrel re-exports them, so no consumer's import path changes. + * + * A re-export is only worth anything if it names the SAME thing, so this file + * pins IDENTITY rather than behaviour — the same shape as + * `@object-ui/i18n`'s `number-display.reexport-identity.test.ts`, written for + * the same move one type over (objectui#4576). A second copy of the date + * convention would pass every behavioural assertion in the repo and fail only + * here, and that copy is precisely what #4576 already cost the repo once, in + * percent. + * + * ── PREDICTIONS, written before the run ── + * RED before the fix, at MODULE LOAD: `@object-ui/core` exported no + * `formatDate`, so the import fails outright — a resolution failure, not an + * assertion one. The behavioural case below passes on both sides (the + * implementation is byte-identical; that is what a move means), which is + * exactly why identity is what this file asserts. + */ + +import { describe, it, expect } from 'vitest'; + +import { + formatDate as fromCore, + formatDateTime as fromCoreDateTime, + formatRelativeDate as fromCoreRelative, +} from '@object-ui/core'; +import { + formatDate as fromEntry, + formatDateTime as fromEntryDateTime, + formatRelativeDate as fromEntryRelative, +} from '../index'; + +describe('the moved date-display symbols are re-exported, not re-implemented (#7178)', () => { + it('the cell renderer\'s date path and `@object-ui/core`\'s are the SAME function object', () => { + expect(fromEntry).toBe(fromCore); + expect(fromEntryDateTime).toBe(fromCoreDateTime); + expect(fromEntryRelative).toBe(fromCoreRelative); + }); + + it('still formats the way it did before the move', () => { + // A non-current year, so the "drop the year" branch is not in play. + expect(fromEntry('2024-07-04', undefined, { locale: 'en-US' })).toBe('Jul 4, 2024'); + expect(fromEntry('2024-07-04', 'short', { locale: 'en-US' })).toBe("Jul 4, '24"); + expect(fromEntry('', undefined, { locale: 'en-US' })).toBe('—'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index a6bc6c9e35..7799271d82 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types'; -import { ComponentRegistry, percentDisplayValue, getRecordDisplayName, humanizeLabel, isMissingForRequired, type ComponentMeta } from '@object-ui/core'; +import { ComponentRegistry, percentDisplayValue, getRecordDisplayName, humanizeLabel, isMissingForRequired, formatDate, formatDateTime, formatRelativeDate, type ComponentMeta, type DateDisplayOptions } from '@object-ui/core'; // The platform's own value-shape contract, asked rather than restated // (objectui#6744). See `locationStoredValueSchemaFor` below for why this is a // runtime import in the barrel and not a hand-written coordinate range. @@ -577,127 +577,20 @@ export function formatPercent(value: number, precision: number = 0, locale?: str */ export { humanizeLabel }; -/** Options shared by {@link formatDate} / {@link formatRelativeDate}. */ -export interface DateDisplayOptions { - dueLike?: boolean; - /** BCP-47 display locale (ADR-0053 tenant default); falls back to the runtime locale. */ - locale?: string; - /** i18n translate fn for phrases `Intl` can't produce (the "Overdue Nd" wording). */ - t?: (key: string, params?: Record) => string; -} - -/** - * Localized day-granularity relative phrase ("Tomorrow", "3 days ago", "明天", - * "3天前"), sentence-cased for locales whose `Intl` output starts lowercase. - */ -function formatRelativeDays(diffDays: number, locale?: string): string { - try { - const phrase = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diffDays, 'day'); - return phrase.charAt(0).toUpperCase() + phrase.slice(1); - } catch { - // Invalid locale tag — degrade to English rather than crash the cell. - if (diffDays === 0) return 'Today'; - if (diffDays === 1) return 'Tomorrow'; - if (diffDays === -1) return 'Yesterday'; - return diffDays > 0 ? `In ${diffDays} days` : `${Math.abs(diffDays)} days ago`; - } -} - -/** - * Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"), - * localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040). - * - * `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at` - * isn't overdue, only a past due/deadline-semantic field is. Non-due-like - * past dates render as plain "N days ago" instead. The overdue phrase has no - * `Intl` equivalent, so it resolves through `options.t` (key - * `fields.relativeDate.overdue`) with an English fallback. - */ -export function formatRelativeDate(value: string | Date | number, options?: DateDisplayOptions): string { - if (value === null || value === undefined || value === '') return '—'; - const date = value instanceof Date ? value : new Date(value as any); - if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; - - const now = new Date(); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - const diffMs = startOfDate.getTime() - startOfToday.getTime(); - const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); - - // Beyond the ±7-day window, fall back to the absolute (already localized) form. - if (diffDays < -7 || diffDays > 7) return formatDate(date, undefined, options); - - if (diffDays < -1 && options?.dueLike) { - const absDays = Math.abs(diffDays); - const key = 'fields.relativeDate.overdue'; - const translated = options.t?.(key, { count: absDays }); - return translated && translated !== key ? translated : `Overdue ${absDays}d`; - } - return formatRelativeDays(diffDays, options?.locale); -} - -/** - * Format date value - */ -export function formatDate(value: string | Date | number, style?: string, options?: DateDisplayOptions): string { - if (value === null || value === undefined || value === '') return '—'; - const date = value instanceof Date ? value : new Date(value as any); - if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; - - if (style === 'short') { - // Compact format for mobile: "Jan 15, '24" / "1月 15, '24". - // Only the MONTH token is localized: the surrounding compact shape (day, - // apostrophe + 2-digit year) is a deliberate fixed layout for narrow - // cards, not a locale-derived one. The tag comes from `options.locale` - // like the default branch below — hardcoding `'en-US'` here made this the - // one branch that ignored a locale its caller had threaded (objectui#4272). - const month = date.toLocaleDateString(options?.locale, { month: 'short' }); - const day = date.getDate(); - const year = String(date.getFullYear()).slice(-2); - return `${month} ${day}, '${year}`; - } - - if (style === 'relative') { - return formatRelativeDate(date, options); - } - - // Default format: locale-aware human-readable. Drop the year when it - // matches the current year — Salesforce / HubSpot / Linear all do this - // because the year is rarely useful for in-progress records and the - // verbose "2026年7月21日" form crowds cards and table cells. Past- / - // future-year dates keep the year so users can disambiguate. - const isCurrentYear = date.getFullYear() === new Date().getFullYear(); - return date.toLocaleDateString(options?.locale, { - year: isCurrentYear ? undefined : 'numeric', - month: 'short', - day: 'numeric', - }); -} - /** - * Format datetime value. + * The date/datetime display path - `@object-ui/core`'s `utils/date-display.ts` + * (objectui#7178), re-exported here under its original names so every existing + * consumer of `@object-ui/fields` is unchanged. * - * `options` mirrors {@link formatDate}'s and is optional, so an existing - * caller that passes nothing keeps the exact runtime-default behavior it had. - * Before objectui#4272 the parameter did not exist at all, which meant no - * caller could localize this function however hard it tried — it always handed - * `Intl` an `undefined` tag, i.e. the MACHINE's locale, which is neither of - * the repo's two locale channels. Callers should pass the tag from - * `useDisplayLocale()`. - */ -export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string { - if (value === null || value === undefined || value === '') return '—'; - const date = value instanceof Date ? value : new Date(value as any); - if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; - - return date.toLocaleDateString(options?.locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} + * It moved for the reason `formatDisplayNumber` moved in objectui#4576: this + * barrel is a React package, and the React-free engine could not import from + * it. `core`'s `formatMeasure` needed this exact path - a dataset measure over + * a date field was rendering its raw ISO string - and the alternative to + * moving was a second date convention in `dataset-format.ts`, which is the + * drift #4576 already paid for once with percent. + */ +export { formatDate, formatDateTime, formatRelativeDate }; +export type { DateDisplayOptions }; /** * Single-line cell value with a working ellipsis and a full-text fallback. diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx new file mode 100644 index 0000000000..2322c47bd3 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.dateMeasure.test.tsx @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7178 — a `min` / `max` measure over a datetime renders as a date on + * the surfaces `DatasetWidget` owns, through the path a list cell uses. + * + * The ruling's pin, in its own words: "a `min` measure over a datetime renders + * through the same path a list cell uses for that field, on the metric tile and + * in the dataset table, and a numeric measure is byte-identical before and + * after." The two rendered surfaces are here; the argument-level sweep is in + * `@object-ui/core`'s `dataset-format.date.test.ts`. + * + * ── Why the expectations are DERIVED and not literal ──────────────────────── + * Every expected date string below is computed by calling `formatDateTime` + * FROM `@object-ui/fields` — the module the `date` / `datetime` cell renderers + * call, imported by the path they import it by. So these cases assert "the tile + * shows what a list cell would show", which is the pin, rather than "the tile + * shows the string I typed", which would pass just as well against a second + * date convention living in `dataset-format.ts` — the objectui#4576 failure the + * ruling forbids. If the two ever diverge, this file goes red at the divergence + * instead of quietly blessing it. + * + * ── Directions, predicted in writing BEFORE the run ───────────────────────── + * both date cases RED pre-fix — `formatMeasure` returned `String(v)` + * for a non-numeric value, so the tile rendered the + * raw 24-character ISO string. + * ⭐ both numeric cases GREEN on both sides — the must-not-change guard. + * + * ⚠️ The objectui#4487 flake lives in the sibling `DatasetWidget.test.tsx`. + * This file mounts the same component, so a red here is verified locally and + * re-run before being owned. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider, LocalizationProvider, type LocalizationValue } from '@object-ui/i18n'; +// The list cell's own date path, by the list cell's own import path. +import { formatDateTime } from '@object-ui/fields'; +import { DatasetWidget } from '../DatasetWidget'; + +afterEach(cleanup); + +type Row = Record; + +const makeSource = (rows: Row[], fields?: Row[]) => ({ + queryDataset: vi.fn(async () => ({ rows, ...(fields ? { fields } : {}) })), +}); + +function renderIn(locale: string, widget: Row, dataSource: unknown) { + const value: LocalizationValue = { locale }; + return render( + + + + + , + ); +} + +/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */ +const OLDEST = '2024-07-04T07:00:00.000Z'; +const EN = 'en-US'; +const DE = 'de-DE'; + +/** The card's own repro: `{ aggregate: 'min', field: 'last_update_at' }`. */ +const DATE_FIELDS = [{ name: 'oldest_touch', type: 'datetime', label: 'Oldest touch' }]; + +describe('DatasetWidget metric tile renders a date measure as a date (objectui#7178)', () => { + const METRIC = { type: 'metric', dataset: 'tasks', values: ['oldest_touch'] }; + + it('shows what a list cell shows, not the raw ISO string', async () => { + renderIn(EN, METRIC, makeSource([{ oldest_touch: OLDEST }], DATE_FIELDS)); + const expected = formatDateTime(OLDEST, { locale: EN }); + expect(await screen.findByText(expected)).toBeInTheDocument(); + expect(screen.queryByText(OLDEST)).not.toBeInTheDocument(); + }); + + it('follows the display locale, through that same path', async () => { + renderIn(DE, METRIC, makeSource([{ oldest_touch: OLDEST }], DATE_FIELDS)); + const expected = formatDateTime(OLDEST, { locale: DE }); + expect(await screen.findByText(expected)).toBeInTheDocument(); + }); + + it('⭐ leaves a numeric measure byte-identical (must-not-change)', async () => { + renderIn(EN, { type: 'metric', dataset: 'sales', values: ['revenue'] }, + makeSource([{ revenue: 1234.5 }], [{ name: 'revenue', type: 'number', label: 'Revenue', format: '0.0' }])); + expect(await screen.findByText('1,234.5')).toBeInTheDocument(); + }); +}); + +describe('DatasetWidget dataset table renders a date measure as a date (objectui#7178)', () => { + const TABLE = { type: 'table', dataset: 'tasks', dimensions: ['owner'], values: ['oldest_touch'] }; + const FIELDS = [{ name: 'owner', type: 'string', label: 'Owner' }, ...DATE_FIELDS]; + + it('shows what a list cell shows in the measure cell', async () => { + renderIn(EN, TABLE, makeSource([{ owner: 'Ada', oldest_touch: OLDEST }], FIELDS)); + const expected = formatDateTime(OLDEST, { locale: EN }); + await waitFor(() => expect(screen.getByText(expected)).toBeInTheDocument()); + expect(screen.queryByText(OLDEST)).not.toBeInTheDocument(); + }); + + it('⭐ leaves a numeric measure cell byte-identical (must-not-change)', async () => { + renderIn(EN, { type: 'table', dataset: 'sales', dimensions: ['owner'], values: ['revenue'] }, + makeSource([{ owner: 'Ada', revenue: 1234.5 }], [ + { name: 'owner', type: 'string', label: 'Owner' }, + { name: 'revenue', type: 'number', label: 'Revenue', format: '0.0' }, + ])); + await waitFor(() => expect(screen.getByText('1,234.5')).toBeInTheDocument()); + }); +});