Skip to content

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 into
mainfrom
claude/issue-12104-timeline-group-key
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-12104-timeline-group-key

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

…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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 290 chunks) 3170.2 KB 3204.6 KB
Main entry chunk (gzip) 74.1 KB 350 KB
Entry file index-CbXU4EsV.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 19.75KB 7.29KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 586.66KB 141.23KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 240.39KB 67.09KB
fields (index.js) 269.55KB 68.24KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 14.32KB 5.17KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.66KB 15.64KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.52KB 47.99KB
plugin-dashboard (index.js) 144.20KB 38.95KB
plugin-designer (index.js) 233.53KB 49.80KB
plugin-detail (index.js) 249.19KB 65.68KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.59KB 46.00KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 249.43KB 69.15KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 120.09KB 30.25KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 04:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 04:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 206505c Oct 11, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-12104-timeline-group-key branch October 11, 2026 05:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

timeline: a lookup groupByField renders one "[object Object]" group, and a text groupByField groups adjacent runs instead of bucketing by key

2 participants