This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Repository navigation
Commit 206505c
fix(plugin-timeline): group an explicit groupByField by its display value, one group per key (objectui#12104) (#12118)
Fixes #12104
Clause-②: no
## What changed
An object timeline with a `groupByField` now draws one group per value,
headed by the value's display name. Measured on HotCRM
(objectstack-ai/hotcrm#2058, 17.7.0): the `owner_id` and `crm_account`
timelines drew one "[object Object]" group, and `channel` drew DIGITAL,
EMAIL, DIGITAL, and so on. The pins below reproduce both shapes red
against `main`.
- **The group key** (`effectiveItems` in `ObjectTimeline.tsx`): the
option's label when the value names an option of the field (as before),
else `recordDisplayValueAt(item, groupByField)`, the resolver the title
and the description already use (objectui#10530). An empty display value
groups under the existing `timeline.bucket.unassigned`. When no row has
a display value, the feed keeps its date buckets, as it did when every
raw value was empty. An array value (a multi-valued field) keeps the key
it had on `main` (see Acceptance notes).
- **The bucketing** (`renderer.tsx`): `groupAdjacent` became
`bucketByGroup`, which draws one section per key. Sections come in the
order of each key's first item, and items keep their input order inside
a section. `ObjectTimeline` hands the items over sorted by start date,
so a group sits where its earliest entry falls and its entries stay in
date order.
## Where the bucketing lives (measured)
The route left two options: order by key, then date, in
`effectiveItems`, or change `groupAdjacent`. The composer route is not
enough. The vertical and horizontal branches of `TimelineRenderer` draw
the same item array, and the horizontal branch draws no groups. Ablation
leg 3 below implements the composer route (key-first order in
`effectiveItems`, adjacent merge kept in the renderer). Every vertical
arm passes, and the horizontal control fails: it reads First, Third,
Second, Fourth where the date order is First, Second, Third, Fourth. So
the bucketing lives in the vertical branch, and the composer keeps the
date order. The claim's file surface names `groupAdjacent` for this
case.
## Group order across keys (mechanism assumption 4)
The card leaves this open. This PR orders groups by **each key's
earliest entry** (first occurrence in date order). It is the order the
renderer already drew whenever a key's rows were contiguous, so a
timeline that rendered correctly before renders byte-identically now
(DOM-hash proof below). It also matches the sort's stated intent,
upcoming work first. I considered option order for a select field and
did not take it, because it would reorder timelines that render
correctly today. The pins arrange their rows so that this order differs
from both the alphabetical order and the input order.
## Lookup values (mechanism assumption 3)
Measured with `recordDisplayValueAt` from the built `@object-ui/core`:
- An expanded `{ id, name }` reads as `Alice`. This is the shape the
timeline's own fetch produces through `buildExpandFields`, and the
reach-path pin asserts that the query asked for `$expand` on `owner_id`.
- An unexpanded id `u1` reads as `u1`, so the group header is the id. No
name is invented for it (AGENTS.md #0.1). Pinned as a control; `main`
renders it the same way.
- An expanded record with no name-ish key (`{ id: 'u9' }`) reads as
empty, so it groups under Unassigned.
- Every array (`['a', 'b']`, `['a']`, `[]`, or an array of expanded
records) reads as empty. That is why arrays keep their old key.
## Pins: `ObjectTimeline.groupKey-12104.test.tsx`
| Arm | sources at `a21ff9a` | `c7a4c09` |
|---|---|---|
| lookup over the timeline's own expanded fetch: `Alice: First, Third` /
`Bob: Second, Fourth` | red: `[object Object]: First, Second, Third,
Fourth` | green |
| text key interleaving in date order: `SMS: First, Third` / `EMAIL:
Second, Fourth` | red: four runs | green |
| empty values share one Unassigned group | red: two Unassigned runs |
green |
| a select key is its option's label, and its options are bucketed |
red: four runs | green |
| no row has a display value: the date buckets | red | green |
| CONTROL: an unexpanded id groups under the id | green | green |
| CONTROL: a multi-valued field keeps its key | green | green |
| CONTROL: no `groupByField`: the date buckets | green | green |
| CONTROL: the horizontal rail keeps its date order | green | green |
Result: with `main`'s two sources, 5 failed and 4 passed. At `c7a4c09`,
9 passed.
Ablations at `c7a4c09`. Each went through `ablation-replace.mjs`, which
requires the anchor to hit and the blob hash to change, and proves the
restore by blob equal to HEAD and an empty `git diff HEAD`:
1. Adjacent merging restored in the renderer: the 4 bucketing arms fail.
2. The key stringifies the raw value again: the lookup arm and the
no-display-value arm fail. The first attempt at this head did nothing.
The second commit had changed the anchor line, so `ablation-replace`
refused with ANCHOR MISS and wrote nothing. It was rerun against the
current line.
3. The composer route (above): only the horizontal control fails.
4. The multi-valued carve-out deleted: only the multi-valued control
fails.
## Date buckets are unchanged, byte for byte
I hashed the rendered canvas `innerHTML` (frozen clock, same rows) with
`main`'s two sources and with this head's. Four shapes hash identically
on both: the date buckets, contiguous text keys, contiguous multi-valued
keys, and the horizontal rail (sha256 `92d0eed3`, `d5749e2a`,
`b7c5fc0d`, `22cc4e45`). The scratch test was not committed.
## Clause-② holds
The fix adds no export, no prop and no locale key. `bucketByGroup` is
module-private, as `groupAdjacent` was. To check the package entry, I
built plugin-timeline with `main`'s two sources and with this head's.
The five emitted `.d.ts` files are byte-identical (`diff -r` is empty).
The JS bundles differ (`80cdf112` vs `0fb277fe`), so the two builds
really did compile different input. The diff touches nothing under
`packages/types`, no translation pack and no spec row, and
`timeline.bucket.unassigned` already existed. No accept set moves.
## Gates, head `c7a4c09`
| Command | Exit | Verdict |
|---|---|---|
| `pnpm --filter '@object-ui/plugin-timeline^...' run build` (dependency
closure; `test-support` has no build) | 0 | built 10 of 11 |
| `pnpm --filter @object-ui/plugin-timeline type-check` | 0 | echoes
`type-check`: `tsc --noEmit && tsc -p tsconfig.test.json`; `--listFiles`
shows the test project compiles the new pin |
| `pnpm exec vitest run packages/plugin-timeline/` | 0 | 46 files, 568
passed, 37 skipped (the zone cases, run by the driver below) |
| the 5 tests outside the package that import
`@object-ui/plugin-timeline`, plus
`scripts/__tests__/date-only-zone-pins-10183.test.ts` | 0 | 6 files, 956
passed; the driver fails on any skipped zone case |
| `pnpm --filter @object-ui/plugin-timeline exec eslint . --format json`
| 0 | 52 files, 0 errors; warnings on the two edited sources equal
`main`'s (25 and 14, same rules) |
| `pnpm check:eager-closure` after `pnpm --filter @object-ui/console
exec vite build` | 0 | 3170.2 KB across 290 of 2474 chunks, the same on
both sides; `main` to head: same eager chunk set, raw +0 bytes, gzip -3
bytes, all in the `index` entry chunk, which names the lazy
`plugin-timeline` chunk by its content hash; no timeline chunk is eager
|
| `pnpm check:new-line-citations` | 0 | 0 new citations |
| `pnpm check:control-bytes` | 0 | OK |
| `node scripts/check-changeset-presence.mjs` | 0 | 4 source files of 1
released package, 1 changeset |
| `node scripts/check-changeset-no-major.mjs` | 0 | no major |
| `node scripts/check-changeset-overwrite.mjs` | 0 | no pre-existing
changeset modified |
| `node scripts/check-changeset-fixed.mjs` | 0 | all in the fixed group
|
| `pnpm check:changeset-claims` | 0 | no pending changeset names a
touched file |
| `pnpm check:pending-changeset-literals` | 0 | no test names a pending
changeset |
| `pnpm check:test-path-roots` | 0 | OK |
| `pnpm check:vi-mock-specifiers` / `vi-mock-inherit` /
`vi-mock-override-shape` | 0 | OK |
| `pnpm check:esm-specifiers` | 0 | no extensionless relative specifier
(the load leg was not run) |
| `pnpm check:self-import` / `check:phantom-deps` | 0 | OK |
| `pnpm check:i18n-keys` | 0 | every call-site key resolves |
| `pnpm check:unreferenced-sources` | 0 | OK, but it covers one package
and plugin-timeline is not it, so it measured nothing here |
Lint narrowing, proved in three parts. (1) The population is what
ESLint's own config gives the package's `eslint .`, which is the command
CI's `turbo run lint` runs; its JSON lists the four touched TS files.
(2) The JSON reports 52 files. (3) Untouched files are unaffected:
`eslint.config.js` has no type-aware setting (0 hits for
`projectService`, `parserOptions`, `TypeChecked` or `tsconfigRootDir`,
against 1 control hit for `tseslint.configs`), and no rule under
`eslint-rules/` reads the filesystem or parser services (0 files,
against 9 control files that define `create(`). So this diff cannot
change the verdict on an untouched file.
Left to CI: repo-wide `pnpm lint`, the `pnpm test` shards, `test:dist`,
and the published-dist, readme-exports and node-esm-load gates.
## Acceptance notes
- **Multi-valued group keys are out of this change.** An array keeps
`main`'s key. A multiselect with two values heads its group with the raw
values joined (`urgent,vip`), not the option labels. An empty array
heads a group with an empty header. A lookup with `multiple` reads
`[object Object],[object Object]`. Whether a multi-valued row groups
once under the combined value or once per value is a metadata-semantics
decision the card does not make. No producer of a multi-valued timeline
`groupByField` was measured, so nothing is filed.
- **A lookup whose referenced record has no name-ish key groups under
Unassigned** (before: "[object Object]"). The resolver's embedded-record
chain (name, full_name, display_name, label, title, subject, then the
first and last name, then email) does not know the referenced object's
declared name field. The title slot gives the same answer: it renders no
line. Not reached at a public door here. HotCRM's `owner_id` and
`crm_account` targets carry `name`.
- **Docs**: the README and `plugin-timeline.mdx` describe `groupByField`
as the field "whose value heads each group", which stays true. Neither
states the group order. No doc sentence became false, so no doc file is
in this diff.
Written by session `session_01AswpQDLCKiZos2jCXknwKz`
(`https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz`).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 16c8810 commit 206505c
5 files changed
Lines changed: 373 additions & 30 deletions
File tree
- .changeset
- packages/plugin-timeline/src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
727 | 727 | | |
728 | 728 | | |
729 | 729 | | |
730 | | - | |
731 | | - | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
732 | 733 | | |
733 | 734 | | |
734 | 735 | | |
| |||
748 | 749 | | |
749 | 750 | | |
750 | 751 | | |
751 | | - | |
752 | | - | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
753 | 755 | | |
754 | 756 | | |
755 | 757 | | |
| |||
800 | 802 | | |
801 | 803 | | |
802 | 804 | | |
803 | | - | |
804 | | - | |
805 | | - | |
806 | | - | |
807 | | - | |
808 | | - | |
809 | | - | |
810 | | - | |
811 | | - | |
812 | | - | |
813 | | - | |
814 | | - | |
815 | | - | |
816 | | - | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
817 | 839 | | |
818 | 840 | | |
819 | | - | |
820 | | - | |
| 841 | + | |
| 842 | + | |
821 | 843 | | |
822 | 844 | | |
823 | 845 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
30 | | - | |
31 | | - | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
32 | 33 | | |
33 | 34 | | |
34 | 35 | | |
| |||
0 commit comments