Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/12297-date-deadline-keys-by-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/types': minor
---

`DateFieldMetadata` and `DateTimeFieldMetadata` take the deadline keys from `@objectstack/spec`'s `FieldSchema` (objectui#12297).

- **`dueLike`** is typed by reference to `FieldSchema.dueLike` instead of a local `boolean`, so it follows the spec when the spec moves. Its value type is unchanged.
- **`settledWhen`** is new on both interfaces, typed by reference to `FieldSchema.settledWhen`: a CEL predicate over the record (`record.status == 'done'`), as a bare string or as the spec's expression envelope, that settles the deadline for that record. The spec accepts it only beside `dueLike: true`. It is declared, not yet rendered: no date or datetime cell evaluates it yet, so a past deadline on a settled record still renders overdue.
8 changes: 8 additions & 0 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,14 @@ An enhanced detail view for a single record with sections, tabs and navigation.
> ⚠️ 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.

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

Expand Down
29 changes: 25 additions & 4 deletions content/docs/fields/date.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,31 @@ const closeDate: DateFieldMetadata = {
};
```

`dueLike` marks the field as due/deadline-semantic, gating the relative "Overdue Nd"
wording and the red styling. `datetime` is its own type with its own metadata
(`DateTimeFieldMetadata`) and honours `dueLike` identically; see
[DateTime Field](/docs/fields/datetime).
`dueLike` declares the date a deadline, gating the relative "Overdue Nd" wording and
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.

`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
colour applies to that record. It is `FieldSchema.settledWhen`, and the spec accepts
it only beside `dueLike: true`:

```ts
import type { DateFieldMetadata } from '@object-ui/types';

const dueDate: DateFieldMetadata = {
type: 'date',
name: 'due_date',
dueLike: true,
settledWhen: "record.status == 'done'",
};
```

⚠️ `settledWhen` is declared, not yet rendered: no date cell evaluates it today, so a
past deadline on a settled record still renders overdue.

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
41 changes: 32 additions & 9 deletions content/docs/fields/datetime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,42 @@ const startsAt: DateTimeFieldMetadata = {

## Overdue Affordance

`dueLike` marks the field as due/deadline-semantic. It is the same key, with the
same meaning, that the [Date Field](/docs/fields/date) uses — a past `starts_at`
or `created_at` is not a missed deadline, so only a field that says so gets the
affordance:
`dueLike` declares the datetime a deadline. It is `@objectstack/spec`'s
`FieldSchema.dueLike`, the same key, with the same meaning, that the
[Date Field](/docs/fields/date) uses, and the spec accepts it on `date` and
`datetime` fields only. A past `starts_at` or `created_at` is not a missed
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 key is optional, and only a declared `true` short-circuits. 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 — and writing `dueLike: false` does **not** suppress that
fallback. Authoring a neutral field name is the opt-out.
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.

`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
`record`, written as a bare string or as the spec's expression envelope. While it
holds, no overdue wording or colour applies to that record, and the spec accepts it
only beside `dueLike: true`:

```ts
import type { DateTimeFieldMetadata } from '@object-ui/types';

const respondBy: DateTimeFieldMetadata = {
type: 'datetime',
name: 'respond_by',
dueLike: true,
settledWhen: { dialect: 'cel', source: "record.status == 'closed'" },
};
```

⚠️ `settledWhen` is declared, not yet rendered: no datetime cell evaluates it
today, so a past deadline on a settled record still renders overdue.

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,109 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#12297 — the deadline semantic, `dueLike` and `settledWhen`, is typed
* on `DateFieldMetadata` and `DateTimeFieldMetadata` BY REFERENCE to
* `@objectstack/spec`'s `FieldSchema`, the convention `visibleWhen` and
* `currencyConfig` already follow in `field-types.ts`.
*
* - The type half is an exact `Equal` against `SpecField[KEY]`, read by this
* package's `type-check` through `tsconfig.test.json`. An exact equality,
* not an assignment: a local `boolean` happens to equal the spec's type
* today, and only the reference keeps it equal when the spec moves.
* - CONTROL: a non-deadline field type (`time`, `text`) carries neither key,
* and a fresh literal carrying one is refused.
* - The runtime half reads the installed spec, so "the spec declares both
* keys on a date field" and "the spec refuses them elsewhere" are
* re-derived on every run.
*
* Not pinned here: whether a renderer reads either key. `settledWhen` is
* declared and not yet read; the read is objectui#11815.
*/
import { describe, it, expect } from 'vitest';
import { FieldSchema, type Field as SpecField } from '@objectstack/spec/data';
import type {
DateFieldMetadata,
DateTimeFieldMetadata,
TextFieldMetadata,
TimeFieldMetadata,
} from '../field-types.js';

type Expect<T extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

// ── Declared by reference ────────────────────────────────────────────────────

export type assertionDeadlineByReference = [
Expect<Equal<DateFieldMetadata['dueLike'], SpecField['dueLike']>>,
Expect<Equal<DateFieldMetadata['settledWhen'], SpecField['settledWhen']>>,
Expect<Equal<DateTimeFieldMetadata['dueLike'], SpecField['dueLike']>>,
Expect<Equal<DateTimeFieldMetadata['settledWhen'], SpecField['settledWhen']>>,
];

/** CONTROL: the keys live on the two deadline types only. */
export type assertionNotOnOtherTypes = [
Expect<Equal<'dueLike' extends keyof TimeFieldMetadata ? true : false, false>>,
Expect<Equal<'settledWhen' extends keyof TimeFieldMetadata ? true : false, false>>,
Expect<Equal<'dueLike' extends keyof TextFieldMetadata ? true : false, false>>,
Expect<Equal<'settledWhen' extends keyof TextFieldMetadata ? true : false, false>>,
];

export const _dateDeadline: DateFieldMetadata = {
type: 'date',
name: 'due_date',
dueLike: true,
settledWhen: "record.status == 'done'",
};

export const _datetimeDeadlineEnvelope: DateTimeFieldMetadata = {
type: 'datetime',
name: 'respond_by',
dueLike: true,
settledWhen: { dialect: 'cel', source: "record.status == 'closed'" },
};

export const _timeRefusesDueLike: TimeFieldMetadata = {
type: 'time',
name: 'opens_at',
// @ts-expect-error — objectui#12297: `dueLike` is a `date` / `datetime` key only
dueLike: true,
};

export const _textRefusesSettledWhen: TextFieldMetadata = {
type: 'text',
name: 'status_note',
// @ts-expect-error — objectui#12297: `settledWhen` is a `date` / `datetime` key only
settledWhen: "record.status == 'done'",
};

// ── The installed spec ───────────────────────────────────────────────────────

/** Whether the parse failed with an issue at `path`. */
function refusedAt(res: ReturnType<typeof FieldSchema.safeParse>, path: string): boolean {
if (res.success) return false;
return res.error.issues.some((i) => i.path.join('.') === path);
}

describe('installed @objectstack/spec — the deadline keys (objectui#12297)', () => {
it('accepts both keys, in both wire shapes, on a date and a datetime field', () => {
expect(FieldSchema.safeParse(_dateDeadline).success).toBe(true);
expect(FieldSchema.safeParse(_datetimeDeadlineEnvelope).success).toBe(true);
});

it('refuses `dueLike` on a non-deadline type, with a minimal field of that type as the control', () => {
expect(FieldSchema.safeParse({ type: 'time', name: 'f' }).success).toBe(true);
expect(refusedAt(FieldSchema.safeParse({ type: 'time', name: 'f', dueLike: true }), 'dueLike')).toBe(true);
});

it('refuses `settledWhen` without `dueLike: true`, and accepts the same field with it', () => {
const settled = { type: 'date', name: 'f', settledWhen: "record.status == 'done'" };
expect(refusedAt(FieldSchema.safeParse(settled), 'settledWhen')).toBe(true);
expect(FieldSchema.safeParse({ ...settled, dueLike: true }).success).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@
* - `GeolocationFieldMetadata` leaves the `FieldMetadata` union: `geolocation`
* is not a spec `FieldType`.
*
* `dueLike` is deliberately NOT moved. The installed spec refuses it, but
* `@objectstack/spec`'s own source has since declared it on `FieldSchema`
* (unpublished at the version installed here), so the authoring face is where
* it is headed, not away from; the card holds it for a decision.
* `dueLike` was deliberately NOT moved: `@objectstack/spec` declared it on
* `FieldSchema` before it reached the installed release. Now that it has, it
* is typed by reference beside `settledWhen` (objectui#12297), pinned in
* `field-metadata-deadline-by-reference-12297.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import { FieldSchema, ObjectSchema, type Field as SpecField } from '@objectstack/spec/data';
Expand Down
56 changes: 42 additions & 14 deletions packages/types/src/field-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,12 +584,35 @@ export interface DateFieldMetadata extends BaseFieldMetadata {
*/
max_date?: never;
/**
* Marks this field as due/deadline-semantic (vs. a plain start/end/created
* date). Gates the relative-time "Overdue Nd" wording and red styling —
* without it, a past date renders as neutral "Nd ago" text. Auto-detected
* from common due/deadline field-name conventions when omitted.
* Declares this date a deadline — `@objectstack/spec`'s
* `FieldSchema.dueLike`, typed BY REFERENCE (objectui#12297). `true` lets a
* renderer show relative overdue wording ("Overdue Nd") and an overdue
* colour once the date has passed; a past date that is not a deadline reads
* as neutral "N days ago" text. The spec authors it on `date` and
* `datetime` fields only and refuses it on any other type. Display only:
* nothing on the write path reads it.
*
* ⚠️ The spec says absent or `false` means "not a deadline", with nothing
* inferred from the field's name. Today's date cells still fall back to the
* due/deadline field-NAME convention when the key is not `true`, and `false`
* does not suppress that fallback; retiring it is objectui#11815.
*/
dueLike?: SpecField['dueLike'];
/**
* When this field's deadline is SETTLED for a record —
* `@objectstack/spec`'s `FieldSchema.settledWhen`, typed BY REFERENCE
* (objectui#12297): a CEL predicate over the record's own columns, read as
* `record` (`record.status == 'done'`), as a bare string or the spec's
* expression envelope (`{ dialect, source }`). While it holds, no overdue
* wording or colour applies to that record. The spec requires
* `dueLike: true` beside it and refuses it on any type but `date` /
* `datetime`. Display only: nothing on the write path reads it.
*
* ⚠️ Declared, not yet rendered: no date cell evaluates it today, so a past
* deadline on a settled record still renders overdue. The read is
* objectui#11815.
*/
dueLike?: boolean;
settledWhen?: SpecField['settledWhen'];
}

/**
Expand All @@ -609,21 +632,26 @@ export interface DateTimeFieldMetadata extends BaseFieldMetadata {
*/
max_date?: never;
/**
* Marks this field as due/deadline-semantic — the same key, with the same
* meaning, as {@link DateFieldMetadata.dueLike} one interface up.
*
* It is declared here because the runtime honours it here (objectui#8958).
* `DetailViewFieldSchema.dueLike` has always described itself as marking "a
* date/datetime field", and `DateTimeCellRenderer` now reads it; leaving it
* off this interface would keep the mismatch alive with the sign flipped —
* a key the renderer honours but the authoring type rejects.
* Declares this datetime a deadline — `@objectstack/spec`'s
* `FieldSchema.dueLike`, typed BY REFERENCE (objectui#12297): the same key,
* with the same meaning and the same name-convention fallback, as
* {@link DateFieldMetadata.dueLike} one interface up. `DateTimeCellRenderer`
* reads it (objectui#8958).
*
* ⚠️ Day granularity, inherited from the shared relative-time path: the
* overdue wording gates on whole calendar days, so a datetime a few hours
* past its deadline is not yet overdue. Sub-day precision is a separate
* call and was not taken.
*/
dueLike?: boolean;
dueLike?: SpecField['dueLike'];
/**
* When this datetime's deadline is SETTLED for a record —
* `@objectstack/spec`'s `FieldSchema.settledWhen`, typed BY REFERENCE
* (objectui#12297): the same key, with the same meaning and the same
* "declared, not yet rendered" status, as
* {@link DateFieldMetadata.settledWhen} one interface up.
*/
settledWhen?: SpecField['settledWhen'];
}

/**
Expand Down
Loading