diff --git a/.changeset/25884-grid-date-absolute.md b/.changeset/25884-grid-date-absolute.md
new file mode 100644
index 00000000000..c3bc64d71db
--- /dev/null
+++ b/.changeset/25884-grid-date-absolute.md
@@ -0,0 +1,18 @@
+---
+'@object-ui/fields': patch
+'@object-ui/plugin-grid': patch
+---
+
+fix(fields,plugin-grid): a grid's `date` column reads in one absolute face when its field declares no `format`
+
+Clause-②: no
+Metadata-protocol: none
+
+A list grid's `date` column used to mix three faces in one column, for example "Yesterday · 3 days ago · Sep 25 · Sep 20, 2025". The cell's default face was `'relative'`, which writes a relative phrase within ±7 days of today and an absolute date outside that window. A sorted column did not look sorted, and "Yesterday" in an expiry column read as a status.
+
+- **Default change for an undeclared `format` (visible from metadata).** In a grid column, a `date` field that declares no `format` now renders the locale's short absolute date: `Oct 25`, with the year added when the date is not in the current year. Every row of the column uses this one face. The group headings of a grid grouped by that field change with it, so a heading still reads like the cell under it.
+- **The relative phrase moves to the tooltip.** It is shown on hover ("Yesterday", "In 2 days", or "Overdue 3d" on a declared `dueLike` field). A date outside the ±7-day window has no relative phrase, so it keeps its ISO `title` as before. A past declared deadline is still red.
+- **A declared `format` still wins.** A field that declares `format: 'relative'` keeps the relative face in the grid. This is how an author who wants relative wording in a list keeps it.
+- **Other surfaces are unchanged.** The record detail body, the header highlights strip, kanban and gallery cards, the tree, a record page's related list and the grid's record panel render `date` cells as before. `DateCellRenderer` paints the absolute default only when its host passes the new `inGridColumn` prop, and `ObjectGrid` passes it on every column it builds. On mobile, a grid card draws its title through the column's cell, so a `date` title reads absolute there too; the card's other date slots were already absolute. `datetime` cells are not affected, because their default face was already absolute (`compact`).
+
+What to do: nothing. To keep the relative wording in a grid column, declare `format: 'relative'` on the field.
diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx
index 2ac4abd3f41..04cd12d255d 100644
--- a/ui/packages/fields/src/index.tsx
+++ b/ui/packages/fields/src/index.tsx
@@ -425,6 +425,23 @@ export interface CellRendererProps {
* the default face that drops the current year.
*/
alwaysShowYear?: boolean;
+ /**
+ * The cell is one of a grid COLUMN's cells, read against the cells above
+ * and below it (objectstack#25884). Read by the `date` cell only.
+ *
+ * A `date` field that declares no `format` then paints `formatDate`'s
+ * absolute default face instead of `'relative'`, and the relative phrase
+ * becomes the cell's tooltip. The relative face is absolute outside a ±7-day
+ * window, so as a column default it mixed faces in one column ("Yesterday",
+ * "Sep 25", "Sep 20, 2025"), and a sorted column did not look sorted. The
+ * ruling on the card: absolute in a sortable column, relative at most as a
+ * tooltip. A declared `format` still wins, `'relative'` included.
+ *
+ * `ObjectGrid` passes it on every column cell it builds. Absent or `false`
+ * changes nothing, so the record detail body, the highlights strip and the
+ * card surfaces keep the relative default.
+ */
+ inGridColumn?: boolean;
}
// `coerceToSafeValue` lives in `./coerceToSafeValue.ts` (objectui#8580) so
@@ -1144,7 +1161,7 @@ function isOverdueInstant(date: Date, dueLike: boolean): boolean {
/**
* Date field cell renderer
*/
-export function DateCellRenderer({ value, field, deadlineSettled, alwaysShowYear }: CellRendererProps): React.ReactElement {
+export function DateCellRenderer({ value, field, deadlineSettled, alwaysShowYear, inGridColumn }: CellRendererProps): React.ReactElement {
// `useDisplayLocale()`, NOT `useLocalization().locale` (objectui#4468). The
// raw tenant locale is `undefined` on any workspace that never configured
// one, and `Intl` reads the MACHINE's locale from `undefined` — so a `zh`
@@ -1205,19 +1222,34 @@ export function DateCellRenderer({ value, field, deadlineSettled, alwaysShowYear
const date = new Date(safe as string | number);
const dateField = field as any;
- const style = dateField.format || 'relative';
+ const declaredStyle: string | undefined = dateField.format || undefined;
+ // objectstack#25884 — in a grid column the undeclared default is the
+ // absolute face (`formatDate`'s default, `undefined`), one face per column;
+ // everywhere else it stays `'relative'`. ⚠️ `ObjectGrid`'s
+ // `temporalGroupHeading` restates this selection for its group headings,
+ // and `groupDateHeading-13234.test.tsx` holds the two equal.
+ const style = declaredStyle ?? (inGridColumn ? undefined : 'relative');
// Both halves of the affordance come from the shared reads above, so the
// `datetime` sibling one function down answers this question identically
// instead of carrying a second copy (objectui#8958).
const dueLike = resolveDueLike(field, deadlineSettled);
- const formatted = formatDate(safe as string | Date, style, { dueLike, locale, t, alwaysShowYear });
+ const options = { dueLike, locale, t, alwaysShowYear };
+ const formatted = formatDate(safe as string | Date, style, options);
const isOverdue = isOverdueInstant(displayDate, dueLike);
+ // The relative phrase the grid default no longer paints ("Yesterday",
+ // "Overdue 3d") is the tooltip instead. Outside the ±7-day window the phrase
+ // IS the absolute face, which says nothing new, so the ISO `title` stays.
+ const relative = inGridColumn && declaredStyle === undefined
+ ? formatDate(safe as string | Date, 'relative', options)
+ : undefined;
+ const title = relative !== undefined && relative !== formatted ? relative : date.toISOString();
+
return (
{formatted}
diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx
index 6ae0aa9206b..1cdbb5f3d09 100644
--- a/ui/packages/plugin-grid/src/ObjectGrid.tsx
+++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx
@@ -636,7 +636,9 @@ interface TemporalGroupFace {
* empty key, the stored text otherwise.
*
* The formatting CALLS are the cells' own, with the cells' arguments:
- * `DateCellRenderer` paints `formatDate(value, format || 'relative', …)`;
+ * `DateCellRenderer` in a grid column (`inGridColumn`, objectstack#25884)
+ * paints `formatDate(value, format || undefined, …)`, the absolute default
+ * face when no `format` is declared;
* `DateTimeCellRenderer` paints `formatRelativeDate` for `'relative'`, the
* compact face for no format and for `'short'`, and `formatDateTime` with the
* authored word otherwise (the compact face's string is the cell's two halves
@@ -661,7 +663,7 @@ function temporalGroupHeading(
if (!key) return undefined;
// The cells' validity guard, the shared parse step.
if (isNaN(toDisplayDate(key).getTime())) return undefined;
- if (face.rendererType === 'date') return formatDate(key, face.format || 'relative', { locale });
+ if (face.rendererType === 'date') return formatDate(key, face.format || undefined, { locale });
// A `datetime` cell renders an INSTANT, from the engine's parse.
const instant = new Date(key);
const style = !face.format || face.format === 'short' ? 'compact' : face.format;
@@ -2707,6 +2709,12 @@ export const ObjectGrid: React.FC = ({
// one helper in `@object-ui/core`, with the scope and relation collapse
// this grid's conditional formatting uses; a field that is not a declared
// deadline answers `false` before any predicate runs.
+ //
+ // objectstack#25884 — every column cell built below also carries
+ // `inGridColumn`, so a `date` field with no declared `format` reads in
+ // one absolute face down the column (`CellRendererProps.inGridColumn`).
+ // The record panel and the mobile card's slots draw their cells without
+ // it, outside this function.
const settledFor = (field: unknown, row: Record | undefined): boolean =>
isDeadlineSettled(field as Parameters[0], row, { scope: predicateScope, fields: objectFields });
// Map field type to column header icon (Airtable-style)
@@ -3030,7 +3038,7 @@ export const ObjectGrid: React.FC = ({
// Both link and action: link takes priority for navigation, action executes on secondary interaction
cellRenderer = (value: any, row: any) => {
const displayContent = pointerFace?.(row) ?? (LinkContentRenderer
- ?
+ ?
: (value != null && value !== '' ? String(value) : ));
return (
= ({
// Link column: clicking navigates to the record detail
cellRenderer = (value: any, row: any) => {
const displayContent = pointerFace?.(row) ?? (LinkContentRenderer
- ?
+ ?
: (value != null && value !== '' ? String(value) : ));
return (
= ({
// `dependentValues` for a cell whose value only means something
// beside a sibling column (objectui#12613).
cellRenderer = (value: any, row: any) => (
- pointerFace?.(row) ??
+ pointerFace?.(row) ??
);
} else {
// Default renderer with empty value handling
@@ -3269,7 +3277,7 @@ export const ObjectGrid: React.FC = ({
if (isPrimaryField && LinkContentRenderer) {
cellRenderer = (value: any, row: any) => {
- const displayContent = pointerFace?.(row) ?? ;
+ const displayContent = pointerFace?.(row) ?? ;
return (
= ({
);
} else if (CellRenderer) {
- cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? ;
+ cellRenderer = (value: any, row: any) => pointerFace?.(row) ?? ;
}
return {
@@ -3458,7 +3466,7 @@ export const ObjectGrid: React.FC = ({
...(schema.showColumnTypeIcons && rendererType && { headerIcon: getTypeIcon(rendererType) }),
...(inferredAlign && { align: inferredAlign }),
...(CellRenderer && {
- cell: (value: any, row: any) => pointerFace?.(row) ?? ,
+ cell: (value: any, row: any) => pointerFace?.(row) ?? ,
}),
sortable: fieldDef?.sortable !== false,
};
@@ -3566,7 +3574,7 @@ export const ObjectGrid: React.FC = ({
// Aligned on the RENDERER key, like paths A-C: a `text` column with
// `format: 'currency'` renders as currency, so it aligns as currency.
...(!!rendererType && numericTypes.includes(rendererType) && { align: 'right' as const }),
- cell: (value: any, row: any) => pointerFace?.(row) ?? ,
+ cell: (value: any, row: any) => pointerFace?.(row) ?? ,
sortable: field.sortable !== false,
});
});
diff --git a/ui/packages/plugin-grid/src/__tests__/ObjectGrid.dateColumnOneFace-25884.test.tsx b/ui/packages/plugin-grid/src/__tests__/ObjectGrid.dateColumnOneFace-25884.test.tsx
new file mode 100644
index 00000000000..817b699cf72
--- /dev/null
+++ b/ui/packages/plugin-grid/src/__tests__/ObjectGrid.dateColumnOneFace-25884.test.tsx
@@ -0,0 +1,194 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectstack#25884 — a grid's `date` column reads in ONE face, the absolute
+ * short date, when its field declares no `format`.
+ *
+ * Measured on a CRM's quote list sorted by its quote date: the column read
+ * "Yesterday · 3 days ago · 7 days ago · Sep 25 · Sep 10", because the cell's
+ * default face was `'relative'`, and `formatRelativeDate` is relative only
+ * inside a ±7-day window and absolute outside it. Three faces in one column:
+ * the eye cannot scan it and a sorted column does not look sorted. The ruling
+ * on the card: absolute in a sortable column, relative at most as a tooltip.
+ *
+ * Every column path of the real `ObjectGrid` is driven (A object `columns`,
+ * B string `columns`, C the inline-data projection, D the object-schema
+ * default columns), because each builds its own cell and each must say the
+ * cell sits in a column.
+ *
+ * CONTROLS: a field that DECLARES `format: 'relative'` keeps the relative face
+ * in the same grid, and a declared deadline keeps its red, its overdue
+ * wording moving to the cell's tooltip.
+ */
+import React from 'react';
+import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest';
+import {
+ assertNoOtherNetworkEscape,
+ installRecordSecurityExplainDouble,
+} from '@object-ui/test-support';
+import { render, screen, cleanup, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { registerAllFields, formatDate } from '@object-ui/fields';
+import { ActionProvider } from '@object-ui/react';
+import { ObjectGrid } from '../ObjectGrid';
+
+registerAllFields();
+
+beforeAll(() => {
+ if (!Element.prototype.hasPointerCapture) {
+ Element.prototype.hasPointerCapture = vi.fn(() => false) as any;
+ }
+ if (!Element.prototype.scrollIntoView) {
+ Element.prototype.scrollIntoView = vi.fn() as any;
+ }
+});
+
+beforeEach(() => installRecordSecurityExplainDouble(vi));
+afterEach(() => {
+ assertNoOtherNetworkEscape(expect);
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+/** A calendar day `offset` days from today, as a `date` field stores it. */
+function storedDay(offset: number): string {
+ const d = new Date();
+ d.setDate(d.getDate() + offset);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+// Four days inside the relative window and two outside it, one of them in a
+// past year, so every branch of the old default is read in one column.
+const OFFSETS = [-1, -3, -6, 2, -40, -400];
+
+const QUOTE_SCHEMA = {
+ name: 'quote',
+ label: 'Quote',
+ fields: {
+ title: { type: 'text', label: 'Title' },
+ quoted_on: { type: 'date', label: 'Quoted on' },
+ expires_on: { type: 'date', label: 'Expires on', dueLike: true },
+ followed_up: { type: 'date', label: 'Followed up', format: 'relative' },
+ },
+};
+
+const ROWS = OFFSETS.map((offset, i) => ({
+ id: `q${i}`,
+ title: `Quote ${i}`,
+ quoted_on: storedDay(offset),
+ expires_on: storedDay(offset),
+ followed_up: storedDay(offset),
+}));
+
+const NAMES = ['title', 'quoted_on', 'expires_on', 'followed_up'];
+
+function makeDataSource() {
+ return {
+ find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })),
+ getObjectSchema: vi.fn(async () => QUOTE_SCHEMA),
+ } as any;
+}
+
+function renderGrid(schemaOverrides: Record, props: Record = {}) {
+ render(
+
+
+ ,
+ );
+}
+
+/** The column's index among the header cells, by its label. */
+function columnIndex(label: string): number {
+ const headers = [...document.querySelectorAll('thead tr th')].map((th) => th.textContent ?? '');
+ const index = headers.findIndex((text) => text.includes(label));
+ if (index < 0) throw new Error(`no column headed ${label}; headers: ${JSON.stringify(headers)}`);
+ return index;
+}
+
+/** The column's cell for each row, top to bottom, keyed by the row's title. */
+function columnCells(label: string): Map {
+ const index = columnIndex(label);
+ const cells = new Map();
+ for (const tr of document.querySelectorAll('tbody tr')) {
+ const tds = tr.querySelectorAll('td');
+ const title = [...tds].map((td) => td.textContent ?? '').find((text) => /^Quote \d$/.test(text));
+ if (title && tds.length > index) cells.set(title, tds[index] as HTMLElement);
+ }
+ return cells;
+}
+
+// Every phrase the relative face writes in `en`, inside its window.
+const RELATIVE_WORDING = /ago|yesterday|today|tomorrow|^in \d|overdue/i;
+
+async function settled() {
+ await waitFor(() => expect(screen.getAllByText(/^Quote \d$/)).toHaveLength(ROWS.length));
+ await waitFor(() => expect(columnCells('Quoted on').size).toBe(ROWS.length));
+}
+
+function expectOneAbsoluteFace(label: string, field: 'quoted_on' | 'expires_on') {
+ const cells = columnCells(label);
+ ROWS.forEach((row, i) => {
+ const text = cells.get(`Quote ${i}`)?.textContent ?? '';
+ expect(text).not.toMatch(RELATIVE_WORDING);
+ expect(text).toBe(formatDate(row[field], undefined, { locale: 'en-US' }));
+ });
+}
+
+async function expectGridFaces() {
+ await settled();
+ // The defect: one face per column, whatever the distance from today.
+ expectOneAbsoluteFace('Quoted on', 'quoted_on');
+ expectOneAbsoluteFace('Expires on', 'expires_on');
+
+ // The deadline keeps its red on the past rows, and the overdue wording the
+ // cell no longer paints is its tooltip.
+ const expires = columnCells('Expires on');
+ const threeDaysPast = expires.get('Quote 1')!.querySelector('span.tabular-nums')!;
+ expect(threeDaysPast.className).toContain('text-red-600');
+ expect(threeDaysPast.getAttribute('title')).toBe('Overdue 3d');
+ const inTwoDays = expires.get('Quote 3')!.querySelector('span.tabular-nums')!;
+ expect(inTwoDays.className).not.toContain('text-red-600');
+ expect(inTwoDays.getAttribute('title')).toBe('In 2 days');
+
+ // An undeclared date's relative phrase is its tooltip too.
+ const yesterday = columnCells('Quoted on').get('Quote 0')!.querySelector('span.tabular-nums')!;
+ expect(yesterday.getAttribute('title')).toBe('Yesterday');
+
+ // CONTROL: a declared `format: 'relative'` keeps the relative face.
+ const followed = columnCells('Followed up');
+ expect(followed.get('Quote 0')?.textContent).toBe('Yesterday');
+ expect(followed.get('Quote 1')?.textContent).toBe('3 days ago');
+}
+
+describe('objectstack#25884 — a grid date column reads in one absolute face', () => {
+ it('A: object `columns`', async () => {
+ renderGrid({ columns: NAMES.map((field) => ({ field })), data: { provider: 'value', items: ROWS } });
+ await expectGridFaces();
+ });
+
+ it('B: string `columns`', async () => {
+ renderGrid({ columns: NAMES, data: { provider: 'value', items: ROWS } });
+ await expectGridFaces();
+ });
+
+ it('C: inline-data projection (`fields`, no `columns`)', async () => {
+ renderGrid({ fields: NAMES, data: { provider: 'value', items: ROWS } });
+ await expectGridFaces();
+ });
+
+ it('D: object-schema default columns (rows handed down)', async () => {
+ renderGrid({}, { data: ROWS });
+ await expectGridFaces();
+ });
+});
diff --git a/ui/packages/plugin-grid/src/__tests__/ObjectGrid.deadlineSettled-11815.test.tsx b/ui/packages/plugin-grid/src/__tests__/ObjectGrid.deadlineSettled-11815.test.tsx
index d6d5c91ae86..21cd5e57132 100644
--- a/ui/packages/plugin-grid/src/__tests__/ObjectGrid.deadlineSettled-11815.test.tsx
+++ b/ui/packages/plugin-grid/src/__tests__/ObjectGrid.deadlineSettled-11815.test.tsx
@@ -13,6 +13,9 @@
* row's reads "Overdue Nd" in red (ruling D). The projection asks for the
* `settledWhen` operand when no column shows it.
*
+ * objectstack#25884 — a grid column paints the absolute date, so the wording
+ * half of that verdict is read from the cell's tooltip; the red is on the cell.
+ *
* The deadline is named `deliver_by` on purpose: no retired name guess can
* paint it, so only the declaration can.
*/
@@ -22,9 +25,9 @@ import {
assertNoOtherNetworkEscape,
installRecordSecurityExplainDouble,
} from '@object-ui/test-support';
-import { render, screen, cleanup, waitFor } from '@testing-library/react';
+import { render, cleanup, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
-import { registerAllFields } from '@object-ui/fields';
+import { registerAllFields, formatDate } from '@object-ui/fields';
import { ActionProvider } from '@object-ui/react';
import { ObjectGrid } from '../ObjectGrid';
@@ -93,17 +96,30 @@ function renderGrid(schemaOverrides: Record, props: Record screen.getByText(text).closest('span.tabular-nums')?.className.includes('text-red-600');
+/**
+ * The date cells whose tooltip reads `title`. objectstack#25884: a grid
+ * column paints the absolute date, and the relative phrase ("Overdue 5d",
+ * "5 days ago") the cell used to paint is its tooltip, so the per-row
+ * verdict is read there; the red stays on the cell.
+ */
+const cellsTitled = (title: string) =>
+ [...document.querySelectorAll('span.tabular-nums')].filter((el) => el.getAttribute('title') === title);
+const absolute = (day: string) => formatDate(day, undefined, { locale: 'en-US' });
/** One overdue (open row) and one neutral (done row) deadline, and the undeclared control. */
async function expectOpenOverdueDoneNeutral() {
- await waitFor(() => expect(screen.getByText('Overdue 5d')).toBeInTheDocument());
- expect(screen.getAllByText('Overdue 5d')).toHaveLength(1);
- expect(isRed('Overdue 5d')).toBe(true);
- expect(screen.getAllByText('5 days ago')).toHaveLength(1);
- expect(isRed('5 days ago')).toBe(false);
+ await waitFor(() => expect(cellsTitled('Overdue 5d')).toHaveLength(1));
+ const [overdue] = cellsTitled('Overdue 5d');
+ expect(overdue.className).toContain('text-red-600');
+ expect(overdue.textContent).toBe(absolute(daysAgo(5)));
+ const neutral = cellsTitled('5 days ago');
+ expect(neutral).toHaveLength(1);
+ expect(neutral[0].className).not.toContain('text-red-600');
+ expect(neutral[0].textContent).toBe(absolute(daysAgo(5)));
// CONTROL: an undeclared past date reads neutral on both rows.
- expect(screen.getAllByText('4 days ago')).toHaveLength(2);
+ const undeclared = cellsTitled('4 days ago');
+ expect(undeclared).toHaveLength(2);
+ for (const cell of undeclared) expect(cell.className).not.toContain('text-red-600');
}
const NAMES = ['title', 'deliver_by', 'started_on'];
@@ -150,8 +166,8 @@ describe('objectui#11815 — the projection carries what settledWhen reads', ()
const selects = ds.find.mock.calls.map(([, params]: any[]) => params?.$select);
expect(selects.length).toBeGreaterThan(0);
for (const sel of selects) expect(sel).toEqual(expect.arrayContaining(['id', 'title', 'deliver_by', 'status']));
- await waitFor(() => expect(screen.getByText('Overdue 5d')).toBeInTheDocument());
- expect(screen.getAllByText('5 days ago')).toHaveLength(1);
+ await waitFor(() => expect(cellsTitled('Overdue 5d')).toHaveLength(1));
+ expect(cellsTitled('5 days ago')).toHaveLength(1);
});
it('CONTROL — an undeclared shown date pulls nothing extra into the projection', async () => {