Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/11815-core-deadline-settled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/core': minor
---

New `isDeadlineSettled(field, row, { scope, fields })` (objectui#11815). It answers whether a declared deadline is settled for one row: `true` only when the field declares `dueLike: true` and a `settledWhen` predicate that holds for the row, evaluated with `evalRowPredicate` (the row bound as `record`). A field that is not a declared deadline, a field with no `settledWhen`, and a predicate that fails to evaluate all answer `false`. Every view that draws a date cell beside a record uses it to set the cell's `deadlineSettled`.
5 changes: 5 additions & 0 deletions .changeset/11815-detail-settled-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-detail': minor
---

The record page and related lists honour a deadline's `settledWhen` (objectui#11815). The details section, the highlights strip and each related-list row judge a declared deadline against their own record, so a done record's past due date reads neutral while an open one reads "Overdue Nd" in red. A related list asks for the fields the predicate reads when no column shows them. The deadline comes from the object field only: a `dueLike` written on a detail-view entry is ignored.
8 changes: 8 additions & 0 deletions .changeset/11815-fields-declared-overdue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/fields': minor
---

A date reads overdue only when its field declares it a deadline, and never once the deadline is settled for the record (objectui#11815).

- **Declared only.** The `date` and `datetime` cells paint the "Overdue Nd" wording and the red colour only for a field that declares `dueLike: true`. The field-name convention that used to turn the affordance on by itself (`due_date`, `deadline`, `expires_at`, `sla_at`, …) is retired: an undeclared date in the past now reads as any past date does, for example "6 days ago". To keep the affordance on such a field, declare `dueLike: true` on it.
- **Settled.** `CellRendererProps` gains an optional `deadlineSettled` boolean. While it is `true`, both cells paint no overdue wording and no red, and read the neutral relative phrase instead. Absent or `false` changes nothing. The host computes it with `isDeadlineSettled` from `@object-ui/core`.
5 changes: 5 additions & 0 deletions .changeset/11815-grid-settled-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-grid': minor
---

A done row's past deadline no longer reads overdue in the grid (objectui#11815). Every column shape — object columns, name columns, inline-row fields and the object's default columns — now carries the field's `dueLike` and `settledWhen`, and each cell is told whether its row's deadline is settled. Before, three of the four column shapes dropped `dueLike`, so a declared deadline only read overdue when its name happened to look like one. When the grid fetches its own rows, it also asks for the fields a shown deadline's `settledWhen` reads, so a grid showing only Title and Due Date still judges each row correctly.
5 changes: 5 additions & 0 deletions .changeset/11815-kanban-settled-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-kanban': minor
---

A kanban card's declared deadline reads neutral once the card's record settles it through the field's `settledWhen`, and "Overdue Nd" in red otherwise (objectui#11815).
5 changes: 5 additions & 0 deletions .changeset/11815-list-settled-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-list': minor
---

Gallery cards and the compact list honour a deadline's `settledWhen` (objectui#11815): a done record's past due date reads neutral, an open one's reads "Overdue Nd" in red. Gallery cards now also read the field's declared `dueLike`, so a declared deadline reads overdue there at all.
5 changes: 5 additions & 0 deletions .changeset/11815-tree-settled-deadline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-tree': minor
---

A tree row's declared deadline reads neutral once the row's record settles it through the field's `settledWhen`, and "Overdue Nd" in red otherwise (objectui#11815).
5 changes: 5 additions & 0 deletions .changeset/11815-types-view-due-like-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/types': minor
---

`DetailViewField.dueLike` and its `DetailViewFieldSchema` row are removed, with no alias (objectui#11815). A deadline is declared on the object's field metadata — `DateFieldMetadata.dueLike` / `DateTimeFieldMetadata.dueLike` and their partner `settledWhen`, by reference to `@objectstack/spec` — and the detail view reads it from there. A detail-view entry that still writes `dueLike` is a compile error; the zod mirror now drops the key as it drops any unknown one. Fix: delete the key from the view entry and declare `dueLike: true` on the object field.
49 changes: 11 additions & 38 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1271,46 +1271,19 @@ An enhanced detail view for a single record with sections, tabs and navigation.
> so author it on a record page. Placed anywhere it cannot resolve a parent id
> it scopes to nothing and renders an empty list.

> **Due/deadline fields: `dueLike`.** A field entry — a `DetailViewField`, in
> `fields` or in a section's `fields` — may mark a `date` / `datetime` field as
> due/deadline-semantic. It is the same key, with the same meaning, that the
> [Date Field](/docs/fields/date) and [DateTime Field](/docs/fields/datetime)
> carry in their own object metadata: the authored entry is handed to the same
> cell renderer, so the affordance it turns on is the one described under
> **Due/deadline fields: `dueLike` and `settledWhen`.** A deadline is declared
> on the object's field metadata, never on a field entry: `dueLike` and its
> partner `settledWhen` are `@objectstack/spec`'s `FieldSchema.dueLike` and
> `FieldSchema.settledWhen`, both for `date` / `datetime` fields only. A
> `DetailViewField` entry carries neither — the detail view reads both from the
> object field it draws. See [Date Field](/docs/fields/date) and
> [Overdue Affordance](/docs/fields/datetime#overdue-affordance).
>
> ```json
> {
> "type": "detail-view",
> "objectName": "Contract",
> "fields": [
> {
> "name": "end_date",
> "label": "Contract Ends",
> "type": "date",
> "format": "relative",
> "dueLike": true
> }
> ]
> }
> ```
>
> ⚠️ `end_date` is deliberately a neutral name here, because only a declared
> `true` short-circuits: with the key absent the due/deadline field-name
> convention still decides for itself, and `dueLike: false` does not suppress
> that fallback either. Authoring a neutral name is the opt-out.
>
> ⚠️ The authored entry wins. Where the object's own field metadata also
> declares `dueLike`, that value is consulted only when the entry leaves the
> key out — it does not overwrite what the view author wrote.
>
> On the object's field metadata, `dueLike` and its partner `settledWhen` are
> `@objectstack/spec`'s `FieldSchema.dueLike` and `FieldSchema.settledWhen`,
> both for `date` / `datetime` fields only. `settledWhen` is a CEL predicate
> over the record (`record.status == 'done'`) that settles the deadline for
> that record, and the spec accepts it only beside `dueLike: true`. It is an
> object-metadata key: a `DetailViewField` entry does not carry it. ⚠️ It is
> declared, not yet rendered: no date cell evaluates it today.
> `settledWhen` is a CEL predicate over the record (`record.status == 'done'`),
> accepted only beside `dueLike: true`. The detail view evaluates it against
> the record it shows: while it holds, the deadline is settled and the cell
> paints no overdue wording or colour. Nothing is inferred from a field's
> name: an undeclared date in the past reads as any past date does.

**Related:** [DetailSchema](#detailschema), [ObjectViewSchema](#objectviewschema)

Expand Down
16 changes: 12 additions & 4 deletions content/docs/fields/date.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ const closeDate: DateFieldMetadata = {
the red styling. It is `@objectstack/spec`'s `FieldSchema.dueLike`, and the spec
accepts it on `date` and `datetime` fields only. `datetime` is its own type with its
own metadata (`DateTimeFieldMetadata`) and honours `dueLike` identically; see
[DateTime Field](/docs/fields/datetime), which also covers the field-name fallback
today's cells still apply when the key is absent.
[DateTime Field](/docs/fields/datetime). Nothing is inferred from the field's name:
without the key, a past `due_date` reads as any past date does.

`settledWhen` says when the deadline is settled for one record: a CEL predicate over
the record's own columns, read as `record`. While it holds, no overdue wording or
Expand All @@ -56,8 +56,16 @@ const dueDate: DateFieldMetadata = {
};
```

⚠️ `settledWhen` is declared, not yet rendered: no date cell evaluates it today, so a
past deadline on a settled record still renders overdue.
Every view that draws the date beside its record — the grid, the record page, related
lists, kanban and gallery cards, the compact list and the tree — evaluates
`settledWhen` against that record, so a done task's past due date reads neutral while
an open one's reads overdue. A predicate that fails to evaluate leaves the deadline
unsettled.

⚠️ The predicate reads the record as the view fetched it. A standalone grid and a
related list add the fields `settledWhen` reads to their own query; a list view
fetches its columns, so a list view that hides the field the predicate reads leaves
its deadlines unsettled. Show that field as a column.

There is no date-range key. `@objectstack/spec`'s `FieldSchema` declares no date bound
(its `min` / `max` are numbers) and refuses `min_date` / `max_date` by name, and no
Expand Down
16 changes: 7 additions & 9 deletions content/docs/fields/datetime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,9 @@ deadline, so only a field that says so gets the affordance:
- the relative face reads **"Overdue Nd"** instead of the neutral "N days ago";
- the cell is styled red, whichever display face it is painting.

The spec reads an absent key, or `false`, as "not a deadline", with nothing
inferred from the field's name. Today's cells do not follow that yet: only a
declared `true` short-circuits, and with the key absent, common due/deadline
field-name conventions (`due_at`, `deadline`, `expires_at`, `sla_at`,
`renewal_at`, `next_action_at`, …) turn the affordance on by themselves —
writing `dueLike: false` does **not** suppress that fallback. Until the fallback
is retired, authoring a neutral field name is the opt-out.
An absent key, or `false`, means "not a deadline", with nothing inferred from
the field's name: an undeclared `due_at` in the past reads as any past datetime
does.

`settledWhen` says when the deadline is settled for one record. It is
`FieldSchema.settledWhen`: a CEL predicate over the record's own columns, read as
Expand All @@ -78,8 +74,10 @@ const respondBy: DateTimeFieldMetadata = {
};
```

⚠️ `settledWhen` is declared, not yet rendered: no datetime cell evaluates it
today, so a past deadline on a settled record still renders overdue.
Every view that draws the datetime beside its record evaluates `settledWhen`
against that record (see [Date Field](/docs/fields/date)), and a settled deadline
reads the neutral phrase with no red. A predicate that fails to evaluate leaves
the deadline unsettled.

The affordance is **day-granular**. It compares calendar days, so a datetime a
few hours past its deadline still reads "Today" and is not red — the same answer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* 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#11815 — the ONE helper every host asks whether a declared deadline
* is settled for a row. Settled only when the field declares `dueLike: true`
* and a `settledWhen` that holds; every other case, a faulting predicate
* included, is not settled.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { isDeadlineSettled } from '../deadlineSettled';
import * as core from '../../index';

const DEADLINE = { name: 'due_date', dueLike: true, settledWhen: "record.status == 'done'" };

afterEach(() => vi.restoreAllMocks());

describe('isDeadlineSettled (objectui#11815)', () => {
it('is settled when the declared predicate holds for the row', () => {
expect(isDeadlineSettled(DEADLINE, { status: 'done' })).toBe(true);
});

it('takes the spec envelope form as well as the bare string', () => {
const envelope = { ...DEADLINE, settledWhen: { dialect: 'cel', source: "record.status == 'done'" } };
expect(isDeadlineSettled(envelope, { status: 'done' })).toBe(true);
expect(isDeadlineSettled(envelope, { status: 'open' })).toBe(false);
});

it('is not settled when the predicate does not hold', () => {
expect(isDeadlineSettled(DEADLINE, { status: 'open' })).toBe(false);
});

it('is not settled when the field declares no settledWhen', () => {
expect(isDeadlineSettled({ name: 'due_date', dueLike: true }, { status: 'done' })).toBe(false);
});

it('is not settled when the field is not a declared deadline, whatever settledWhen says', () => {
expect(isDeadlineSettled({ name: 'due_date', settledWhen: DEADLINE.settledWhen }, { status: 'done' })).toBe(false);
expect(isDeadlineSettled({ ...DEADLINE, dueLike: false }, { status: 'done' })).toBe(false);
expect(isDeadlineSettled(undefined, { status: 'done' })).toBe(false);
});

it('is not settled when the predicate fails to evaluate', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
// `status` is absent from the row: CEL faults `No such key`, never "settled".
expect(isDeadlineSettled(DEADLINE, { title: 'x' })).toBe(false);
expect(isDeadlineSettled({ ...DEADLINE, settledWhen: 'record.status ==' }, { status: 'done' })).toBe(false);
expect(isDeadlineSettled(DEADLINE, null)).toBe(false);
});

it('reads the host scope beside the row', () => {
const field = { ...DEADLINE, settledWhen: "record.status == 'done' && features.settle" };
expect(isDeadlineSettled(field, { status: 'done' }, { scope: { features: { settle: true } } })).toBe(true);
expect(isDeadlineSettled(field, { status: 'done' }, { scope: { features: { settle: false } } })).toBe(false);
});

it('is published from @object-ui/core', () => {
expect(core.isDeadlineSettled).toBe(isDeadlineSettled);
});
});
57 changes: 57 additions & 0 deletions packages/core/src/evaluator/deadlineSettled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* 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.
*/

/**
* Whether a declared deadline is settled for one row (objectui#11815).
*
* `@objectstack/spec` declares the deadline semantic on a `date` / `datetime`
* field: `dueLike: true` makes it a deadline, and `settledWhen` is a per-record
* CEL predicate that, while it holds, settles that deadline for the record —
* no overdue wording, no overdue colour. The date cells paint from the field's
* declaration alone and cannot evaluate a predicate, so every host that draws
* one beside a row asks THIS function and hands the cell its answer as the
* `deadlineSettled` prop. One helper, so no host carries its own copy.
*/
import { evalRowPredicate, type RowPredicateOptions } from './listConditional.js';
import type { FieldRulePredicate } from './fieldRules.js';

/** The two declared keys {@link isDeadlineSettled} reads off a field. */
export interface DeadlineFieldLike {
name?: string;
dueLike?: boolean | null;
settledWhen?: FieldRulePredicate | null;
}

/**
* `true` only when the field declares `dueLike: true` AND a `settledWhen`, and
* that predicate holds for `row` (bound as `record`, through
* {@link evalRowPredicate} — the evaluator, scope and relation collapse the
* grid's conditional formatting uses).
*
* Every other answer is `false`:
* - a field that is not a declared deadline, or declares no `settledWhen`, is
* never settled;
* - a predicate that fails to evaluate is NOT settled. It was judged when it
* was authored, and a quiet "settled" would hide an overdue item; the
* evaluator's own one-time warning names the fault.
*/
export function isDeadlineSettled(
field: DeadlineFieldLike | null | undefined,
row: Record<string, unknown> | null | undefined,
opts: Pick<RowPredicateOptions, 'scope' | 'fields'> = {},
): boolean {
if (!field || field.dueLike !== true) return false;
const settledWhen = field.settledWhen;
if (settledWhen == null) return false;
return evalRowPredicate(settledWhen, row, {
fallback: false,
scope: opts.scope,
fields: opts.fields,
label: `${field.name ?? 'field'}.settledWhen`,
});
}
1 change: 1 addition & 0 deletions packages/core/src/evaluator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from './fieldRules.js';
export * from './subjectPermissions.js';
export * from './rowPredicateCanon.js';
export * from './listConditional.js';
export * from './deadlineSettled.js';
export * from './optionRules.js';
export * from './optionLint.js';
export * from './ExpressionCache.js';
Expand Down
13 changes: 11 additions & 2 deletions packages/fields/src/__tests__/DateCellRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,19 @@ describe('DateCellRenderer', () => {
expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument();
});

it('labels a past due_date as "Overdue Nd" and colors it red', () => {
// CONTROL (objectui#11815, ruling D): this pin used to assert the field-name
// guess — an undeclared `due_date` in the past read "Overdue 6d" in red.
// Overdue is declared-only now, so the same field reads as any past date.
it('CONTROL — an undeclared past due_date reads neutral, with no red', () => {
render(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'due_date' } as any} />);
const el = screen.getByText('6 days ago');
expect(el.className).not.toMatch(/text-red-600/);
expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument();
});

it('labels a past DECLARED deadline as "Overdue Nd" and colors it red', () => {
render(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'due_date', dueLike: true } as any} />);
const el = screen.getByText('Overdue 6d');
expect(el).toBeInTheDocument();
expect(el.className).toMatch(/text-red-600/);
});

Expand Down
5 changes: 3 additions & 2 deletions packages/fields/src/__tests__/date-locale-channel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,14 +348,15 @@ describe('en session — output is byte-identical (must-not-change)', () => {

describe('the already-localized overdue branch is undisturbed (green both sides)', () => {
it('resolves through the translate fn, not through Intl — zh', () => {
renderSession('zh', <DateCellRenderer value={daysFromNow(-6)} field={dateField('due_date')} />);
// Declared (objectui#11815): overdue is declared-only, the name guesses nothing.
renderSession('zh', <DateCellRenderer value={daysFromNow(-6)} field={{ ...dateField('due_date'), dueLike: true }} />);
const el = screen.getByText('逾期 6 天');
expect(el).toBeInTheDocument();
expect(el.className).toMatch(/text-red-600/);
});

it('resolves through the translate fn, not through Intl — en', () => {
renderSession('en', <DateCellRenderer value={daysFromNow(-6)} field={dateField('due_date')} />);
renderSession('en', <DateCellRenderer value={daysFromNow(-6)} field={{ ...dateField('due_date'), dueLike: true }} />);
expect(screen.getByText('Overdue 6d')).toBeInTheDocument();
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ afterEach(() => {

function cellAt(value: string): { text: string; red: boolean } {
const { container } = render(
<DateCellRenderer value={value} field={{ name: 'due_date', type: 'date' } as any} />,
// Declared (objectui#11815): overdue is declared-only, the name guesses nothing.
<DateCellRenderer value={value} field={{ name: 'due_date', type: 'date', dueLike: true } as any} />,
);
const span = container.querySelector('span');
return { text: span?.textContent ?? '', red: (span?.className ?? '').includes('text-red-600') };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ describe('objectui#8581 — an unparsable date string is not a formatted value,
it('POPULATED — a past due_date is still "Overdue Nd" in red', () => {
const past = new Date();
past.setDate(past.getDate() - 6);
const { container } = renderCell('date', past.toISOString(), { name: 'due_date' });
// Declared (objectui#11815): overdue is declared-only, the name guesses nothing.
const { container } = renderCell('date', past.toISOString(), { name: 'due_date', dueLike: true });
const span = container.querySelector('span.tabular-nums');
expect(span?.textContent, 'due_date: a real past date must still read Overdue').toMatch(/Overdue/);
expect(span?.className, 'due_date: a real past date must still be red').toMatch(/text-red-600/);
Expand Down
Loading
Loading