diff --git a/docs/i18n/01-dashboard-zh.png b/docs/i18n/01-dashboard-zh.png index 47572eb..30f804a 100644 Binary files a/docs/i18n/01-dashboard-zh.png and b/docs/i18n/01-dashboard-zh.png differ diff --git a/docs/i18n/04-dashboard-zh-390.png b/docs/i18n/04-dashboard-zh-390.png new file mode 100644 index 0000000..84e47a4 Binary files /dev/null and b/docs/i18n/04-dashboard-zh-390.png differ diff --git a/src/dashboards/duty-health.dashboard.ts b/src/dashboards/duty-health.dashboard.ts index f5e91d3..31b39ea 100644 --- a/src/dashboards/duty-health.dashboard.ts +++ b/src/dashboards/duty-health.dashboard.ts @@ -51,9 +51,13 @@ import { Dashboard } from '@objectstack/spec/ui'; * a pie. Here they are two separate KPI tiles, which is the one arrangement * that cannot be misread as a partition — and the by-unit chart carries a * SINGLE series for the same reason. - * - **`oldest_last_update_at` is a timestamp, not a score.** It answers "what - * is the worst thing here" with a DATE and names no person. It is a metric - * tile, never a bar length. + * - **`oldest_last_update_at` is a timestamp, and no widget here binds it any + * more (#122).** A metric tile prints what the measure returns, and what it + * returns is an instant: `POST /api/v1/analytics/dataset/query` answers + * `"2026-07-04T07:00:00.000Z"` for it — measured on a real boot of this + * app, and typed `number` in the response's own `fields[]`, which is the + * mismatch underneath. The removal note where the tile used to be carries + * what was measured and why a days-since number cannot replace it. * - **`due_week` / `due_month` are one column at two granularities** — group * by one, never both. * - **`tasks_due` means the same thing in both datasets that declare it**, so @@ -67,14 +71,14 @@ import { Dashboard } from '@objectstack/spec/ui'; * The sales deck's p20 is the leadership first screen: four KPI cards and two * charts. Read against this file the four are 停滞项 (stagnation), 逾期项 * (overdue), 按期完成率 (on-time rate) and 清单完备度 (list completeness) — - * and the first of them is the THREE tiles at the top of this file, not one. + * and the first of them is the TWO tiles at the top of this file, not one. * That is deliberate and predates the deck: the >14d and >30d thresholds nest, * so two tiles is the one arrangement that cannot be misread as a partition - * (above), and `oldest_last_update_at` is the date that answers "the worst - * one" without ranking anybody. Collapsing them to fit a slide count would - * delete a measured decision to satisfy an arithmetic that was never about - * widget count. `test/dashboard.test.ts` pins the >30d tile's "subset" wording - * for the same reason. + * (above). It was three until #122 retired the oldest-touch tile; the + * arithmetic never was about widget count, and collapsing the two that remain + * to fit a slide count would delete a measured decision. + * `test/dashboard.test.ts` pins the >30d tile's "subset" wording for the same + * reason. * * Neither of the two charts already here was retired to make room. "Not * moving, by unit" and "Coming up" answer questions the deck's two do not — @@ -258,7 +262,10 @@ export const DutyHealthDashboard = Dashboard.create({ dataset: 'duly_stagnation', values: ['untouched_over_14d'], colorVariant: 'warning', - layout: { x: 0, y: 0, w: 6, h: 4 }, + // Seven of the twelve columns since #122, up from six — the same 7/5 + // split the two charts under it already use. Why it moved is the + // note on the tile beside it. + layout: { x: 0, y: 0, w: 7, h: 4 }, }, /** @@ -267,34 +274,83 @@ export const DutyHealthDashboard = Dashboard.create({ * cannot be added up by eye the way two stacked bars invite. * * `orange` rather than `danger`: deeper attention, not a failure verdict. + * + * It holds the rest of the top row since #122 retired the tile that used + * to sit at `x: 9` (the removal note is below), and the row is split 7/5 + * rather than 6/6. **Both halves of that are forced, and the second one + * was measured in the browser rather than reasoned about:** + * + * - **Not 6/6.** No other NUMBER may compete with the headline, and + * `test/dashboard.test.ts` spells it as a strict area comparison + * (`w * h`). Six columns at `h: 4` would make this tile EXACTLY the + * headline's area, which fails that pin — and reads as a second + * headline, which is what the pin is protecting against. + * - **Not 6 × 3 either, which was the first attempt.** The console's grid + * COMPACTS VERTICALLY: a shorter tile leaves a gap that the next widget + * is pulled up into. Screenshotted at 1440 — with this tile at `h: 3`, + * the on-time rate jumped into the right column beside the headline and + * the overdue and completeness tiles rose to fill the left, so the + * documented reading order (`on_time_rate` directly under the headline) + * silently stopped being what the screen showed. A row of tiles must be + * UNIFORM in height; the width is the only free dimension. */ { id: 'not_moving_30d', title: 'Not moving over 30 days', - description: 'A subset of the tile beside it, not an addition to it.', + description: + 'A subset of the tile beside it, not an addition to it. The single worst case is the ' + + 'first row of the Not moving list, which is ordered by last touch.', type: 'metric', dataset: 'duly_stagnation', values: ['untouched_over_30d'], colorVariant: 'orange', - layout: { x: 6, y: 0, w: 3, h: 4 }, + layout: { x: 7, y: 0, w: 5, h: 4 }, }, /** - * 3. The worst single case, as a DATE. `oldest_last_update_at` is a `min` - * over a timestamp — it names a day, never a magnitude and never a - * person, which is what makes "what is the worst thing here" answerable - * on a screen that ranks nobody. + * 3. REMOVED (#122) — the "Oldest untouched task" tile, which bound + * `duly_stagnation.oldest_last_update_at` at `{ x: 9, y: 0, w: 3, h: 4 }`. + * + * It asked the right question — "what is the worst single case" — and + * answered it with an INSTANT, which is the one thing a metric tile cannot + * turn into a number a manager reads at a glance. What the card wanted + * instead was "days since the oldest touch", and that is **not expressible + * in the dataset layer**. Measured on this app rather than assumed, on + * `@objectstack/spec` / `@objectstack/cli` 17.3.0: + * + * - `AggregationFunction` is `count | sum | avg | min | max | + * count_distinct` — no date-difference aggregate, so no measure can + * subtract an instant from now. + * - A derived measure is `{ op, of }` where `op` is `ratio | sum | + * difference | product` and `of` is `z.array(SnakeCaseIdentifierSchema)` + * — OTHER MEASURE NAMES only. There is no literal operand and no `now` + * measure to name, so `today − min(last_update_at)` has nothing to + * write on the left of the minus sign. + * - Even measure-to-measure arithmetic over instants is dead. A temporary + * `{ op: 'difference', of: ['newest_last_update_at', + * 'oldest_last_update_at'] }` was added, booted and queried through + * `POST /api/v1/analytics/dataset/query`, and answered `null`: the + * executor's `computeDerived` coerces every operand with `Number()`, + * and `Number('2026-07-04T07:00:00.000Z')` is `NaN`. The same response + * carried `oldest_last_update_at: "2026-07-04T07:00:00.000Z"` under a + * `fields[]` entry typed `number`. + * + * The remaining way to get a days number would be a stored column + * recomputed every midnight — `AGENTS.md` rule 5's banned shape, and the + * card refuses it by name. So the card's own fallback is what ships: the + * tile goes, `not_moving_30d` takes the row, and the worst single case + * stays reachable in `src/views/task.view.ts`'s `stalled` view, which + * already sorts `last_update_at` ascending and shows that column — the + * first row IS the answer, with the task and the owner beside it, which a + * bare date on a tile never had. + * + * ⚠ The MEASURE stays in `src/datasets/stagnation.dataset.ts`. It is a + * legitimate semantic-layer value (a table column, a report, an API read) + * and the dataset already ships measures no widget binds; what is banned + * is binding an instant to a METRIC tile, and `test/dashboard.test.ts` + * pins that as a property of the barrel so the tile cannot come back by + * hand. */ - { - id: 'oldest_touch', - title: 'Oldest untouched task', - description: 'The last time anything moved on the stalest open task — a date, not a score.', - type: 'metric', - dataset: 'duly_stagnation', - values: ['oldest_last_update_at'], - colorVariant: 'default', - layout: { x: 9, y: 0, w: 3, h: 4 }, - }, /** * 3b. The on-time rate — the number the product is asked for by name. @@ -324,11 +380,14 @@ export const DutyHealthDashboard = Dashboard.create({ dataset: 'duly_duty_health', values: ['on_time_rate'], colorVariant: 'default', - // Directly under the headline tile and the same width as it — the second - // number a manager reads — but SHORTER, and that is a rule rather than a - // taste call: no other number on this screen may out-area the not-moving - // tile, because stagnation is the signal that arrives early enough to act - // on. `test/dashboard.test.ts` pins it. + // Directly under the headline tile — the second number a manager reads — + // and SHORTER than it, which is a rule rather than a taste call: no other + // number on this screen may out-area the not-moving tile, because + // stagnation is the signal that arrives early enough to act on. + // `test/dashboard.test.ts` pins it. One column narrower than the headline + // since #122 widened that to 7; this row is 6/3/3 and its three tiles are + // the same height, because the grid compacts vertically and a ragged row + // reorders the screen (see the top-right tile). layout: { x: 0, y: 4, w: 6, h: 3 }, }, diff --git a/src/datasets/stagnation.dataset.ts b/src/datasets/stagnation.dataset.ts index 5b165d5..89bcd01 100644 --- a/src/datasets/stagnation.dataset.ts +++ b/src/datasets/stagnation.dataset.ts @@ -98,10 +98,32 @@ export const Stagnation = defineDataset({ }), }, { - // The single oldest untouched moment in the group. A KPI tile bound to - // this answers "what is the worst thing here" without ranking anybody - // against anybody — it is a timestamp, not a score, and it names a date - // rather than a person. + // The single oldest untouched moment in the group — a timestamp, not a + // score: it names a date rather than a person, which is what makes "what + // is the worst thing here" answerable on a screen that ranks nobody. + // + // ⛔ Never bind it to a METRIC TILE (#122). A metric widget prints what + // the measure returns, and the analytics door returns the instant + // itself: `POST /api/v1/analytics/dataset/query` answered + // `"2026-07-04T07:00:00.000Z"` here on a real boot, under a `fields[]` + // entry typed `number`. Console 17.3.0 runs that through a locale date + // formatter, so the tile reads `2026年7月4日 07:00` rather than the raw + // ISO string it printed on 17.2.0 — better, and still a DATE carrying a + // meaningless time-of-day where every neighbouring tile shows a number. + // + // The number a manager wants — days since — cannot be derived here: + // `AggregationFunction` has no date-difference member, and a derived + // measure's `of` takes OTHER MEASURE NAMES only (no literal, no `now`), + // so there is nothing to put on the left of `today − min(...)`. Measured: + // even `{ op: 'difference', of: [, ] }` returns `null`, + // because `computeDerived` coerces operands with `Number()` and an ISO + // string is `NaN`. A stored `days_stalled` is the banned shape (AGENTS.md + // rule 5 — it needs a writer every midnight and lies the day it does not + // run). + // + // So this stays a semantic-layer value — a table column, a report, an + // API read — and no dashboard binds it. `test/dashboard.test.ts` enforces + // the tile ban across the whole dashboards barrel. name: 'oldest_last_update_at', label: 'Oldest touch', aggregate: 'min', diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index 6220da4..a5166b4 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -659,13 +659,13 @@ export const dulyChinese = defineTranslationBundle({ title: '停滞', description: '超过 14 天没有任何动静的、组织认定的待办任务。仅统计组织认定的职责,自行申报的工作不计入。', }, + // 顶行只剩两块指标:「最久未动的任务」在 #122 被撤下——它给出的是一个 + // 时刻,而不是一个主管一眼能读的数字,而「已停滞多少天」在数据集层 + // 表达不出来(见仪表盘文件里的撤除说明)。最严重的那一个改由「停滞」 + // 列表的第一行回答,那里按上次有动静的时间从早到晚排序。 not_moving_30d: { title: '停滞超过 30 天', - description: '这是旁边那块指标的子集,不能与它相加。', - }, - oldest_touch: { - title: '最久未动的任务', - description: '最停滞的那个待办任务上一次有动静的时间——是一个日期,不是一个分数。', + description: '这是旁边那块指标的子集,不能与它相加。最严重的那一个在「停滞」列表的第一行——该列表按上次有动静的时间从早到晚排序。', }, // 「按期率」用 #52 的原词。分母写清楚是「已完成」,是因为读者若 // 默认分母是「应完成」,同一块指标会读出完全不同的数。 diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index caa01b6..edcbb86 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -106,11 +106,20 @@ interface DeclaredObject { readonly fields: Rec; } +interface MeasureLike { + readonly name: string; + /** Absent on a derived measure — it combines other measures instead. */ + readonly aggregate?: string; + /** Absent on `count`, and on a derived measure. */ + readonly field?: string; + readonly derived?: unknown; +} + interface DatasetLike { readonly name: string; readonly object: string; readonly dimensions?: ReadonlyArray<{ name: string }>; - readonly measures?: ReadonlyArray<{ name: string }>; + readonly measures?: ReadonlyArray; } interface Finding { @@ -601,9 +610,11 @@ describe('the four numbers the sales deck p20 promises are all on the screen', ( * binds rather than by widget id, which is the part that cannot be satisfied * by renaming a tile. * - * Stagnation is deliberately three tiles, not one; the dashboard file header + * Stagnation is deliberately two tiles, not one; the dashboard file header * carries why (nested thresholds must not be summable by eye), and the >30d - * tile's "subset" wording is pinned above. + * tile's "subset" wording is pinned above. It was three until #122 retired + * the oldest-touch tile — an instant, where every other tile on the row is a + * number; the last describe in this file is what stops it coming back. */ const boundMeasures = new Set(allWidgets.flatMap((entry) => entry.widget.values ?? [])); @@ -918,3 +929,246 @@ describe('widget bindings — the guard can fail (self-test on synthetic metadat expect(r.boundaries).toEqual([expect.stringContaining('duty.frequency')]); }); }); + +/** + * ── A metric tile prints a NUMBER, never an instant (#122) ─────────────── + * + * `duly_stagnation.oldest_last_update_at` is `min(last_update_at)`, and the + * "Oldest untouched task" tile bound it. A metric widget renders the measure's + * value as it arrives, and what arrives for an instant is the instant: + * `POST /api/v1/analytics/dataset/query` answered + * `"2026-07-04T07:00:00.000Z"` on a real boot of this app — under a `fields[]` + * entry typed `number`, which is the mismatch underneath the symptom. + * + * What the reader saw depended on the console version, and that is the reason + * this is pinned in the repo rather than left to the renderer: on Console + * 17.2.0 the tile printed the ISO string verbatim and overflowed; on 17.3.0 the + * same value renders through a locale date formatter (`2026年7月4日 07:00` / + * `Jul 4, 2026, 07:00 AM`) and fits. Better, and still a DATE — carrying a + * time-of-day that is the dispatch clock — sitting in a row of counts and + * rates, where the number a manager wants is "how many days". + * + * That number is not expressible here: `AggregationFunction` has no + * date-difference member, and a derived measure's `of` takes other MEASURE + * NAMES only — no literal, no `now` — so `today − min(last_update_at)` has + * nothing to put on the left of the minus. Measured, on a temporary measure + * added to the real dataset and queried on a real boot: `{ op: 'difference', + * of: [, ] }` answers `null`, because the executor's + * `computeDerived` coerces operands with `Number()` and an ISO string is + * `NaN`. A stored `days_stalled` is AGENTS.md rule 5's banned shape. + * + * So the rule is about the BINDING, not about the measure: the measure stays a + * legitimate semantic-layer value (a table column, a report, an API read) and + * this walk bans putting one on a tile whose whole content is one value. It + * iterates the dashboards barrel, so a second dashboard is covered the moment + * it enters — and it resolves the measure's field against the objects barrel + * rather than matching on a name, because the next one will not be called + * `oldest_last_update_at`. + */ + +/** Field types whose value is an instant, not a magnitude. */ +const INSTANT_FIELD_TYPES: ReadonlySet = new Set(['date', 'datetime', 'time']); + +/** + * Aggregations that hand back a value of the FIELD's own type. `count` / + * `count_distinct` always return a number, whatever they count. + */ +const VALUE_PRESERVING_AGGREGATES: ReadonlySet = new Set(['min', 'max', 'sum', 'avg']); + +/** Widget types that render exactly one value, with no axis to date it. */ +const SINGLE_VALUE_WIDGETS: ReadonlySet = new Set(['metric', 'kpi']); + +export const instantTileFindings = (stack: { + readonly dashboards: readonly Dash[]; + readonly datasets: readonly DatasetLike[]; + readonly objects: readonly DeclaredObject[]; +}): WalkResult => { + const result: WalkResult = { findings: [], resolved: [], boundaries: [] }; + const datasets = new Map(stack.datasets.map((d) => [d.name, d])); + const objects = new Map(stack.objects.map((o) => [o.name, o])); + + for (const dashboard of stack.dashboards) { + for (const widget of dashboard.widgets ?? []) { + if (!SINGLE_VALUE_WIDGETS.has(String(widget.type))) continue; + const where = `dashboard ${dashboard.name ?? '(unnamed)'} · widget '${widget.id ?? '(no id)'}'`; + const dataset = datasets.get(String(widget.dataset ?? '')); + // An unresolvable dataset is the other walk's finding, not this one's. + if (!dataset) continue; + + for (const name of widget.values ?? []) { + const measure = (dataset.measures ?? []).find((m) => m.name === name); + if (!measure) continue; + // Derived measures are arithmetic over other measures — always a + // number, or `null` when an operand will not coerce. + if (measure.derived) { + result.resolved.push(`${where} · ${name} → derived, a number`); + continue; + } + const aggregate = String(measure.aggregate ?? ''); + if (!VALUE_PRESERVING_AGGREGATES.has(aggregate)) { + result.resolved.push(`${where} · ${name} → ${aggregate || '(none)'}, a number`); + continue; + } + const path = String(measure.field ?? ''); + if (path.includes('.')) { + // A joined path. No measure authors one today; this walk resolves + // base fields only, so record it rather than pass it — the day one + // appears, the tripwire below says so instead of reading as clean. + result.boundaries.push(`${where} · ${name} aggregates "${path}" through a join`); + continue; + } + const base = objects.get(dataset.object); + const field = base && Object.hasOwn(base.fields, path) + ? (base.fields[path] as { type?: unknown }) + : undefined; + if (!field) { + result.boundaries.push( + `${where} · ${name} aggregates "${path}", which is not a declared field of ${dataset.object}`, + ); + continue; + } + const type = String(field.type ?? ''); + if (!INSTANT_FIELD_TYPES.has(type)) { + result.resolved.push(`${where} · ${name} → ${aggregate}(${type}), a number`); + continue; + } + result.findings.push({ + where, + reference: `${dataset.name}.${name}`, + reason: + `a ${widget.type} tile renders one value and this one is an INSTANT: ` + + `${aggregate}(${dataset.object}.${path}), a ${type}. The tile prints what the ` + + `analytics door returns — an ISO string, typed \`number\` in the response's own ` + + `fields[] — so the reader gets a date where the row beside it shows counts and ` + + `rates, and how it reads is the console's choice, not this repo's (17.2.0 printed ` + + `the raw ISO and overflowed; 17.3.0 formats it). "Days since" is not expressible: ` + + `no date-difference aggregate, and a derived measure's \`of\` takes measure names ` + + `only. Put the instant in a LIST ordered by it — task.view.ts's \`stalled\` — or ` + + `bind a count. #122`, + }); + } + } + } + return result; +}; + +describe('a metric tile prints a number, never an instant', () => { + const stack = { + dashboards, + datasets: dulyDatasets as unknown as readonly DatasetLike[], + objects: dulyObjects as unknown as readonly DeclaredObject[], + }; + + it('no tile on any dashboard binds an aggregate over a date/datetime field', () => { + const result = instantTileFindings(stack); + expect( + result.findings.map((f) => `${f.where} · ${f.reference}: ${f.reason}`), + 'a metric tile is bound to an instant', + ).toEqual([]); + }); + + it('the walk actually reached the tiles — an empty walk proves nothing', () => { + const result = instantTileFindings(stack); + // Six metric tiles minus the one #122 removed; the count is not asserted, + // the REACH is: every tile resolved at least one measure to a verdict. + expect(result.resolved.length, 'the walk judged no measure at all').toBeGreaterThan(0); + expect(result.boundaries, 'a measure this walk cannot judge').toEqual([]); + }); + + it('the retired tile is gone, and its measure is still declared', () => { + // Both halves matter. Deleting the measure too would be a contract change + // nobody asked for (the dataset already ships measures no widget binds); + // keeping the tile is the defect. + const ids = allWidgets.map((entry) => entry.widget.id); + expect(ids, 'the oldest-touch tile is back').not.toContain('oldest_touch'); + const stagnation = (dulyDatasets as unknown as readonly DatasetLike[]) + .find((d) => d.name === 'duly_stagnation'); + expect( + (stagnation?.measures ?? []).map((m) => m.name), + 'the semantic-layer measure was dropped with the tile', + ).toContain('oldest_last_update_at'); + }); +}); + +describe('the instant-tile guard can fail (self-test on synthetic metadata)', () => { + /** + * The guard that has never been observed failing is indistinguishable from + * the guard that cannot fail — and this one is written to stay green on the + * real barrel forever, so its only evidence is here. + */ + const objects: DeclaredObject[] = [ + { + name: 'fx_task', + fields: { + touched_at: { type: 'datetime' }, + due_date: { type: 'date' }, + amount: { type: 'number' }, + }, + }, + ]; + const datasets: DatasetLike[] = [ + { + name: 'fx_metrics', + object: 'fx_task', + dimensions: [{ name: 'week' }], + measures: [ + { name: 'n', aggregate: 'count' }, + { name: 'total', aggregate: 'sum', field: 'amount' }, + { name: 'oldest', aggregate: 'min', field: 'touched_at' }, + { name: 'earliest_due', aggregate: 'min', field: 'due_date' }, + { name: 'joined', aggregate: 'min', field: 'duty.reviewed_at' }, + { name: 'ghost', aggregate: 'min', field: 'not_a_field' }, + { name: 'rate', derived: { op: 'ratio', of: ['n', 'total'] } }, + ], + }, + ]; + const run = (widget: Widget): WalkResult => + instantTileFindings({ dashboards: [{ name: 'fx_dash', widgets: [widget] }], datasets, objects }); + + const tile = (values: string[], type = 'metric'): Widget => ({ + id: 'w', type, dataset: 'fx_metrics', values, + }); + + it('is clean on a count tile — otherwise every case below is meaningless', () => { + const r = run(tile(['n'])); + expect(r.findings).toEqual([]); + expect(r.boundaries).toEqual([]); + expect(r.resolved.length).toBeGreaterThan(0); + }); + + it('is clean on a sum over a number', () => { + expect(run(tile(['total'])).findings).toEqual([]); + }); + + it('is clean on a derived ratio', () => { + expect(run(tile(['rate'])).findings).toEqual([]); + }); + + it('FIRES on min() over a datetime — the #122 shape', () => { + const r = run(tile(['oldest'])); + expect(r.findings.map((f) => f.reference)).toEqual(['fx_metrics.oldest']); + expect(r.findings[0]!.reason).toContain('INSTANT'); + }); + + it('FIRES on min() over a date as well — the type set is not one spelling', () => { + expect(run(tile(['earliest_due'])).findings.map((f) => f.reference)).toEqual(['fx_metrics.earliest_due']); + }); + + it('fires on a kpi tile too, not just a metric one', () => { + expect(run(tile(['oldest'], 'kpi')).findings).toHaveLength(1); + }); + + it('does NOT fire on a chart — an instant on an axis is legitimate', () => { + expect(run(tile(['oldest'], 'bar')).findings).toEqual([]); + }); + + it('records a boundary — not a pass — for a joined measure path', () => { + const r = run(tile(['joined'])); + expect(r.findings).toEqual([]); + expect(r.boundaries).toEqual([expect.stringContaining('duty.reviewed_at')]); + }); + + it('records a boundary for a measure field the object does not declare', () => { + expect(run(tile(['ghost'])).boundaries).toEqual([expect.stringContaining('not_a_field')]); + }); +});