Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/i18n/01-dashboard-zh.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/i18n/04-dashboard-zh-390.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
121 changes: 90 additions & 31 deletions src/dashboards/duty-health.dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 —
Expand Down Expand Up @@ -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 },
},

/**
Expand All @@ -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.
Expand Down Expand Up @@ -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 },
},

Expand Down
30 changes: 26 additions & 4 deletions src/datasets/stagnation.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [<max ts>, <min ts>] }` 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',
Expand Down
10 changes: 5 additions & 5 deletions src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,13 +659,13 @@ export const dulyChinese = defineTranslationBundle({
title: '停滞',
description: '超过 14 天没有任何动静的、组织认定的待办任务。仅统计组织认定的职责,自行申报的工作不计入。',
},
// 顶行只剩两块指标:「最久未动的任务」在 #122 被撤下——它给出的是一个
// 时刻,而不是一个主管一眼能读的数字,而「已停滞多少天」在数据集层
// 表达不出来(见仪表盘文件里的撤除说明)。最严重的那一个改由「停滞」
// 列表的第一行回答,那里按上次有动静的时间从早到晚排序。
not_moving_30d: {
title: '停滞超过 30 天',
description: '这是旁边那块指标的子集,不能与它相加。',
},
oldest_touch: {
title: '最久未动的任务',
description: '最停滞的那个待办任务上一次有动静的时间——是一个日期,不是一个分数。',
description: '这是旁边那块指标的子集,不能与它相加。最严重的那一个在「停滞」列表的第一行——该列表按上次有动静的时间从早到晚排序。',
},
// 「按期率」用 #52 的原词。分母写清楚是「已完成」,是因为读者若
// 默认分母是「应完成」,同一块指标会读出完全不同的数。
Expand Down
Loading
Loading