You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
buildChartSeries' pivot branch silently drops a null SECOND-dimension group: its measure is written to the bucket but never becomes a series, so the value vanishes from the chart #4673
Measured while implementing #4508, filed rather than widened into it: #4508 and its two predecessors (#4466, #4497) are all about the FIRST dimension's bucket. This is the same defect one dimension over, and nothing has ever covered it.
What is wrong
In buildChartSeries' pivot branch (packages/core/src/utils/chart-series.ts):
A row whose SECOND dimension is null/undefined (or '') gets gId === ''. The gId !== '' gate means it never joins seriesKeys — but the line below still writes the measure into the bucket under the '' key. So the value is present in the emitted row and no series ever binds to it, i.e. the renderer is never told to draw it.
buildChartSeries([{status: 'Backlog',priority: 'High',est_hours: 5},{status: 'Backlog',priority: null,est_hours: 40}],['status','priority'],['est_hours'],)// data: [{ status: 'Backlog', High: 5, '': 40 }]// series: [{ dataKey: 'High', label: 'High' }] <- no binding for the 40
The chart draws a 5-hour bar. The 40 hours are in the data and on no mark.
#4466's measured case was exactly this shape on the x-axis: [{user_id: null, event_count: 51}, {user_id: 'Dev Admin', event_count: 2}] drew one bar and silently dropped the dominant group while the y-axis scale still accommodated it. Quoting that card's framing: the chart "was not merely empty, it understated its own data without saying so." A null second-dimension group understates it the same way, and for the same reason — no explicit bucket for "no value".
#4466 answered it for the first dimension with NULL_CATEGORY_LABEL; #4497 extended that answer to the pivot's first dimension; #4508 gave that bucket an identity distinct from its label. The second dimension was never in scope of any of the three, and the gId !== '' gate is still the pre-#4466 answer: drop it.
Drill consequence too
findChartSeriesRow's pivot arm matches the series dimension as String(r[gDim] ?? '') === s, so a null second-dimension row is reachable only by clicking a segment that is never drawn. Dead by construction rather than by accident.
The decision the fix needs
Not mechanical, which is why this is a card and not a drive-by. The first dimension's answer is a DISPLAY label supplied by the renderer (nullCategoryLabel, from the locale bundle — @object-ui/core is React-free and cannot read it). A series key, unlike an axis tick, is also a row key — it names a column in the emitted rows and a dataKey a renderer binds to. So the second dimension needs the label/identity split #4508 established for the first, but with the extra constraint that the key must not collide with a measure name or another series value. Worth deciding alongside whether '' (a genuine empty-string group) and null get separate series here, which is #4508's collision 1 in the series axis.
Also worth ruling: whether a null second-dimension group should draw at all, or whether the honest answer for a grouped-bar chart is to name it the way hasNoCategoryKey (framework#4033) names an unprojected dimension. Both are defensible; the current behaviour — carry the number, draw nothing, say nothing — is not.
First-touch grading + board admission + delegated direction (seat repo:objectui; session session_01RnQd8iMMUwXQEV1crFmQiQ, 2026-08-15): promoted — pm:queue + target:v17, type Bug. Board basis: silent data understatement — the measure is carried in the emitted row and bound to no drawn mark, which is the #4466 harm verbatim, one dimension over.
Direction (delegated ruling; maintainer veto window OPEN): the current behavior — carry the number, draw nothing, say nothing — is not one of the defensible options. The #4466 doctrine extends to the second dimension unchanged: a known-empty group draws (the null-category treatment), while an unprojected key refuses (hasNoCategoryKey, framework#4033) — "known to be empty" and "cannot know" are different facts and already have different answers on the first dimension. Choosing refusal here would give the two dimensions two different doctrines for the same fact, which is the #3314 shape.
Mechanism is the implementer's, with the card's constraint binding: a series key is also a row key and a renderer dataKey, so the null-group series needs the #4508 label/identity split with a collision-safe key (must not collide with a measure name or a real group value), and the ''-vs-null distinction follows #4508's collision-1 answer. ⛔ Stop-clause: if a collision-safe key cannot be carried through the renderer binding without leaking onto an authoring surface (data on a chart schema), stop and report — do not trade the leak for the draw.
Sequencing: queued behind #4508's merge (same file region, chart-series.ts — the pivot branch this card edits is the one #4508's PR rewrites).
Claim: PM loop round 8
Session: session_01RnQd8iMMUwXQEV1crFmQiQ
Branch: claude/issue-4673-null-second-dim-series
Worktree: objectui-issue-4673
Domain: repo:objectui
File surface: packages/core/src/utils/chart-series.ts (pivot branch + findChartSeriesRow series arm) + chart-series.nullCategory.test.ts (+ a consumer-level test in plugin-charts/plugin-dashboard test files if needed). ⛔ AdvancedChartImpl.tsx's handleCartesianClick region is held by in-flight #4672 — if the mechanism needs a renderer-side edit beyond the existing nullCategoryLabel plumbing, name the exact region and stop on any overlap (explain in the report).
Container & model: M, mode:subagent, model: opus
Serial constraints cleared: the blocker #4508 / PR #4677 is MERGED — this branches from a main carrying chartBucketId / CHART_BUCKET_ID_KEY / pivotBucketId, which are the identity primitives the ruled direction builds on; #4672 holds the cartesian click region (no core files); #4676 holds metadata-admin/plugin-designer — disjoint.
{
"issue": 4673,
"status": "done",
"branch": "claude/issue-4673-null-second-dim-series",
"pr": "https://github.com/objectstack-ai/objectui/pull/4681",
"premise_still_valid": true,
"summary": "Premise A verified on current origin/main after #4677 merged: the `gId !== ''` gate and the unconditional `byX.get(xId)[gId] = row[measure]` write both survive at chart-series.ts:351-353 — #4677 rewrote only the FIRST dimension's bucketing, so the card's defect is real and its quoted code is accurate even though its line numbers moved. New `pivotSeriesBuckets` is the single place deciding what a second-dimension group IS (chartBucketId), what it SAYS (nullCategoryLabel, per Premise B — verified that both renderers already pass it for the first dimension, so no renderer edit was needed), and which COLUMN carries it; buildChartSeries and findChartSeriesRow both go through it, so writer and reader cannot drift. Key assignment is injective by construction: a group keys by its own display label unless that label is shared with another group, reserved by the row (xKey / CHART_BUCKET_ID_KEY), or equal to some group's identity — those key by identity, and since no surviving label is any identity the two key spaces cannot meet. Ordinary pivots are byte-identical, so no encoded key reaches an authoring surface and the stop-clause was not triggered. Premise C answered by extending the identity match rather than re-widening string tolerance. Also fixed within the same function and named in the PR body: a group value equal to the x-axis dimension NAME previously overwrote the axis value on its own bucket row.",
"tests": "All at final commit a6a2282bb (= branch head; PR #4681). SWEEP: `pnpm exec vitest run packages/core/ packages/plugin-charts/ packages/plugin-dashboard/` -> 'Test Files 171 passed (171) / Tests 2497 passed (2497)'. TYPECHECK: `pnpm --filter @object-ui/core --filter @object-ui/plugin-charts --filter @object-ui/plugin-dashboard --workspace-concurrency=2 run type-check` -> 'Scope: 3 of 47 workspace projects', each running a real tsc, all Done (dependency closure built first via `--filter '<pkg>^...' build`; non-zero-match confirmed). LINT: eslint --quiet on the 3 changed files, clean. GATES: check-changeset-presence ('3 source file(s) of 2 released package(s) changed ... declares 1 changeset(s)'), check-changeset-no-major, check-changeset-fixed, check-control-bytes ('scanned 4208 tracked text file(s)') — all green. NEW COVERAGE: 21 cases in chart-series.nullCategory.test.ts (64 total in file) + 4 DOM cases in the new AdvancedChartImpl.pivotNullSeries.test.tsx. PRE-FIX MEASUREMENT of the card's repro, taken through the real renderer: data [{status:'Backlog', High:5, '':40}], series [{dataKey:'High'}], rects 1 — the 40 on no mark; post-fix 2 rects with the taller bar at exactly 8x the height. REVERSE VERIFICATION, direction predicted BEFORE running, two legs. Writer ablated (gId gate restored): predicted red in the buildChartSeries blocks and the DOM file, GREEN in reader-only drill cases — measured exactly that, 14 red/50 green in core and 3 red/1 green at the DOM, with 'resolves the null group's segment', 'resolves the empty-string group to ITS row' and 'never matches a row that lacks the second dimension key' staying green (the reader half is independently correct), and every first-dimension suite green. Reader ablated (String(r[gDim] ?? '') === s restored): predicted red in the drill block only — measured 7 red/57 green including 'expected 1 to be 2', a WRONG-row failure rather than a dead one. DOGFOOD/ABLATION DISCIPLINE: the DOM legs resolve @object-ui/core from dist, so core was rebuilt for every leg and the mutation was proved to have reached the artifact each time (4 ablation markers present when ablated, 0 in both src and dist after restore, so no mutated code stays live).",
"open_questions": [],
"out_of_scope_findings": [
"filed as #4682 (label `finding`, unassigned): DatasetWidget.handleChartDrill titles the drill drawer with `ev.series`, the raw dataKey — so an identity-keyed group's drawer reads `[null]` instead of `(None)`. Drill resolves to the correct rows; only the title is opaque. The fix needs a `seriesLabel` on ChartSegmentClickEvent written by AdvancedChartImpl's click handlers, i.e. the handleCartesianClick region held by #4672, so it carries a Blocked-by line rather than being done here.",
"filed as #4683 (label `finding`, unassigned): an unprojected SECOND dimension renders a grouped chart with zero series and no explanation — `hasNoCategoryKey` (framework#4033) has no counterpart on the series axis. Pre-existing and unchanged in mark count by this PR. Distinct from #4507, which is the first dimension's guard being blinded rather than absent; stated in the card why fixing #4507 does not produce this answer.",
"NOT filed as a new card — noted on #4672 instead (comment 5301373955), because it lives inside that in-flight card's completion scope and scattering it would duplicate the line #4672 is already rewriting: handleCartesianClick forwards `ap?.dataKey ? String(ap.dataKey) : undefined`, and `''` is falsy, so the empty-string group's click sends `series: undefined` and survives only because findChartSeriesRow coerces it back to `''`. The two halves agree by arithmetic, not design. Pinned on the core side by a test that documents the coupling; the renderer side is #4672's region."
]
}
Held region respected: AdvancedChartImpl.tsx's handleCartesianClick was NOT edited — the mechanism needed no renderer-side change, because the null group's series carries the same nullCategoryLabel the renderers already pass for the first dimension. The two places where a renderer edit would have been the better answer are #4682 and the #4672 note above, both deferred rather than taken.
Surprise worth surfacing: the empty-string second-dimension group's drill works end-to-end today only through the falsy-dataKey/?? '' coincidence described above. It is correct, but it is not designed, and #4672's rewrite sits directly on top of it.
Mechanism quality: pivotSeriesBuckets as the single writer/reader authority kills the drift class; the key assignment is injective by construction (label unless shared / row-reserved / equal-to-an-identity ⇒ identity, and no surviving label is any identity), not merely unlikely to collide. The bounding sweep beyond the card's own list (x-axis column name, CHART_BUCKET_ID_KEY) is the right kind of widening — both are pinned, and each was a real lost-measure hole inside the function's own purpose.
Evidence: 171 files / 2497 tests green across the three packages; pre-fix measurement through the real renderer (the 40-hour measure on zero marks; post-fix 2 rects at exactly 8× height); two-leg reverse verification with per-leg predicted sets matching observed exactly (writer 14/50 + DOM 3/1 with the reader-only drill cases staying green; reader 7/57 with the wrong-row failure surfaced, not just a dead one); and the dist-rebuild ablation discipline done properly (markers present when ablated, zero after restore, both src and dist).
CI on this head is converging — ready + auto-merge flips when Lint / Type Check / Test shards conclude green (timer round covers it). Card closes on merge; pm:dispatched comes off then. This merge also unblocks #4672's re-dispatch (its A work was sequenced behind this PR).
Activity
yinlianghui commentedon Aug 15, 2026
First-touch grading + board admission + delegated direction (seat
repo:objectui; sessionsession_01RnQd8iMMUwXQEV1crFmQiQ, 2026-08-15): promoted —pm:queue+target:v17, type Bug. Board basis: silent data understatement — the measure is carried in the emitted row and bound to no drawn mark, which is the #4466 harm verbatim, one dimension over.Direction (delegated ruling; maintainer veto window OPEN): the current behavior — carry the number, draw nothing, say nothing — is not one of the defensible options. The #4466 doctrine extends to the second dimension unchanged: a known-empty group draws (the null-category treatment), while an unprojected key refuses (
hasNoCategoryKey, framework#4033) — "known to be empty" and "cannot know" are different facts and already have different answers on the first dimension. Choosing refusal here would give the two dimensions two different doctrines for the same fact, which is the #3314 shape.Mechanism is the implementer's, with the card's constraint binding: a series key is also a row key and a renderer
dataKey, so the null-group series needs the #4508 label/identity split with a collision-safe key (must not collide with a measure name or a real group value), and the''-vs-nulldistinction follows #4508's collision-1 answer. ⛔ Stop-clause: if a collision-safe key cannot be carried through the renderer binding without leaking onto an authoring surface (dataon achartschema), stop and report — do not trade the leak for the draw.Sequencing: queued behind #4508's merge (same file region,
chart-series.ts— the pivot branch this card edits is the one #4508's PR rewrites).Generated by Claude Code
yinlianghui commentedon Aug 15, 2026
Claim: PM loop round 8
Session:
session_01RnQd8iMMUwXQEV1crFmQiQBranch:
claude/issue-4673-null-second-dim-seriesWorktree:
objectui-issue-4673Domain:
repo:objectuiFile surface:
packages/core/src/utils/chart-series.ts(pivot branch +findChartSeriesRowseries arm) +chart-series.nullCategory.test.ts(+ a consumer-level test in plugin-charts/plugin-dashboard test files if needed). ⛔AdvancedChartImpl.tsx'shandleCartesianClickregion is held by in-flight #4672 — if the mechanism needs a renderer-side edit beyond the existingnullCategoryLabelplumbing, name the exact region and stop on any overlap (explain in the report).Container & model: M,
mode:subagent,model: opusSerial constraints cleared: the blocker #4508 / PR #4677 is MERGED — this branches from a main carrying
chartBucketId/CHART_BUCKET_ID_KEY/pivotBucketId, which are the identity primitives the ruled direction builds on; #4672 holds the cartesian click region (no core files); #4676 holds metadata-admin/plugin-designer — disjoint.Generated by Claude Code
yinlianghui commentedon Aug 15, 2026
{ "issue": 4673, "status": "done", "branch": "claude/issue-4673-null-second-dim-series", "pr": "https://github.com/objectstack-ai/objectui/pull/4681", "premise_still_valid": true, "summary": "Premise A verified on current origin/main after #4677 merged: the `gId !== ''` gate and the unconditional `byX.get(xId)[gId] = row[measure]` write both survive at chart-series.ts:351-353 — #4677 rewrote only the FIRST dimension's bucketing, so the card's defect is real and its quoted code is accurate even though its line numbers moved. New `pivotSeriesBuckets` is the single place deciding what a second-dimension group IS (chartBucketId), what it SAYS (nullCategoryLabel, per Premise B — verified that both renderers already pass it for the first dimension, so no renderer edit was needed), and which COLUMN carries it; buildChartSeries and findChartSeriesRow both go through it, so writer and reader cannot drift. Key assignment is injective by construction: a group keys by its own display label unless that label is shared with another group, reserved by the row (xKey / CHART_BUCKET_ID_KEY), or equal to some group's identity — those key by identity, and since no surviving label is any identity the two key spaces cannot meet. Ordinary pivots are byte-identical, so no encoded key reaches an authoring surface and the stop-clause was not triggered. Premise C answered by extending the identity match rather than re-widening string tolerance. Also fixed within the same function and named in the PR body: a group value equal to the x-axis dimension NAME previously overwrote the axis value on its own bucket row.", "tests": "All at final commit a6a2282bb (= branch head; PR #4681). SWEEP: `pnpm exec vitest run packages/core/ packages/plugin-charts/ packages/plugin-dashboard/` -> 'Test Files 171 passed (171) / Tests 2497 passed (2497)'. TYPECHECK: `pnpm --filter @object-ui/core --filter @object-ui/plugin-charts --filter @object-ui/plugin-dashboard --workspace-concurrency=2 run type-check` -> 'Scope: 3 of 47 workspace projects', each running a real tsc, all Done (dependency closure built first via `--filter '<pkg>^...' build`; non-zero-match confirmed). LINT: eslint --quiet on the 3 changed files, clean. GATES: check-changeset-presence ('3 source file(s) of 2 released package(s) changed ... declares 1 changeset(s)'), check-changeset-no-major, check-changeset-fixed, check-control-bytes ('scanned 4208 tracked text file(s)') — all green. NEW COVERAGE: 21 cases in chart-series.nullCategory.test.ts (64 total in file) + 4 DOM cases in the new AdvancedChartImpl.pivotNullSeries.test.tsx. PRE-FIX MEASUREMENT of the card's repro, taken through the real renderer: data [{status:'Backlog', High:5, '':40}], series [{dataKey:'High'}], rects 1 — the 40 on no mark; post-fix 2 rects with the taller bar at exactly 8x the height. REVERSE VERIFICATION, direction predicted BEFORE running, two legs. Writer ablated (gId gate restored): predicted red in the buildChartSeries blocks and the DOM file, GREEN in reader-only drill cases — measured exactly that, 14 red/50 green in core and 3 red/1 green at the DOM, with 'resolves the null group's segment', 'resolves the empty-string group to ITS row' and 'never matches a row that lacks the second dimension key' staying green (the reader half is independently correct), and every first-dimension suite green. Reader ablated (String(r[gDim] ?? '') === s restored): predicted red in the drill block only — measured 7 red/57 green including 'expected 1 to be 2', a WRONG-row failure rather than a dead one. DOGFOOD/ABLATION DISCIPLINE: the DOM legs resolve @object-ui/core from dist, so core was rebuilt for every leg and the mutation was proved to have reached the artifact each time (4 ablation markers present when ablated, 0 in both src and dist after restore, so no mutated code stays live).", "open_questions": [], "out_of_scope_findings": [ "filed as #4682 (label `finding`, unassigned): DatasetWidget.handleChartDrill titles the drill drawer with `ev.series`, the raw dataKey — so an identity-keyed group's drawer reads `[null]` instead of `(None)`. Drill resolves to the correct rows; only the title is opaque. The fix needs a `seriesLabel` on ChartSegmentClickEvent written by AdvancedChartImpl's click handlers, i.e. the handleCartesianClick region held by #4672, so it carries a Blocked-by line rather than being done here.", "filed as #4683 (label `finding`, unassigned): an unprojected SECOND dimension renders a grouped chart with zero series and no explanation — `hasNoCategoryKey` (framework#4033) has no counterpart on the series axis. Pre-existing and unchanged in mark count by this PR. Distinct from #4507, which is the first dimension's guard being blinded rather than absent; stated in the card why fixing #4507 does not produce this answer.", "NOT filed as a new card — noted on #4672 instead (comment 5301373955), because it lives inside that in-flight card's completion scope and scattering it would duplicate the line #4672 is already rewriting: handleCartesianClick forwards `ap?.dataKey ? String(ap.dataKey) : undefined`, and `''` is falsy, so the empty-string group's click sends `series: undefined` and survives only because findChartSeriesRow coerces it back to `''`. The two halves agree by arithmetic, not design. Pinned on the core side by a test that documents the coupling; the renderer side is #4672's region." ] }Held region respected:
AdvancedChartImpl.tsx'shandleCartesianClickwas NOT edited — the mechanism needed no renderer-side change, because the null group's series carries the samenullCategoryLabelthe renderers already pass for the first dimension. The two places where a renderer edit would have been the better answer are #4682 and the #4672 note above, both deferred rather than taken.Surprise worth surfacing: the empty-string second-dimension group's drill works end-to-end today only through the falsy-
dataKey/?? ''coincidence described above. It is correct, but it is not designed, and #4672's rewrite sits directly on top of it.Generated by Claude Code
Generated by Claude Code
yinlianghui commentedon Aug 15, 2026
ACCEPT — PR #4681 (reviewer of record; readings taken on GitHub at head
a6a2282bb, matching the report's gate-union HEAD).main, first lineFixes #4673— correct, the ruled deliverable lands whole. No closing keyword near any other open card's number.Intl.NumberFormat('en-US')— an ordinalField.number(a year) always renders2,026, and no field property can turn it off #4033 split now answers identically on both dimensions, exactly as the delegated direction bound.''vsnullare two groups (fix(core,charts,dashboard): give a chart bucket an identity distinct from its display label #4677's collision-1 answer, extended). The stop-clause did not trigger: ordinary pivots are byte-identical, so no encoded key reaches an authoring surface.pivotSeriesBucketsas the single writer/reader authority kills the drift class; the key assignment is injective by construction (label unless shared / row-reserved / equal-to-an-identity ⇒ identity, and no surviving label is any identity), not merely unlikely to collide. The bounding sweep beyond the card's own list (x-axis column name,CHART_BUCKET_ID_KEY) is the right kind of widening — both are pinned, and each was a real lost-measure hole inside the function's own purpose.AdvancedChartImpl.tsxsource untouched — the new DOM test file sits beside it, and the''-is-falsy arithmetic coupling with AdvancedChartImpl's cartesian click readsactivePayload, which recharts 3 does not send — every cartesian drill loses its series and value, killing the pivoted drill outright #4672's handler is pinned on the core side and noted on AdvancedChartImpl's cartesian click readsactivePayload, which recharts 3 does not send — every cartesian drill loses its series and value, killing the pivoted drill outright #4672 (comment 5301373955) instead of being reached into. Correct.[null]instead of its label #4682 (drill drawer title shows the raw identity key[null]) →pm:blocked, Blocked-by AdvancedChartImpl's cartesian click readsactivePayload, which recharts 3 does not send — every cartesian drill loses its series and value, killing the pivoted drill outright #4672 — its fix is aseriesLabelwritten by the click handlers, i.e. AdvancedChartImpl's cartesian click readsactivePayload, which recharts 3 does not send — every cartesian drill loses its series and value, killing the pivoted drill outright #4672's held region; the requirement will be folded into AdvancedChartImpl's cartesian click readsactivePayload, which recharts 3 does not send — every cartesian drill loses its series and value, killing the pivoted drill outright #4672's dispatch when its ruled item-level work goes out. An unprojected SECOND dimension draws a grouped chart with no series and says nothing —hasNoCategoryKeyhas no counterpart on the series axis #4683 (unprojected second dimension yields zero series with nohasNoCategoryKeycounterpart) →pm:queue, age-ordered.CI on this head is converging — ready + auto-merge flips when Lint / Type Check / Test shards conclude green (timer round covers it). Card closes on merge;
pm:dispatchedcomes off then. This merge also unblocks #4672's re-dispatch (its A work was sequenced behind this PR).Generated by Claude Code
fix(core): draw a null second-dimension group instead of orphaning it…
fix(plugin-report): 报表内嵌图表分桶 null 类目并转发作者声明的图表 chrome (objectstack-ai…