Skip to content

refactor(dashboard): compareTo 收敛为 { kind, dimension? },并让 dataset 路径真的跑出对比 - #3612

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3337-compareto-convergence
Aug 7, 2026
Merged

refactor(dashboard): compareTo 收敛为 { kind, dimension? },并让 dataset 路径真的跑出对比#3612
yinlianghui merged 2 commits into
mainfrom
claude/issue-3337-compareto-convergence

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3337

上游 objectstack#5011 把 DashboardWidgetSchema.compareTo 从三分支联合收敛为执行器早已实现的那份契约(DatasetCompareTo,普通 strict object)。本仓是消费侧适配。前提已实测:pin 的 @objectstack/spec 解析到 17.0.0-rc.5,node_modulescompareTo: z.ZodOptional<z.ZodObject<{ kind, dimension? }, $strict>>,DatasetCompareTo.dimension 已是可选并注明由执行器决议。

两个 commit,按「改型」与「唯一的功能改动」分开:

1. refactor:类型收敛(正文 1-4、6-8 点)

  • CompareToConfig{ kind: 'previousPeriod' | 'previousYear'; dimension?: string };shiftFilterByCompareTo / compareToTrendLabelKey.kind 分派,{ offset } 的时长位移随分支删除。
  • 无孤儿语言包 key(按 PM 裁决只报不删,实测为零):offset 分支原本返回 vsPreviousPeriod,而它正是 previousPeriod 的兜底,六个 dashboard.trend.* 一个没少。本 PR 未触碰任何语言包文件。
  • DatasetWidget 删掉 typeof widget.compareTo === 'object' 的字符串丢弃 workaround 及那句已不成立的注释,widget.compareTo 原样转发。遗留字符串在新形状下是非法元数据,应在作者/发布侧被拒,而不是在渲染器里改写成另一个查询(AGENTS.md #0.1)。
  • ObjectChart / ObjectMetricWidget / DashboardRenderer:这三处本来就没有整值判别 —— 它们只把值转发进两个 core helper,唯一的判别式在 helper 里。所以是类型与注释跟随,外加删掉 DashboardRenderer 里那个用来兜跨仓 skew 的 as any。这一点与 issue 正文的预期不同,如实说明。

2. feat:唯一的功能改动(正文第 5 点)

只做改型的话,dataset 路径只是换个报错继续坏:执行器要位移的是带 dateRangetimeDimensions 条目,而本组件只产出 runtimeFilter。所以在且仅在请求对比时,把已解析的日期窗口降进 selection.timeDimensions[].dateRange:

  • 是搬走,不是复制。 对比 pass 用位移后的 timeDimensions 但同一份runtimeFilter;留一份副本会让位移窗口与当前窗口取交集,每个 __compare 列都空。
  • 只认闭合有界{ $gte, $lte } ISO 日期对。数值区间不是窗口;$gt/$lt 是开区间而 dateRange 没有开区间上界;半开的 { $gte } 没有可位移的终点。三者都留在 filter 里 —— 这正是让执行器把话说出口的方式。
  • 只走合取位置(顶层与 $and);$or 里的窗口不是整条查询所处的窗口。同一维度上的两个合取窗口取交集(AND 上的算术),顺带避免把同一个维度名递给执行器两次再被判为歧义。
  • 不在渲染器侧挑维度:每个窗口按作者写的字段名下沉;零个或多个由执行器报错并列出候选。渲染器自己猜维度 = 把「响亮报错」换成「安静的错窗口」,正是这次收敛要终结的失败类。

验收里那句「真的渲染出对比列」需要的第二半:本组件此前根本不渲染__compare 列(表格 columns = [...dimensions, ...values]、metric 只读 values[0]),所以就算对比成功也会被丢掉 —— 同一个「静默无对比」的另一个方向。现在 KPI 显示 delta + 窗口标签(复用 computeMetricDelta 与内联 metric 卡片同一套 dashboard.trend.*,不新增 key),表格多一列(按基础度量的 format 格式化,如 revenue__compare 显示 $100),图表多一条 variant: 'comparison' 覆盖序列(与 ObjectChart 的 overlay 同款)。交叉表(pivot ≥2 维)有意不做:把对比铺进 桶 × 度量 × 窗口 是另一个布局决策,已另开 finding 记录。

验证

  • 全量 vitest(仓根,flock 串行 + --maxWorkers=2):packages/corepackages/plugin-dashboardpackages/plugin-charts110 files / 1817 tests passed
  • type-check 三包全绿;改动文件 eslint 0 error;check-control-bytes OK;changeset guard(无 major)OK。
  • 逆向验证(先预测方向再跑) —— 保留新测试、把 5 个源文件回退到 origin/main,结果与预测逐条吻合:
    • compare-to.test.ts红 3 条(previousYear 窗口 + 两条 label key):旧码用 compareTo === 'previousYear' 比字符串,{ kind: 'previousYear' } 落进 previousPeriod 分支。
    • DatasetWidget.compareTo.test.tsx红 16/20(旧码没有 extractDateWindows、不下沉窗口、不渲染对比);绿的 4 条全是「无 compareTo 时不变」的反向对照。
    • 内联两个文件各 红 2/4,红的恰是 previousYear 两条;previousPeriod 与无 compareTo 两条前后都绿 —— 那条分支本来就没改,如实记录。
    • ⚠️DatasetWidget.test.tsx 第 8 点的反转断言,对旧码是绿的,不是红。派发单预设的 before-red 在这里不成立:旧守卫的判别式是 typeof === 'object',而新形状的每一个取值都是 object。真正变化的是「字符串拼写不再由渲染器裁决」,它落回 schema 的具名拒绝(#5018 家族的倒置方向)。因此那条老断言是整条替换:原来钉的「字符串被丢弃」随着字符串形态一起消失,替换成「省略 dimension 时不得替作者补一个」。同理 core 测试里的 offset 块也是整条替换(re-spell 只会让它「因为什么都没产出」而绿),换成钉住 dimension 属于执行器、渲染器不读。
    • 内联路径新测试特意用季度窗口:previousYear 与 previousPeriod 在窗口和标签上都不同,若用年窗口,一个落错分支的 previousYear 也会蒙混过关。

越界说明(1 处,有意)

packages/plugin-dashboard/SKILL.md 不在派发单的文件围栏内,但它是本包面向 AI 作者的授权文档,原文三个 JSON 示例写的正是已退役拼写("compareTo": "previousPeriod" / { "offset": "7d" })—— 留着会让 AI 持续产出被 strict schema 拒绝的元数据。按 AGENTS.md #2(docs-driven)一并更新,并补上「窗口必须有界」这条新的作者约束。与在途 #3546-s2(语言包)、#3584(CONTRIBUTING.md)无文件相交。

🤖 Generated with Claude Code

https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt


Generated by Claude Code

…ension? }
`CompareToConfig` was a three-branch union
(`'previousPeriod' | 'previousYear' | { offset }`). objectstack#5011 collapsed
`DashboardWidgetSchema.compareTo` onto the contract the analytics executor
already implements — `DatasetCompareTo`, a plain strict object
`{ kind: 'previousPeriod' | 'previousYear'; dimension?: string }` — so this
renderer now reads exactly that one shape.
- `shiftFilterByCompareTo` / `compareToTrendLabelKey` dispatch on `.kind`. The
`{ offset }` duration shift is deleted with the arm: `{ offset: '1y' }` IS
`kind: 'previousYear'`, while `'7d'` / `'1M'` have no faithful target and are
restated by the author on the widget's own `filter` plus
`kind: 'previousPeriod'` (semantic migration
`dashboard-widget-compareto-offset`). No trend label key is retired — the
offset arm resolved to `vsPreviousPeriod`, which survives as the
`previousPeriod` fallback.
- `DatasetWidget` no longer discards half of `compareTo`. It forwarded only the
object form because the two string arms had no meaning downstream; with one
shape there is nothing to discard, and a stale string is invalid metadata to
be rejected where it is authored, not reinterpreted here (AGENTS.md #0.1).
- `ObjectChart` / `ObjectMetricWidget` / `DashboardRenderer` needed no branch
table of their own: they only ever forwarded the value into the two core
helpers, so the single discriminator stays in one place. The cast that
bridged the old spec skew in `DashboardRenderer` is gone.
Why no union, kept in a comment: zod collapses a failed union into a bare
`Invalid input`, so the prescriptions written inside each arm never reached the
author (framework#5014). One strict object keeps the rejection specific.
Tests: the retired `offset` block is REPLACED, not re-spelled — it pinned the
duration shift itself, so re-spelling would have left it green by producing
nothing. What replaces it pins the key the new shape gained: `dimension` is the
EXECUTOR's, never read renderer-side. New inline-path coverage
(ObjectMetricWidget + ObjectChart) uses a QUARTER window so `previousYear` and
`previousPeriod` disagree about both window and label — with a year-scoped
filter a `previousYear` falling through to the previousPeriod arm would pass.
Refs #3337 (points 1-4, 6-8).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
…on runs
The type convergence alone leaves the dataset path exactly as broken as before,
one error later: the executor shifts a `timeDimensions` entry carrying a
`dateRange`, and this widget only ever produced a `runtimeFilter`. A widget
whose window lives in its own `filter` (a date macro, or the dashboard's
date-range filter merged in by DashboardRenderer) forwarded a well-formed
`compareTo` and got "compareTo needs a dated window to shift" back.
So when — and only when — a comparison is asked for, the resolved filter's
bounded date windows are lowered into `selection.timeDimensions[].dateRange`:
- MOVED, not copied. The comparison pass re-runs with the SHIFTED
timeDimensions but the same `runtimeFilter`; a copy left behind would
intersect the shifted window with the current one and every
`<measure>__compare` column would come back empty.
- Only a bounded, closed `{ $gte, $lte }` pair of ISO dates qualifies. A
numeric range is not a window; `$gt`/`$lt` are exclusive and `dateRange` has
no exclusive bound; a half-open `{ $gte }` has no end to shift. Each is left
in the filter, which is what makes the executor say so out loud.
- Only CONJUNCTIVE positions are walked (top level and `$and`); a window inside
`$or` is not the window the query runs in. Two conjunctive windows on ONE
dimension are intersected — arithmetic on the AND, and it keeps the executor
from being handed the same dimension twice and calling it ambiguous.
- WHICH dimension is shifted is not decided here. Every window is lowered under
the name the author wrote; zero or two candidates is the executor's error to
raise, naming them. Guessing one renderer-side would trade a loud error for a
quietly wrong window — the failure class objectstack#5011 set out to end.
Second half, without which the acceptance criterion cannot be met: this widget
never rendered `<measure>__compare` at all, so even a successful comparison was
dropped on the floor — the same "silently no comparison" from the other
direction. KPI widgets now show the delta + window label (via the shared
`computeMetricDelta` and the same `dashboard.trend.*` keys the inline metric
card uses — no new key), tables gain a comparison column formatted as its base
measure, and charts gain a `variant: 'comparison'` overlay series matching
ObjectChart's own. The cross-tab is deliberately left out: spreading a
comparison across bucket × measure × window is a layout decision of its own.
Refs #3337 (point 5).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercelBot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 7, 2026 3:51pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-DdCT9SAf.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)480.72KB105.64KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)137.51KB35.11KB
fields (index.js)230.87KB56.83KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)115.50KB29.96KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.79KB57.42KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)186.61KB49.34KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@yinlianghui
yinlianghui marked this pull request as ready for review August 7, 2026 15:56
@yinlianghui
yinlianghui added this pull request to the merge queueAug 7, 2026
Merged via the queue into main with commit 4bc6c23Aug 7, 2026
19 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3337-compareto-convergence branch August 7, 2026 15:56
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 10, 2026
…-tab (objectstack-ai#3614) (objectstack-ai#3757)
The matrix branch of `DatasetWidget` (`type: 'pivot'` with two or more
dimensions) was the one render path PR objectstack-ai#3612 left out. It lays out
`bucket x measure` and never admitted the `<measure>__compare` columns the
executor returns, so a pivot with a bounded date window and a `compareTo` ran a
correct comparison query, received correct comparison data, and showed none of
it — headers, cells and all three subtotals silent. That is objectui#3337's
"silently no comparison", relocated to another widget family.
Maintainer ruling 2026-08-07 chose form 2, in-cell stacking:
- The comparison stacks INSIDE the cell — current value on top, comparison
value and delta beneath in smaller type. The column structure stays
`bucket x measure`: a comparison column would make it
`bucket x measure x window`, doubling the width and adding a third header
level on the widget family whose width is already the scarce resource.
- rowTotalById / colTotalById / grandTotal stack it identically. A Total that
alone showed no comparison reads as "this row has none" — a different, false
statement.
- `exportColumns` drops its `isMatrix` exception, so the cross-tab exports a
flat `<measure>__compare` column per compared measure with bare numbers in
the cells. Display stacks; export is data, and a spreadsheet can still
compute on it (never "$120 $100 20%").
- One caption names the window for the whole table, from the same
`dashboard.trend.*` vocabulary the KPI/flat-table paths use, and the delta
comes from the same `computeMetricDelta` — a cross-tab cell and a dataset KPI
over the same two windows agree on sign and rounding rather than each rolling
their own percentage. A cross-tab has no per-window column header to qualify,
which is exactly what stacking traded away.
Presence is detected from the DATA via the existing `comparedValues`, not a new
config flag, so nothing is opted into per path and a pivot the executor sent no
comparison for renders byte-identically to before.
Tests extend the package's own compareTo suite: stacked cells (rise / fall /
flat / empty cell), all three subtotals, the flat CSV export asserted to carry
no display formatting at all, plus three regression pins (no compareTo; a
compareTo the executor answered without comparison columns; stray `__compare`
columns the widget never asked to compare). Reverse verification, predicted
before running: reverting the render change alone turns the two stacking tests
red on the missing `matrix-cell-compare` and leaves the export test and all
three pins green; reverting the export change alone turns only the export test
red ("Status,Priority,Amount" vs the expected comparison column). Both matched.
Fixesobjectstack-ai#3614
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto 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.

dashboard widget compareTo 收敛为 { kind, dimension? }:遗留内联图表路径跟改 + 删除 DatasetWidget 的字符串丢弃 workaround

2 participants

@yinlianghui@claude