Repository navigation
fix(plugin-timeline): group an explicit groupByField by its display value, one group per key (objectui#12104) - #12118
Merged
objectstack-fleet[bot] merged 2 commits intoOct 11, 2026
Conversation
…alue, one group per key (objectui#12104) The group key read the raw `item[groupByField]` and stringified it, and the timeline's own fetch expands every declared relation, so a lookup keyed on "[object Object]" and the whole feed drew one group. The key now reads the option label for a select, else `recordDisplayValueAt`, the resolver the title and description already use; an empty display value groups under "Unassigned", and a feed with none falls back to the date buckets. The vertical renderer merged only ADJACENT equal keys after the date sort, so a key whose rows interleave in date order drew one header per run. It now buckets by key, groups in the order of each key's earliest entry and entries in date order inside each. The date buckets are contiguous in date order by construction, so they render as before. The bucketing sits in the renderer, not the composer, because the horizontal branch draws the same item array, draws no groups, and keeps its date order. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz
… it had (objectui#12104) `recordDisplayValueAt` reads no display value off an array, so routing a multiselect, tags or multi-lookup value through it moved every such row under "Unassigned". An array keeps the stringified key it had on main; how a multi-valued row should group is not decided by this change. Pinned as a control. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
objectstack-fleet
Bot
deleted the
claude/issue-12104-timeline-group-key
branch
October 11, 2026 05:00
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #12104
Clause-②: no
What changed
An object timeline with a
groupByFieldnow draws one group per value, headed by the value's display name. Measured on HotCRM (objectstack-ai/hotcrm#2058, 17.7.0): theowner_idandcrm_accounttimelines drew one "[object Object]" group, andchanneldrew DIGITAL, EMAIL, DIGITAL, and so on. The pins below reproduce both shapes red againstmain.effectiveItemsinObjectTimeline.tsx): the option's label when the value names an option of the field (as before), elserecordDisplayValueAt(item, groupByField), the resolver the title and the description already use (objectui#10530). An empty display value groups under the existingtimeline.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 onmain(see Acceptance notes).renderer.tsx):groupAdjacentbecamebucketByGroup, 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.ObjectTimelinehands 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 changegroupAdjacent. The composer route is not enough. The vertical and horizontal branches ofTimelineRendererdraw the same item array, and the horizontal branch draws no groups. Ablation leg 3 below implements the composer route (key-first order ineffectiveItems, 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 namesgroupAdjacentfor 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
recordDisplayValueAtfrom the built@object-ui/core:{ id, name }reads asAlice. This is the shape the timeline's own fetch produces throughbuildExpandFields, and the reach-path pin asserts that the query asked for$expandonowner_id.u1reads asu1, so the group header is the id. No name is invented for it (AGENTS.md #0.1). Pinned as a control;mainrenders it the same way.{ id: 'u9' }) reads as empty, so it groups under Unassigned.['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.tsxa21ff9ac7a4c09Alice: First, Third/Bob: Second, Fourth[object Object]: First, Second, Third, FourthSMS: First, Third/EMAIL: Second, FourthgroupByField: the date bucketsResult: with
main's two sources, 5 failed and 4 passed. Atc7a4c09, 9 passed.Ablations at
c7a4c09. Each went throughablation-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 emptygit diff HEAD:ablation-replacerefused with ANCHOR MISS and wrote nothing. It was rerun against the current line.Date buckets are unchanged, byte for byte
I hashed the rendered canvas
innerHTML(frozen clock, same rows) withmain'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 (sha25692d0eed3,d5749e2a,b7c5fc0d,22cc4e45). The scratch test was not committed.Clause-② holds
The fix adds no export, no prop and no locale key.
bucketByGroupis module-private, asgroupAdjacentwas. To check the package entry, I built plugin-timeline withmain's two sources and with this head's. The five emitted.d.tsfiles are byte-identical (diff -ris empty). The JS bundles differ (80cdf112vs0fb277fe), so the two builds really did compile different input. The diff touches nothing underpackages/types, no translation pack and no spec row, andtimeline.bucket.unassignedalready existed. No accept set moves.Gates, head
c7a4c09pnpm --filter '@object-ui/plugin-timeline^...' run build(dependency closure;test-supporthas no build)pnpm --filter @object-ui/plugin-timeline type-checktype-check:tsc --noEmit && tsc -p tsconfig.test.json;--listFilesshows the test project compiles the new pinpnpm exec vitest run packages/plugin-timeline/@object-ui/plugin-timeline, plusscripts/__tests__/date-only-zone-pins-10183.test.tspnpm --filter @object-ui/plugin-timeline exec eslint . --format jsonmain's (25 and 14, same rules)pnpm check:eager-closureafterpnpm --filter @object-ui/console exec vite buildmainto head: same eager chunk set, raw +0 bytes, gzip -3 bytes, all in theindexentry chunk, which names the lazyplugin-timelinechunk by its content hash; no timeline chunk is eagerpnpm check:new-line-citationspnpm check:control-bytesnode scripts/check-changeset-presence.mjsnode scripts/check-changeset-no-major.mjsnode scripts/check-changeset-overwrite.mjsnode scripts/check-changeset-fixed.mjspnpm check:changeset-claimspnpm check:pending-changeset-literalspnpm check:test-path-rootspnpm check:vi-mock-specifiers/vi-mock-inherit/vi-mock-override-shapepnpm check:esm-specifierspnpm check:self-import/check:phantom-depspnpm check:i18n-keyspnpm check:unreferenced-sourcesLint narrowing, proved in three parts. (1) The population is what ESLint's own config gives the package's
eslint ., which is the command CI'sturbo run lintruns; its JSON lists the four touched TS files. (2) The JSON reports 52 files. (3) Untouched files are unaffected:eslint.config.jshas no type-aware setting (0 hits forprojectService,parserOptions,TypeCheckedortsconfigRootDir, against 1 control hit fortseslint.configs), and no rule undereslint-rules/reads the filesystem or parser services (0 files, against 9 control files that definecreate(). So this diff cannot change the verdict on an untouched file.Left to CI: repo-wide
pnpm lint, thepnpm testshards,test:dist, and the published-dist, readme-exports and node-esm-load gates.Acceptance notes
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 withmultiplereads[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 timelinegroupByFieldwas measured, so nothing is filed.owner_idandcrm_accounttargets carryname.plugin-timeline.mdxdescribegroupByFieldas 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