Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 9e6619f

Browse files
fix(plugin-timeline,core): the gantt axis draws a stored date-only day on that day in every zone, and the formula date functions do a calendar day's arithmetic on the UTC calendar (objectui#10866, slice 3) (#10957)
Part of #10866 Clause-②: no Slice 3 of the date-only zone family's carrier card: the timeline renderer's gantt variant and the D1 day arithmetic of core `FormulaFunctions`, per release note `5866905836`, ruling `5864664470` (objectui#10903, its last execution parameter) and claim `5867569822`. The card stays open for slice 1's owed items, listed under Acceptance notes. Why `Clause-②: no`: stored date-only values read and compute as the stored day. No schema, published type or accepted set moves, and ruling `5864664470` states the arithmetic needs no statement. ## What changed ### Timeline, gantt variant (`plugin-timeline` `renderer.tsx`) One read, `readGanttDate`: a date string goes through core `toDisplayDate` (the objectui#10183 convention, which the bar tooltip's `formatDate` has taken since slice 2), so a date-only `2026-10-05` is local midnight of the day it names and a value with a time part keeps its instant. A number or a `Date` is an instant and is read by `new Date(value)` as before; for a `Date` that is a fresh copy, so no method the authored object carries is called afterwards (objectui#7027). Every stop on the gantt branch now reads through it: - `generateTimeScaleHeaders` (exported): both ends. The walk moves and prints with local setters and getters, so a date-only end starts it on that day in every zone. - `calculateDateRange`: reduces with the same read and prints each end as the viewer's calendar day (`toDateInputValue` of a `Date`, local getters). `toISOString()`'s date part named the day before east of UTC once the read is local. - `calculateBarDimensions`: all four ends. - `findUnusableGanttDate`: its "does it parse" half asks the same read, so `2026-02-30` is refused with the existing `timeline.gantt.unusableRange.malformedDate` message (no new key), where the engine rolled it into March and the bar was drawn there. Kept on the engine parse, the gate would pass a value the reduce below it reads as an Invalid Date. - The min-over-max guard: both ends, as the headers read them. - `emptyGanttDateRange`: the viewer's today (`toDateInputValue(new Date())`), where it was the UTC day. No import line of the renderer changed: `toDisplayDate` and `toDateInputValue` were already imported from `@object-ui/core`. ### Core formula date functions (`core` `evaluator/FormulaFunctions.ts`) - `readFormulaDate`: the engine parse (a date-only value is UTC midnight of its day, as before), the same named `Invalid date` error, and a `day` flag from `isRealCalendarDate`, the shared judgement, imported inside core from `utils/date-display` (already on core's barrel). - `DATEADD`: a calendar day goes through `addToDay`, which uses UTC setters. Its months go through `addMonthsUtc`: step to the 1st, move the month, clamp the day to the target month's last day. That is the rule of objectstack `@objectstack/formula`'s stdlib `addMonthsUtc` behind CEL `addMonths(d, n)`. A year is twelve months on the same rule. A day moved by days, months or years comes back as `YYYY-MM-DD`; hours and minutes give an instant (UTC midnight of the day, moved). Anything else (an instant, or a date-only string naming a day its month does not have) keeps the base code path verbatim: local setters, `setMonth` with no clamp, an instant out. - `DATEDIFF`: the month and year units read a day's fields with UTC getters and an instant's with local getters (`calendarFields`), so two instants count exactly as on the base. Day, hour and minute units are millisecond differences, unchanged. - `DATEFORMAT`: a day's fields with UTC getters, an instant's with local getters (`calendarFields`), as on the base. - `TODAY()` and `NOW()` are untouched. The objectui#10915 docblock on `TODAY()` stands, and a new docblock above `DATEADD` states the three functions' rule. ### Pins, docs, changeset - Two pins registered in `PINS` of `scripts/__tests__/date-only-zone-pins-10183.test.ts`, plus one header sentence: `TimelineGantt.dateOnlyZone-10866` (plugin-timeline) and `FormulaFunctions.dateOnlyZone-10866` (core). Each runs under `America/Los_Angeles` and `Asia/Shanghai`, each zone opens with a rig case (the gantt pin adds a fixture-validity case per zone), and each carries instant controls. The gantt pin reads the named producer, `examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json`, from disk. The formula pin's every-zone cases also run in the suite zone. - Docs: one paragraph in `content/docs/plugins/plugin-timeline.mdx`, "Time Scales (Gantt)". - Changeset `.changeset/10866-gantt-axis-formula-day-arithmetic.md`: `patch` for `@object-ui/plugin-timeline` and `@object-ui/core`. ## Readings, base `3b469c8ea` against head, in both zones Base readings: the base sources with the two new pins, in a separate comparison worktree, forks pool with the driver's flag (`Tests 27 failed | 24 passed (51)`, every red a row below), plus a direct probe of core `FormulaFunctions` under `TZ`. Head readings: the pins, green. Gantt, `en-US`: | reading | LA base | LA head | Shanghai base | Shanghai head | |:--|:--|:--|:--|:--| | producer `gantt-style-timeline.json`, month axis | Dec 2023, Jan 2024, Mar 2024, Apr 2024 | Jan 2024, Feb 2024, Mar 2024, Apr 2024 | Jan 2024 … Apr 2024 | same | | computed day axis over bars `2026-10-05`…`2026-10-07` | Oct 4, Oct 5, Oct 6 | Oct 5, Oct 6, Oct 7 | Oct 5, Oct 6, Oct 7 | same | | pinned `2026-10-05`…`2026-10-07`, instant bar from `2026-10-06T04:00Z`, its `left` | axis Oct 4, Oct 5, Oct 6 (red before the bars) | 43.75% (21:00 on the 5th) | 58.333…% (the UTC hour) | 75% (12:00 on the 6th) | | pinned start `2026-10-06`, pinned end `2026-10-06T03:00Z` | draws | refused as inverted (the end is 20:00 on the 5th here) | draws | draws, axis Oct 6 | | empty plan at `2026-10-05T20:00Z`, day scale | Oct 4 | Oct 5 | Oct 5 | Oct 6 | | bar starting `2026-02-30` (suite zone, UTC) | no refusal: the gate let it through | refused, naming `items[0].items[0].startDate` | | | Formula functions: | call | base | head, every zone (UTC, LA, Shanghai) | |:--|:--|:--| | `DATEADD('2026-01-31', 1, 'month')` | `2026-03-03T00:00:00.000Z` in every zone | `2026-02-28` | | `DATEADD('2026-03-31', -1, 'months')` | `2026-03-03T00:00:00.000Z` (UTC, Shanghai), `2026-03-03T01:00:00.000Z` (LA) | `2026-02-28` | | `DATEADD('2024-02-29', 1, 'year')` | `2025-03-01T00:00:00.000Z` in every zone | `2025-02-28` | | `DATEADD('2026-09-01', 1, 'day')` | `2026-09-02T00:00:00.000Z` | `2026-09-02` | | `DATEADD('2026-11-01', 1, 'day')` | LA `2026-11-02T01:00:00.000Z`; UTC and Shanghai `2026-11-02T00:00:00.000Z` | `2026-11-02` | | `DATEADD('2026-03-08', 1, 'day')` | LA `2026-03-08T23:00:00.000Z`; UTC and Shanghai `2026-03-09T00:00:00.000Z` | `2026-03-09` | | `DATEADD('2026-09-01', 1, 'month')` | LA `2026-10-02T00:00:00.000Z`; UTC and Shanghai `2026-10-01T00:00:00.000Z` | `2026-10-01` | | `DATEADD('2026-01-31T12:00:00.000Z', 1, 'month')` (instant) | `2026-03-03T12:00:00.000Z` in every zone | same | | `DATEADD('2026-10-31T12:00:00.000Z', 1, 'day')` (instant) | LA `2026-11-01T13:00:00.000Z`; UTC and Shanghai `2026-11-01T12:00:00.000Z` | same | | `DATEADD('2026-09-01', 3, 'hours')` | `2026-09-01T03:00:00.000Z` | same | | `DATEFORMAT('2026-09-01', 'YYYY-MM-DD')` | LA `2026-08-31` | `2026-09-01` | | `DATEFORMAT('2026-09-01', 'DD/MM/YY HH:mm:ss')` | LA `31/08/26 17:00:00`, Shanghai `01/09/26 08:00:00` | `01/09/26 00:00:00` | | `DATEFORMAT('2026-09-01T03:00:00.000Z', 'YYYY-MM-DD HH:mm')` (instant) | LA `2026-08-31 20:00`, Shanghai `2026-09-01 11:00`, UTC `2026-09-01 03:00` | same | | `DATEDIFF('2025-12-31', '2026-01-01', 'year')` | LA 0 | 1 | | `DATEDIFF('2026-08-31', '2026-09-01', 'month')` | LA 0 | 1 | | `TODAY()` and `DATEFORMAT(NOW(), 'YYYY-MM-DD')` at `2026-09-28T05:30Z` | LA `2026-09-28` and `2026-09-27` | same | Server parity, `@objectstack/formula` 17.4.0 as installed here (CEL `celEngine.evaluate`), the same in LA and Shanghai: `addMonths('2026-01-31', 1)` is `2026-02-28T00:00Z`, `addMonths('2024-02-29', 12)` is `2025-02-28T00:00Z`, `addDays('2026-11-01', 1)` is `2026-11-02T00:00Z`, `addDays('2026-10-31T12:00:00.000Z', 1)` is `2026-11-01T12:00Z`, `addMonths('2026-01-31T12:00:00.000Z', 1)` is `2026-02-28T12:00Z`, and `addDays('2026-02-30', 1)` is `2026-03-03T00:00Z`, which head `DATEADD` answers as the same instant string. ## Tests and gates Every heavy run went through objectstack's `os-verify-lock.sh`; each VERDICT line is quoted. The final head is `5b48d0292`, the contract-review repair; its readings are in the first bullet below. - **Repair round, at `5b48d0292`** (contract review `5869002399`, item 1): instants are back on the base path. A probe of base `3b469c8ea` against head under UTC, Los Angeles and Shanghai reads every instant row the same on both: `DATEFORMAT('2026-09-01T03:00:00.000Z', 'HH:mm')` is `03:00` / `20:00` / `11:00`; `DATEADD('2026-10-31T12:00:00.000Z', 1, 'day')` is `2026-11-01T12:00:00.000Z` / `2026-11-01T13:00:00.000Z` / `2026-11-01T12:00:00.000Z`; `DATEADD('2026-01-31T12:00:00.000Z', 1, 'month')` is `2026-03-03T12:00:00.000Z` in all three. Ten more rows over instants, epoch milliseconds, a zone-less date-time and an impossible day are also all equal, and the date-only rows still move. Under os-verify-lock: closure build exit 0; `type-check` for core and plugin-timeline echoes `tsc --noEmit && tsc -p tsconfig.test.json` and `Done`; `pnpm exec vitest run packages/core/ packages/plugin-timeline/ scripts/__tests__/date-only-zone-pins-10183.test.ts --maxWorkers=2` gives `Test Files 220 passed (220)`, `Tests 4037 passed | 58 skipped (4095)`, VERDICT command-exit 0. Red on base, with the base `FormulaFunctions.ts` blob `b5e63812fe3f` proven on disk: the formula pin in the forks child gives `Tests 15 failed | 24 passed (39)`. The date-only rows are red (the month and year clamp and the output shape in all three zones, the stored day's `DATEFORMAT` in Los Angeles and Shanghai, `DATEDIFF` in Los Angeles), and all six instant controls are green. The driver gives `Tests 2 failed | 18 passed (20)`. Restored: blob `edb61cdf519c` equals HEAD, `git diff HEAD` empty. Ablation (an instant's hour read with `getUTCHours`): exactly the Los Angeles and Shanghai instant-format controls go red, `Tests 2 failed | 37 passed (39)`; restored. Gates exit 0: `check-changeset-presence`, `changeset:check`, `check-changeset-overwrite`, `check:pending-changeset-literals`, `check:changeset-claims`, `check:control-bytes`, `check:new-line-citations` (0 new citations); NOT GOVERNED. ESLint on the two changed code files: 0 errors, `FormulaFunctions.ts` 26 warnings, as on base. The 11 changeset-reading `scripts/__tests__` suites were not re-run locally (two lock queue-timeouts); CI's eight test shards, which include `scripts/**`, are green. CI on `5b48d0292`: 43 check-runs, 40 success, 3 skipped, 0 failed. - **Red first, base `3b469c8ea` plus the two pins** (comparison worktree, `OBJECTUI_DATE_ZONE_CHILD=1 pnpm exec vitest run` over the two pins `--pool=forks`): `Tests 27 failed | 24 passed (51)`, VERDICT command-exit 1. Every red asserts a head value of the tables above (19 in the formula pin, over its three zones, and 8 in the gantt pin); every rig, fixture-validity and control case green. - **Reverse validation after commit, at `2006ddb03`** (the driver itself): the base blobs of `renderer.tsx` (`445a11147770`) and `FormulaFunctions.ts` (`b5e63812fe3f`) checked out and proven on disk (each equal to its base blob, not its head blob), then `pnpm exec vitest run scripts/__tests__/date-only-zone-pins-10183.test.ts`: `Tests 3 failed | 17 passed (20)`, the two new pin rows and the child-exit row; the 17 other pins green. Restored by a trap with `git checkout HEAD --` on absolute paths: each blob equal to HEAD (`e0bc666ef587`, `717f3bd2cdc7`), `git diff HEAD` empty. - **Head suites, at `2006ddb03`:** `pnpm exec vitest run packages/plugin-timeline/ packages/core/ scripts/__tests__/date-only-zone-pins-10183.test.ts --maxWorkers=2`: `Test Files 220 passed (220)`, `Tests 4035 passed | 54 skipped (4089)`, VERDICT command-exit 0. The driver row is green, so every zone case of every registered pin ran and passed in its forks child. - **At `8dd4543cc`** (one commit later, which types `readFormulaDate`'s argument instead of casting it): closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-timeline^...' --filter '@object-ui/core^...' run build` exit 0; `pnpm --workspace-concurrency=2 --filter @object-ui/plugin-timeline --filter @object-ui/core run type-check` echoes `tsc --noEmit && tsc -p tsconfig.test.json` and `Done` for each; `pnpm type-check:scripts` exit 0; `pnpm exec vitest run packages/core/src/evaluator/ scripts/__tests__/date-only-zone-pins-10183.test.ts --maxWorkers=2`: `Test Files 18 passed (18)`, `Tests 455 passed | 23 skipped (478)` (the 23 are the formula pin's zone cases, run inside the driver's child). VERDICT command-exit 0. `--listFiles` on each package's test program lists its new pin once. - **Two ablations, at `8dd4543cc`** (objectstack `scripts/ablation-replace.mjs`, wrap mode, each run through the pin on the forks pool with the driver's flag; no `dist` involved, the pins import `../renderer` and `../FormulaFunctions` relatively): - the extent's min end printed with `toISOString()` again, the parse left on the shared step (anchor `minDate: toDateInputValue(new Date(minTimestamp)),`, 1 to 0, blob `e0bc666ef587` to `3265f47d94fa`). Predicted and measured: exactly the two Shanghai rows that compute the extent red (the producer and the computed day axis), `Tests 2 failed | 16 passed (18)`; every LA row green. So the east rows are the ones that catch a parse-only repair. - months moved with UTC setters but without the clamp (anchor `addMonthsUtc(date, amount);`, 1 to 0, blob `1695444571e2` to `a50648d3900b`). Predicted and measured: exactly the month-clamp and instant-clamp rows red in each of the three zones, `Tests 6 failed | 27 passed (33)`. - Both restored by the tool: blob equal to HEAD, `git diff HEAD` empty. - **Schema catalog render, suite zone:** `pnpm exec vitest run examples/schema-catalog/test/catalog-gallery-render.test.tsx examples/schema-catalog/test/smoke.test.tsx`: `Test Files 2 passed (2)`, `Tests 1020 passed (1020)`. - **Every `scripts/__tests__` gate, at `8dd4543cc`:** `pnpm exec vitest run scripts/__tests__/ --maxWorkers=2`: `Test Files 177 passed | 2 skipped (179)`, `Tests 5334 passed | 2 skipped (5336)`, VERDICT command-exit 0. - **Gates at `8dd4543cc`, each exit 0:** `check-changeset-presence` ("4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"), `changeset:check` (fixed group, no major), `check-changeset-overwrite`, `check:pending-changeset-literals`, `check:changeset-claims` ("No pending changeset names a file this change touches"), `check:control-bytes`, `check:new-line-citations` (`VERDICT new-cross-file-line-citations: 0 new citation(s)`), `check:test-path-roots`. A control-byte self-scan over the 7 changed files: no hits. - **ESLint, narrowed** to the 5 changed code files (root `eslint.config.js`, `--no-inline-config --format json`, 5 results): 0 errors. `FormulaFunctions.ts` 26 warnings and `renderer.tsx` 14, each equal to its base blob via `--stdin`; the two pins and the driver 0. The config sets no `parserOptions.project` or `projectService`, so the rules are not type-aware and this diff cannot move an untouched file's verdict. The repo-wide lint is CI's. - **Server parity** was read with `@objectstack/formula` 17.4.0 as installed here, above; the rule was read in objectstack `packages/formula/src/stdlib.ts` (`addMonthsUtc`, `addDaysUtc`) at objectstack `origin/main` `50e273fd`. ## Acceptance notes - **Decisions taken here, for the contract reviewer.** - The gantt gate now refuses a day its month does not have (`2026-02-30`), because the gate must read what the extent and the headers read: the shared step refuses it (objectui#10026), and a gate that let it through would hand the reduce an Invalid Date. The message is the existing "not a valid date" key. - An instant's gantt extent is the VIEWER's day of that instant, where it was its UTC day, so its bar sits at its local hour on an axis of the viewer's days, the day its tooltip names. - An instant in `DATEADD` / `DATEDIFF` / `DATEFORMAT` is unchanged from the base (contract review `5869002399`, item 1). Only an argument that is a calendar day, a date-only value naming a day its month has, takes the UTC calendar. An instant is still moved and read with local setters and getters in the zone the formula runs in, so `DATEFORMAT` prints its clock in the viewer's zone: `DATEFORMAT('2026-09-01T03:00:00.000Z', 'HH:mm')` is `20:00` in Los Angeles, `11:00` in Shanghai and `03:00` in UTC, as on the base. `DATEADD` of an instant by a day follows the viewer's calendar across a DST change, and a month from January 31st on an instant still overflows into March. Triage `5861646819` says a `datetime` keeps its instant, and ruling `5864664470`'s parameter names only the date-only month overflow; putting instants on the UTC calendar, or clamping their months, would be a separate decision. So `DATEFORMAT(NOW(), 'YYYY-MM-DD')` still names the viewer's day, which can differ from `TODAY()`'s UTC day for part of every day, as on the base. - A date-only string naming a day its month does not have is not refused by the formula functions: the engine rolls it forward as the server's parse does, and `DATEADD` hands it back as an instant, as before. A date-time written without an offset is still read in the viewer's zone by the engine's parse. - **DST.** Two local midnights across a DST change are 23 or 25 hours apart, so on the gantt a bar's edge can sit up to one hour's share of the axis off its column's edge. The pins stay clear of DST dates on the gantt. - **Not changed, pre-existing and zone-independent (noted, not filed):** the gantt's percentage scale spans min to max midnight while the header row draws one equal-width column per bucket from min to max inclusive, so on a day axis a bar is placed on a scale one column narrower than the header row (the computed-axis pin's second bar starts at 50% under three columns). Measured: the style percentages; the column widths are inferred from `flex-1`, not measured. - **Slice 1's owed items stay for a slice 4** (measured, not small): - The `timeZone` "persisted data stays real instants" prose sits in `@object-ui/types` (`GanttConfig.timeZone`'s JSDoc), in `plugin-gantt` (`GanttViewProps.timeZone`'s JSDoc, "Writes still persist real instants", and `makeTzShift`'s JSDoc), and in the spec's `view.zod.ts` describe, which the `@object-ui/types` zod mirror reuses and which only objectstack can edit. That is two more packages here and a cross-repo half. - The calendar's DST-day move needs a drag measured across the November change before any edit. - The calendar no-schema fallback, the `endDateField` write and the gantt no-schema path off UTC each need a new zone pin. - **Bundle.** No new cross-package import: the renderer's import line is unchanged, and `FormulaFunctions` imports `utils/date-display` inside core, a module core's barrel already re-exports. `Bundle Analysis` is left to CI. - **Governed surface.** `check-governed-queue-guard --test` over the 7 paths: NOT GOVERNED (control: `AGENTS.md` exit 3). ## Serial - Branch base `3b469c8ea`. `git merge-tree --write-tree` of head `8dd4543cc` against `main` `06a96e948`: clean (exit 0), and nothing `main` gained since the base touches these 7 paths. Implemented by the dev agent for `domain:ui` seat #1, session `https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk`. --- _Generated by [Claude Code](https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk)_ _Body amended by the `domain:ui` seat #1 after contract review `5869002399` and the repair commit `5b48d0292`; the repair text is the dev's._ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc4e476 commit 9e6619f

7 files changed

Lines changed: 803 additions & 43 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/plugin-timeline': patch
3+
'@object-ui/core': patch
4+
---
5+
6+
fix(plugin-timeline,core): the timeline's gantt axis draws a stored date-only day on that day in every viewer zone, and the formula date functions do their day arithmetic on the UTC calendar, as the server does (objectui#10866, slice 3)
7+
8+
- **Timeline, gantt variant.** Every date string the gantt branch reads now goes through `toDisplayDate` from `@object-ui/core`, the step its bar tooltip already takes: in the axis headers, the extent computed from the rows, the bar positions, the validity check and the check that the start is not after the end. A date-only value is local midnight of the day it names, and a value with a time part keeps its instant, which the axis places at its local hour in the viewer's day. A number or a `Date` is an instant and is read as before. West of UTC the headers no longer read the day before: a day axis over October 5th to 7th reads Oct 5, Oct 6, Oct 7, and the schema catalog's `gantt-style-timeline.json` reads Jan 2024 to Apr 2024, where it read Dec 2023, Jan 2024, Mar 2024, Apr 2024 in Los Angeles. So the axis now agrees with the bar tooltip. The computed extent is printed as the viewer's calendar day. An empty plan's one-day axis is the viewer's today, where it was the UTC day. A bar date naming a day its month does not have, such as `2026-02-30`, is now refused by the render-time "not a valid date" message, where it was rolled into March and drawn there. `generateTimeScaleHeaders`, which the package exports, reads a date-only `minDate` / `maxDate` the same way.
9+
- **Formula date functions.** `DATEADD`, `DATEDIFF` and `DATEFORMAT` in `@object-ui/core` now do a calendar day's arithmetic on the UTC calendar. A date-only value naming a day its month has is read as UTC midnight of that day, as before, and is now moved and read with UTC setters and getters, so what the three answer for a day no longer depends on the viewer's zone. `DATEADD` hands such a day moved by days, months or years back as `YYYY-MM-DD`, where it gave an instant string. Its months clamp to the target month's last day, as the server's `addMonths` does: `DATEADD('2026-01-31', 1, 'month')` is `2026-02-28`, where it was `2026-03-03T00:00:00.000Z` in every zone, and a year from February 29th is February 28th. West of UTC, `DATEFORMAT('2026-09-01', 'YYYY-MM-DD')` no longer prints `2026-08-31`, `DATEDIFF` in months or years counts between the stored days rather than the days before them (`DATEDIFF('2026-08-31', '2026-09-01', 'month')` read 0), and a day added across a DST change no longer lands an hour off midnight. A value with a time part is unchanged: it is still moved and read in the viewer's zone and comes back as an instant, so `DATEFORMAT` still prints an instant's clock in the viewer's zone. So is a date-only value naming a day its month does not have, which the engine rolls forward as before. `TODAY()` is unchanged.

‎content/docs/plugins/plugin-timeline.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,8 @@ const mediumProject: TimelineSchema['scale'] = 'week' // medium projects
340340
const longProject: TimelineSchema['scale'] = 'month' // long projects
341341
```
342342

343+
A date-only bar date, `minDate` or `maxDate` (`2024-01-15`) names a calendar day, so the axis headers, the bar positions and the bar tooltips all place it on that day in every viewer's time zone. A value with a time part is an instant and sits at its hour in the viewer's day. A plan with no bar dates and no pinned range shows a one-day axis on the viewer's today.
344+
343345
## Customization
344346

345347
### Custom Icons

‎packages/core/src/evaluator/FormulaFunctions.ts‎

Lines changed: 165 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,124 @@
1717
* @packageDocumentation
1818
*/
1919

20+
import { isRealCalendarDate } from '../utils/date-display.js';
21+
2022
/**
2123
* A formula function that can be registered with the expression evaluator
2224
*/
2325
export type FormulaFunction = (...args: any[]) => any;
2426

27+
/**
28+
* One argument of `DATEADD` / `DATEDIFF` / `DATEFORMAT` (objectui#10866,
29+
* slice 3).
30+
*
31+
* `date` is the engine's own parse, as it always was: a date-only
32+
* `YYYY-MM-DD` is UTC midnight of the day it names, and a value with a time
33+
* part is its instant. `day` says the argument IS a calendar day: a date-only
34+
* string naming a day its month has, by the shared judgement
35+
* `isRealCalendarDate`. Only a day takes the UTC calendar. Anything else (an
36+
* instant, or a date-only string naming a day its month does not have, which
37+
* the engine rolls forward) keeps the path every argument took before: local
38+
* setters and getters, in the zone the formula runs in, and an instant out.
39+
*/
40+
interface FormulaDate {
41+
date: Date;
42+
day: boolean;
43+
}
44+
45+
/** Read one date argument, or throw the function's named error. */
46+
function readFormulaDate(fn: string, value: string | number | Date): FormulaDate {
47+
const date = new Date(value);
48+
if (isNaN(date.getTime())) {
49+
throw new Error(`${fn}: Invalid date "${value}"`);
50+
}
51+
return { date, day: typeof value === 'string' && isRealCalendarDate(value) };
52+
}
53+
54+
/**
55+
* The calendar fields of one argument. A day's are read with UTC getters,
56+
* which read back the day it names in every zone; an instant's are read with
57+
* local getters, in the zone the formula runs in, as they always were.
58+
*/
59+
function calendarFields({ date, day }: FormulaDate): {
60+
year: number;
61+
month: number;
62+
date: number;
63+
hours: number;
64+
minutes: number;
65+
seconds: number;
66+
} {
67+
return day
68+
? {
69+
year: date.getUTCFullYear(),
70+
month: date.getUTCMonth(),
71+
date: date.getUTCDate(),
72+
hours: date.getUTCHours(),
73+
minutes: date.getUTCMinutes(),
74+
seconds: date.getUTCSeconds(),
75+
}
76+
: {
77+
year: date.getFullYear(),
78+
month: date.getMonth(),
79+
date: date.getDate(),
80+
hours: date.getHours(),
81+
minutes: date.getMinutes(),
82+
seconds: date.getSeconds(),
83+
};
84+
}
85+
86+
/**
87+
* Move `date` by `months` calendar months on the UTC calendar, clamping the
88+
* day to the target month's last day: January 31st plus one month is February
89+
* 28th (29th in a leap year), never an overflow into March.
90+
*
91+
* This is the server's rule, objectstack `@objectstack/formula`'s stdlib
92+
* `addMonthsUtc` behind CEL `addMonths(d, n)`, written the same way: step to
93+
* the 1st, move the month, then clamp the day. Only a calendar day is moved
94+
* with it; an instant keeps `setMonth`, which it always had.
95+
*/
96+
function addMonthsUtc(date: Date, months: number): void {
97+
const day = date.getUTCDate();
98+
date.setUTCDate(1);
99+
date.setUTCMonth(date.getUTCMonth() + months);
100+
const lastDay = new Date(date.getTime());
101+
lastDay.setUTCMonth(lastDay.getUTCMonth() + 1, 0);
102+
date.setUTCDate(Math.min(day, lastDay.getUTCDate()));
103+
}
104+
105+
/**
106+
* `DATEADD` on a calendar day, on the UTC calendar: days, months and years
107+
* hand the day back as `YYYY-MM-DD`; hours and minutes give an instant, UTC
108+
* midnight of the day moved. `undefined` for a unit `DATEADD` does not know.
109+
*/
110+
function addToDay(date: Date, amount: number, normalizedUnit: string): string | undefined {
111+
switch (normalizedUnit) {
112+
case 'day':
113+
case 'days':
114+
date.setUTCDate(date.getUTCDate() + amount);
115+
break;
116+
case 'month':
117+
case 'months':
118+
addMonthsUtc(date, amount);
119+
break;
120+
case 'year':
121+
case 'years':
122+
addMonthsUtc(date, amount * 12);
123+
break;
124+
case 'hour':
125+
case 'hours':
126+
date.setUTCHours(date.getUTCHours() + amount);
127+
return date.toISOString();
128+
case 'minute':
129+
case 'minutes':
130+
date.setUTCMinutes(date.getUTCMinutes() + amount);
131+
return date.toISOString();
132+
default:
133+
return undefined;
134+
}
135+
return date.toISOString().slice(0, 10);
136+
}
137+
25138
/**
26139
* Registry of built-in formula functions
27140
*/
@@ -149,12 +262,43 @@ export class FormulaFunctions {
149262
return new Date().toISOString();
150263
});
151264

265+
/**
266+
* `DATEADD`, `DATEDIFF` and `DATEFORMAT` do a calendar DAY's arithmetic on
267+
* the UTC calendar, the server's (objectstack ADR-0053 D1: a calendar day
268+
* is UTC midnight of that day), with UTC setters and getters, so what they
269+
* answer for a day does not depend on the zone the formula runs in
270+
* (objectui#10866, slice 3). A day is a date-only argument naming a day
271+
* its month has.
272+
*
273+
* - A day is read as UTC midnight of that day. `DATEADD` hands a day moved
274+
* by days, months or years back as `YYYY-MM-DD`; hours and minutes on a
275+
* day give an instant, UTC midnight of the day moved.
276+
* - A day's months clamp to the target month's last day, as the server's
277+
* `addMonths` does; a year is twelve months on the same rule, so
278+
* February 29th plus a year is February 28th.
279+
* - `DATEFORMAT` prints a day as itself, and `DATEDIFF` counts months and
280+
* years between two days' own months and years.
281+
* - Anything else, a value with a time part included, is unchanged: it is
282+
* moved and read with local setters and getters, in the zone the formula
283+
* runs in, and `DATEADD` hands it back as an instant (`toISOString()`).
284+
*
285+
* A day used to take that same local path: west of UTC `DATEFORMAT`
286+
* printed a date-only value as the day before, `DATEDIFF` in months from
287+
* August 31st to September 1st read 0, and a `DATEADD` of a day across a
288+
* DST change moved an hour off midnight; and a month from January 31st
289+
* overflowed into March in every zone. Pinned in both directions by
290+
* `__tests__/FormulaFunctions.dateOnlyZone-10866.test.ts`.
291+
*/
152292
this.register('DATEADD', (dateStr: string, amount: number, unit: string): string => {
153-
const date = new Date(dateStr);
154-
if (isNaN(date.getTime())) {
155-
throw new Error(`DATEADD: Invalid date "${dateStr}"`);
156-
}
293+
const { date, day } = readFormulaDate('DATEADD', dateStr);
157294
const normalizedUnit = String(unit).toLowerCase();
295+
if (day) {
296+
const moved = addToDay(date, amount, normalizedUnit);
297+
if (moved === undefined) {
298+
throw new Error(`DATEADD: Unsupported unit "${unit}"`);
299+
}
300+
return moved;
301+
}
158302
switch (normalizedUnit) {
159303
case 'day':
160304
case 'days':
@@ -183,26 +327,23 @@ export class FormulaFunctions {
183327
});
184328

185329
this.register('DATEDIFF', (dateStr1: string, dateStr2: string, unit: string): number => {
186-
const date1 = new Date(dateStr1);
187-
const date2 = new Date(dateStr2);
188-
if (isNaN(date1.getTime())) {
189-
throw new Error(`DATEDIFF: Invalid date "${dateStr1}"`);
190-
}
191-
if (isNaN(date2.getTime())) {
192-
throw new Error(`DATEDIFF: Invalid date "${dateStr2}"`);
193-
}
194-
const diffMs = date2.getTime() - date1.getTime();
330+
const first = readFormulaDate('DATEDIFF', dateStr1);
331+
const second = readFormulaDate('DATEDIFF', dateStr2);
332+
const diffMs = second.date.getTime() - first.date.getTime();
195333
const normalizedUnit = String(unit).toLowerCase();
196334
switch (normalizedUnit) {
197335
case 'day':
198336
case 'days':
199337
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
200338
case 'month':
201-
case 'months':
202-
return (date2.getFullYear() - date1.getFullYear()) * 12 + (date2.getMonth() - date1.getMonth());
339+
case 'months': {
340+
const a = calendarFields(first);
341+
const b = calendarFields(second);
342+
return (b.year - a.year) * 12 + (b.month - a.month);
343+
}
203344
case 'year':
204345
case 'years':
205-
return date2.getFullYear() - date1.getFullYear();
346+
return calendarFields(second).year - calendarFields(first).year;
206347
case 'hour':
207348
case 'hours':
208349
return Math.floor(diffMs / (1000 * 60 * 60));
@@ -215,19 +356,16 @@ export class FormulaFunctions {
215356
});
216357

217358
this.register('DATEFORMAT', (dateStr: string, format: string): string => {
218-
const date = new Date(dateStr);
219-
if (isNaN(date.getTime())) {
220-
throw new Error(`DATEFORMAT: Invalid date "${dateStr}"`);
221-
}
359+
const fields = calendarFields(readFormulaDate('DATEFORMAT', dateStr));
222360
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
223361
return format
224-
.replace('YYYY', String(date.getFullYear()))
225-
.replace('YY', String(date.getFullYear()).slice(-2))
226-
.replace('MM', pad(date.getMonth() + 1))
227-
.replace('DD', pad(date.getDate()))
228-
.replace('HH', pad(date.getHours()))
229-
.replace('mm', pad(date.getMinutes()))
230-
.replace('ss', pad(date.getSeconds()));
362+
.replace('YYYY', String(fields.year))
363+
.replace('YY', String(fields.year).slice(-2))
364+
.replace('MM', pad(fields.month + 1))
365+
.replace('DD', pad(fields.date))
366+
.replace('HH', pad(fields.hours))
367+
.replace('mm', pad(fields.minutes))
368+
.replace('ss', pad(fields.seconds));
231369
});
232370
}
233371

0 commit comments

Comments
 (0)