diff --git a/src/dashboards/duty-health.dashboard.ts b/src/dashboards/duty-health.dashboard.ts index 16b7538..f5e91d3 100644 --- a/src/dashboards/duty-health.dashboard.ts +++ b/src/dashboards/duty-health.dashboard.ts @@ -62,6 +62,113 @@ import { Dashboard } from '@objectstack/spec/ui'; * - **The governed filter is already on every measure.** This file neither * adds it nor removes it. * + * ── The p20 set: four cards, and why six tiles is still four cards ─────── + * + * 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. + * 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. + * + * 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 — + * the earliest actionable signal per unit, and the forward look — and the deck + * is a sales narrative, not an inventory of what a manager may see. Four + * charts, two rows. + * + * ── 逾期項: a plain date filter now, and still not a due-date window ────── + * + * `tasks_overdue` is `late_after < {today} AND status IN (open, in_progress)`, + * on the governed measure like everything else here. Two things about it are + * worth not re-deriving: + * + * - It is a column against a MACRO, not a column against a column. #52 put + * the deadline on the row at dispatch, so the comparison objectstack#14104 + * cannot express never has to be made. Nothing here needs the platform to + * change. + * - It is NOT `due_date < {today}`. That version needs no new measure at all, + * which is exactly why it keeps being written: it marks late every task + * still inside the grace its own duty grants. The dataset file carries the + * refusal; `test/dashboard.test.ts` pins both the widget and the measure + * side. + * + * The by-unit chart under it is the same measure with the unit dimension, and + * is ordered by the unit NAME for the same reason its neighbour above it is: + * the bar order is a property of the org chart, never of who is doing badly + * this week. + * + * ── 清单完备度, and the sub-caption that is a platform gap ──────────────── + * + * The tile binds `approved_rate` — approved duties as a share of the duty + * REGISTER, over `review_status`. It is never a count of list items per person + * or per unit: "whose list is shortest" is the ranking `AGENTS.md` bans + * outright, and the two are easy to confuse because both are "completeness". + * `duly_duty_register` carries that distinction in full. + * + * The deck also asks for a sub-caption under the number — "N to confirm / M to + * approve". **The platform cannot render one on a dataset-bound tile today**, + * measured on objectui rather than assumed: + * + * - `plugin-dashboard/src/DatasetWidget.tsx`, the `isMetric` branch, reads + * `values[0]` and drops every other measure the widget selects. A second + * and third measure here would be silently invisible — the worst available + * failure on this screen. + * - The authored sub-caption slot IS `widget.options.description` + * (`DashboardRenderer.tsx`, `tWidgetSubCaption`, translation key + * `…widgets..subCaption`) — but it is passed only on the inline + * `object-metric` / static-value paths, under `isObjectProvider`. A + * dataset-bound widget renders through `DatasetWidget`, which never reads + * it. Since `dataset` is REQUIRED on `DashboardWidgetSchema`, every + * spec-valid widget takes that path, so the key is declared and reachable + * by nothing. + * + * So the tile ships with the rate and its `description` line, and the two + * pending counts are NOT approximated into the title, faked as static text, or + * smuggled in as extra `values` that would not render. Filed as + * **objectstack-ai/objectui#7293** with both measurements; the two measures + * arrive on `duly_duty_register` when the renderer can show them. This is the + * AGENTS.md rule 9 shape: the gap is reported, not written around. + * + * ── 本月工作构成: duties, not tasks — and no month filter ───────────────── + * + * The pie counts `duly_duty.form` over the register: recurring / one-off / + * standing. It is the only place on this screen a STANDING duty appears at + * all, because a standing duty never generates a task and every other widget + * here counts tasks. The description says so, and carries the deck's own + * caption — the on-time rate counts recurring work only. + * + * There is no "this month" filter behind the deck's card name, and that is a + * decision rather than an omission: a duty is a standing definition and has no + * month. What "this month's work" can honestly mean over duties is the + * register as it stands now, which is what the measure's `status != retired` + * already says. A date window bolted on here would have to read + * `effective_from` / `effective_to`, which are null on almost every seeded + * duty — it would silently shrink the pie to whichever duties happened to + * carry an effective window, and read as a composition of the whole register. + * + * Two presentation properties of this chart come from the FIELD, not from + * here, and the metadata is written to agree with them rather than to fight + * them (measured in objectui's `core/src/utils/chart-presentation.ts`, + * `chartConfigPresentation`, and `DatasetWidget`'s `buildOptionColorMap` / + * `buildCategoryOrder`): + * + * - **Slice colour.** A select dimension's own option colours become + * `categoryColors`, which take PRECEDENCE over the positional `colors` + * palette. So the three hexes below are `duly_duty.form`'s own option + * colours, written out so the metadata claims what the screen actually + * draws — a different palette here would be inert decoration, and a + * contrast assertion over it would be testing a colour nobody sees. + * - **Slice order.** It is the picklist's declared option order, not the + * counts. That is the same count-independence the unit chart buys with + * `sortBy`, already true here by construction — which is why this widget + * authors no `sortBy` at all: a second, conflicting order. + * * ── No ranking of people ───────────────────────────────────────────────── * `owner` is a dimension on `duly_stagnation` and on `duly_workload`, and no * widget below selects it. Unit comparison is a workload question and is @@ -88,6 +195,15 @@ import { Dashboard } from '@objectstack/spec/ui'; * `#16515F` and `#5A3F0C` pass on white (8.8:1, 9.8:1) and land at 2.2:1 and * 2.0:1 on a dark card. * + * The three form colours the pie inherits were put through the identical + * test before this file agreed to restate them: `#2E7C8E` (recurring, the teal + * already here), `#8C6512` (one-off, L≈0.149 → 5.3:1 on white, 3.6:1 on + * #0B0F14) and `#576B73` (standing, L≈0.138 → 5.6:1 and 3.4:1). All three sit + * inside the same [0.118, 0.30] band, so the pie needs no per-theme colour + * either. The overdue bar reuses `#B07C17` rather than introducing a fourth + * fill: late and not-moving are the same ATTENTION role, and this app has one + * amber for it. + * * The same measurement is why `showDataLabels` stays off rather than being * left to the default: white text ON `#B07C17` is 3.7:1, which is a contrast * FAILURE for a value label (AA wants 4.5:1) — the light-fill-with-white-text @@ -216,6 +332,65 @@ export const DutyHealthDashboard = Dashboard.create({ layout: { x: 0, y: 4, w: 6, h: 3 }, }, + /** + * 3c. Overdue — the deck's 逾期项, and the second half of what #52 made + * expressible. + * + * `tasks_overdue` counts OPEN governed work past its own `late_after`, the + * deadline stamped at dispatch from the duty's grace. Its neighbour to the + * left is a rate over work that FINISHED; this is the same family of + * numbers asked of work that has not. Both read a write-once stamp, and + * neither compares a column to a column. + * + * `warning`, not `danger`: an overdue task is a thing to go and look at. + * The file header carries the refusal of the two-line `due_date < {today}` + * version, which is what this tile would be if the grace were dropped. + */ + { + id: 'overdue', + title: 'Overdue', + description: + 'Open governed tasks past their own deadline — the due date plus the grace their duty ' + + 'granted, as it stood when the task was dispatched.', + type: 'metric', + dataset: 'duly_duty_health', + values: ['tasks_overdue'], + colorVariant: 'warning', + layout: { x: 6, y: 4, w: 3, h: 3 }, + }, + + /** + * 3d. List completeness — the deck's 清单完备度. + * + * Approved duties as a share of the governed register, over + * `review_status`. NEVER a count of list items per person or per unit: + * that is the ranking `AGENTS.md` bans, and the resemblance between the + * two is the whole reason the dataset states the difference at length. + * + * `default` rather than an attention colour, for the same reason the + * on-time tile is: this is a number that is GOOD when it is high, and + * painting it as an alert would read as a problem at 100%. + * + * The card's "N to confirm / M to approve" sub-caption is missing on + * purpose — a dataset-bound tile renders `values[0]` and nothing else, and + * the authored sub-caption slot is unreachable on this path. Measured, and + * filed as objectui#7293; the file header has both citations. It is not + * faked into the description, which would be a static number that goes + * stale the first time somebody approves a duty. + */ + { + id: 'list_completeness', + title: 'List completeness', + description: + 'Approved duties as a share of the governed duty register. Retired duties are out of both ' + + 'halves; paused ones are still owed and stay in.', + type: 'metric', + dataset: 'duly_duty_register', + values: ['approved_rate'], + colorVariant: 'default', + layout: { x: 9, y: 4, w: 3, h: 3 }, + }, + /** * 4. By unit. Ordered by the unit DIMENSION, never by the count — * `sortBy` names a selected dimension, so the bar order is a property of @@ -298,5 +473,83 @@ export const DutyHealthDashboard = Dashboard.create({ options: { sortBy: 'due_week', sortOrder: 'asc' }, layout: { x: 7, y: 7, w: 5, h: 6 }, }, + + /** + * 6. Overdue by unit — the deck's 各部门逾期项分布. + * + * The same measure as the tile above, split by the org chart, and ordered + * by the unit DIMENSION exactly like the not-moving chart it sits under: + * `sortBy` names a selected dimension, so the bar order is a property of + * the org chart and not of who is doing badly this week. Ordering it by the + * count would turn a workload picture into a league table of units, which + * is the same mechanism the person rule exists to stop, one level up. + * + * One series, and `#B07C17` again: late and not-moving are one ATTENTION + * role and this app has one amber for it. Nothing here is `danger`. + * + * ⚠ Nothing in the platform checks that `sortBy` names something this + * widget selects (objectstack#14148 part B) — `test/dashboard.test.ts` + * resolves it. + */ + { + id: 'overdue_by_unit', + title: 'Overdue, by business unit', + description: + 'Open governed tasks past their own deadline, per unit. Units are ordered by name, ' + + 'never by the count.', + type: 'horizontal-bar', + dataset: 'duly_duty_health', + dimensions: ['business_unit'], + values: ['tasks_overdue'], + chartConfig: { + type: 'horizontal-bar', + colors: ['#B07C17'], + showLegend: false, + showDataLabels: false, + }, + options: { sortBy: 'business_unit', sortOrder: 'asc' }, + layout: { x: 0, y: 13, w: 7, h: 6 }, + }, + + /** + * 7. Work mix — the deck's 本月工作构成. + * + * The one widget on this screen that counts DUTIES rather than tasks, and + * therefore the only place a STANDING duty is visible at all: standing work + * never generates a task, so every other number here is structurally blind + * to it. The description says which population it is over and carries the + * deck's own caption — the on-time rate counts recurring work only — because + * a composition chart beside a completion rate invites exactly the reading + * that the rate covers all three slices. + * + * A pie is legitimate here where it never is for the stagnation buckets: + * these three forms PARTITION the register (a duty has exactly one `form`), + * while `>14d` and `>30d` nest. `test/dashboard.test.ts` enforces that + * distinction rather than banning the chart type. + * + * The palette restates `duly_duty.form`'s own option colours because a + * select dimension's option colours take precedence over the positional + * palette — see the file header. Slice order is the picklist's, which is + * why there is no `sortBy` here; `showDataLabels` stays off so no text is + * drawn on a fill, and the legend carries the names on the card background. + */ + { + id: 'work_mix', + title: 'Work mix', + description: + 'The governed duty register by form. This counts duties, not tasks — a standing duty ' + + 'never generates one — and the on-time rate counts recurring work only.', + type: 'pie', + dataset: 'duly_duty_register', + dimensions: ['form'], + values: ['duties_in_register'], + chartConfig: { + type: 'pie', + colors: ['#2E7C8E', '#8C6512', '#576B73'], + showLegend: true, + showDataLabels: false, + }, + layout: { x: 7, y: 13, w: 5, h: 6 }, + }, ], }); diff --git a/src/datasets/duty-health.dataset.ts b/src/datasets/duty-health.dataset.ts index ccb0d38..1f15e8a 100644 --- a/src/datasets/duty-health.dataset.ts +++ b/src/datasets/duty-health.dataset.ts @@ -61,6 +61,14 @@ import { governed } from './governed.js'; * exactly the direction a customer configures grace against, and wrong * invisibly. `test/dashboard.test.ts` and `test/datasets.test.ts` both pin it. * + * ── The same stamps answer a SECOND question: what is late right now ───── + * `tasks_done_on_time` / `tasks_completed_late` read `completed_late`, the + * verdict stamped at completion. `tasks_overdue` reads the other stamp, + * `late_after`, because there is no verdict on a task that has not finished — + * open work past its own dispatched deadline. One family of numbers, two + * stamps, and neither is a query-time comparison against today's + * `duty.grace_days`. + * * ── `tasks_due` excludes cancelled, in every dataset that uses the name ─── * A cancelled task was withdrawn: it was never owed, so it is neither load nor * the denominator of anything. Keeping it out is also what makes the counts @@ -145,6 +153,47 @@ export const DutyHealth = defineDataset({ aggregate: 'count', filter: governed({ status: 'done', completed_late: true }), }, + { + /** + * STILL OPEN and already past its own deadline — the p20 deck's + * 「逾期项」 tile, and the only lateness measure here that is not about + * completed work. + * + * Its neighbours above all count rows that have FINISHED and read the + * verdict `completed_late` stamped at completion. Nothing stamps a + * verdict on a task that has not finished, so this one reads the other + * write-once stamp instead: `late_after` (`due_date + duty.grace_days`, + * stamped at dispatch) against today. That is a plain date column + * compared to a date macro, which this filter grammar has always been + * able to express — the reason it could not be written before #52 is + * that the deadline itself did not exist on the row, not that the + * comparison was hard. + * + * ⛔ The two-line version of this measure is `due_date: { $lt: + * '{today}' }`, and it is the grace-free approximation the file header + * refuses: it marks late every task still inside the grace its own duty + * grants — wrong in exactly the direction a customer configures grace + * AGAINST, and wrong invisibly. `test/dashboard.test.ts` pins the + * measure side of that as well as the widget side. + * + * A task carrying no `late_after` is never counted, and that is correct + * rather than a gap: no deadline was ever stamped, so there is nothing + * to be past. It is the same direction as `completed_late: false` for a + * task with no due date — an absent deadline is not a missed one. + * + * `cancelled` and `skipped` are excluded by asking for the two OPEN + * statuses directly, which is also what makes this the population a + * manager can still act on: the tile answers "what is late and still + * mine to fix", not "what was late once". + */ + name: 'tasks_overdue', + label: 'Overdue', + aggregate: 'count', + filter: governed({ + status: { $in: ['open', 'in_progress'] }, + late_after: { $lt: '{today}' }, + }), + }, { /** * The product's headline number, at last expressible. diff --git a/src/datasets/duty-register.dataset.ts b/src/datasets/duty-register.dataset.ts new file mode 100644 index 0000000..4886a2b --- /dev/null +++ b/src/datasets/duty-register.dataset.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +import { governed } from './governed.js'; + +/** + * `duly_duty_register` — the DUTY LIST itself: is it confirmed, and what is on it. + * + * The only dataset here whose base object is `duly_duty`. Every other one + * counts TASKS — what a duty produced. This one counts the duties, which is + * the whole reason it exists as a fourth dataset rather than three more + * measures on `duly_duty_health`: a task-based dataset structurally cannot see + * a `standing` duty (it never generates a task) or an unapproved one (it never + * dispatches), and those two absences are precisely what the manager screen is + * being asked about. + * + * ── `source` here is the DUTY's own column, and that is not a slip ──────── + * `governed.ts` says to read `duly_task.source` rather than `duty.source`, and + * that instruction is about a TASK-based measure: a task carries its own copy, + * stamped at dispatch, so it keeps the caliber it was dispatched under even if + * the duty is later re-sourced. On this dataset the base object IS the duty, + * so `source` is not a join and there is no denormalised copy to prefer — the + * duty's own column is the only thing the question can mean. Same helper, same + * two values, same product rule: self-declared work is surfaced in the views + * and never scored here. + * + * ── Why `status != retired` and not `status == active` ─────────────────── + * The register is what the organisation currently owes, and a PAUSED duty is + * still owed — it is temporarily not dispatching, which is a scheduling state, + * not a withdrawal. A RETIRED duty was withdrawn, so it is no longer part of + * anything a completeness rate should be measured against. That is the same + * boundary `tasks_due` draws with `status != cancelled`, one object up, and it + * is drawn the same way on purpose: one rule for "withdrawn, therefore never + * owed", across the semantic layer. + * + * Excluding retired duties matters in the direction nobody checks: a rate over + * EVERY duty ever declared falls every time a duty is retired, so the screen + * would report the register getting worse as it is being tidied up. + * + * ── The completeness rate is over `review_status`, never over item counts ── + * `approved_rate` is `duties_approved / duties_in_register`. It answers "how + * much of the duty list has been through confirmation and approval", which is + * a governance fact each row states about itself. + * + * The thing it must never become is a count of ITEMS per person or per unit — + * "whose list is shortest" — which `AGENTS.md` bans outright ("Item counts are + * never ranked or compared, anywhere in the UI"). The two are easy to confuse + * because both are "completeness of the list", so the distinction is worth + * stating in one line: this measures whether the rows were AGREED, not how + * many rows there are. A short approved list is complete; a long unapproved + * one is not. There is deliberately no `owner` dimension here, so the ranking + * shape has nowhere to be authored even by accident. + * + * `review_status` is `duly_duty`'s own confirmation column (`to_confirm` -> + * `to_review` -> `approved`, with `returned` as the send-back), added by the + * duty-review card. `approved` is the only value that dispatches, which is + * what makes it the numerator: an unapproved duty produces no tasks, so it is + * absent from every other number on this screen without being absent from the + * obligation. + * + * ── What is NOT declared here, and why ─────────────────────────────────── + * - **No `owner` dimension.** See above; also the barrel-wide rule that no + * widget slices by a person (`test/dashboard.test.ts`). + * - **No `business_unit` dimension.** No widget asks for one yet. A dataset is + * a published contract (ADR-0021), so a dimension nobody binds is surface + * that has to be kept working forever to serve nothing. Add it with the + * widget that needs it. + * - **No `duties_to_confirm` / `duties_to_review` counts.** The card asks for + * them as a SUB-CAPTION under the completeness tile, and a dataset-bound KPI + * widget cannot render one today: it renders `values[0]` and drops the rest, + * and the authored sub-caption slot (`options.description`) is read only on + * the inline metric path, which no spec-valid widget can reach any more + * because `dataset` is required. Measured on objectui's + * `plugin-dashboard/src/DatasetWidget.tsx` (the `isMetric` branch) and + * `DashboardRenderer.tsx` (`tWidgetSubCaption`, used only under + * `isObjectProvider`). Filed as objectstack-ai/objectui#7293 rather than + * approximated; the tile ships with the rate, and the two counts arrive with + * the renderer. See the dashboard file header. + */ +export const DutyRegister = defineDataset({ + name: 'duly_duty_register', + label: 'Duty register', + description: + 'The governed duty list itself — how much of it has been confirmed and approved, and what forms of work it is made of. Counts duties, not the tasks they produce, so standing duties are visible here and nowhere else.', + + object: 'duly_duty', + + dimensions: [ + // The product's three forms, in the field's own vocabulary. A pie over this + // is the one place `standing` work appears on the manager screen at all. + { name: 'form', label: 'Form', field: 'form', type: 'string' }, + ], + + measures: [ + { + // The denominator, and the register's own size. Retired duties are out + // (withdrawn, never owed); paused ones are in (owed, not dispatching). + name: 'duties_in_register', + label: 'Duties on the register', + aggregate: 'count', + filter: governed({ status: { $ne: 'retired' } }), + }, + { + name: 'duties_approved', + label: 'Approved duties', + aggregate: 'count', + filter: governed({ status: { $ne: 'retired' }, review_status: 'approved' }), + }, + { + /** + * A DERIVED measure (ADR-0021 Q1) — it names other measures and nothing + * else, so the caliber gate it inherits is theirs and cannot drift from + * them, exactly like `duly_duty_health.on_time_rate`. + * + * `format: '0.00'` and NOT `'percent'`, for the reason #101 measured on + * the on-time tile: `format` is a numeral PATTERN, the renderer takes the + * decimals from the digits after the point and switches to percent only + * on a literal `%`, so `'percent'` renders 0.94 as `1`. `'0.0%'` would + * print `94.0%` and is refused for the other half of #101: a percent is + * scaled by the heuristic `value > -1 && value < 1 ? value * 100 : value`, + * so a fully approved register — exactly 1, the number a customer most + * wants to see — renders as `1.0%`. Do not "fix" this here; #101 is where + * it is fixed. + */ + name: 'approved_rate', + label: 'Approved rate', + derived: { op: 'ratio', of: ['duties_approved', 'duties_in_register'] }, + format: '0.00', + }, + ], +}); diff --git a/src/datasets/index.ts b/src/datasets/index.ts index 1eab5ee..2191850 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -28,11 +28,21 @@ // the only thing standing between a typo here and a chart that renders empty while // every gate reports success. +// ⚠ Three of the four are based on `duly_task` and one — `duly_duty_register` +// — is based on `duly_duty`. That is not a detail of convenience: a task-based +// dataset structurally cannot see a `standing` duty (it never generates a +// task) or an unapproved one (it never dispatches), so a question about the +// DUTY LIST has to be asked of the duty list. Check which object a dataset +// sits on before adding a measure to it; a duty count and a task count that +// share a screen are two different populations, and only their names look +// alike. + import { DutyHealth } from './duty-health.dataset.js'; +import { DutyRegister } from './duty-register.dataset.js'; import { Stagnation } from './stagnation.dataset.js'; import { Workload } from './workload.dataset.js'; -export { DutyHealth, Stagnation, Workload }; +export { DutyHealth, DutyRegister, Stagnation, Workload }; export { GOVERNED_SOURCES, governed } from './governed.js'; -export const dulyDatasets = [DutyHealth, Stagnation, Workload]; +export const dulyDatasets = [DutyHealth, DutyRegister, Stagnation, Workload]; diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index 1367f68..6341120 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -587,6 +587,19 @@ export const dulyChinese = defineTranslationBundle({ title: '按期率', description: '在各自宽限期内完成的、组织认定的任务,占已完成的组织认定任务的比例。未完成的工作不计入这里。', }, + // 「逾期项」是方案 p8/p20 四卡之一。口径写全:判的是任务自身的逾期 + // 时点(派发当时按各自职责的宽限期钉住),不是「过了到期日」。 + overdue: { + title: '逾期项', + description: '已过自身逾期时点、仍未完成的组织认定任务。逾期时点 = 到期日 + 该条职责在派发当时给的宽限期。', + }, + // 「清单完备度」按 review_status 计数,绝不按条目数——「谁的清单条目 + // 少」是产品明令禁止的排名。副标「N 条待确认 / M 条待审定」当前渲染 + // 不出来(平台缺口 objectui#7293,见仪表盘文件头),所以这里不用静态文字假装有。 + list_completeness: { + title: '清单完备度', + description: '已审定的职责占组织认定职责清单的比例。已退役的职责分子分母都不计;暂停的职责仍然是欠着的,计入。', + }, not_moving_by_unit: { title: '停滞情况(按部门)', description: '各部门超过 14 天没有动静的、组织认定的待办任务。部门按名称排序,绝不按数量排序。', @@ -595,6 +608,16 @@ export const dulyChinese = defineTranslationBundle({ title: '即将到期', description: '未来 14 天内到期的、组织认定的任务,按周分组。', }, + overdue_by_unit: { + title: '各部门逾期项分布', + description: '各部门已过自身逾期时点、仍未完成的组织认定任务。部门按名称排序,绝不按数量排序。', + }, + // 方案 p20 的口径说明原话:「完成率只统计重复事项」。这张图数的是 + // 职责不是任务——常设职责永远不产生任务,所以它只在这里露面。 + work_mix: { + title: '本月工作构成', + description: '组织认定的职责清单按形态划分。这里数的是职责,不是任务——常设职责永远不产生任务;完成率只统计重复事项。', + }, }, }, }, diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index f4c0310..caa01b6 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -591,6 +591,49 @@ describe('lateness is on the screen, and only in the one shape that respects gra }); }); +describe('the four numbers the sales deck p20 promises are all on the screen', () => { + /** + * The deck's leadership first screen is four KPI cards: stagnation, overdue, + * on-time rate and list completeness. Two of them arrived late (#52 for the + * rate, this card for the other two), and the failure mode for a demo screen + * is not an error — it is a tile quietly missing, which reads as a screen + * that never promised it. So the four are pinned by the MEASURE each one + * 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 + * carries why (nested thresholds must not be summable by eye), and the >30d + * tile's "subset" wording is pinned above. + */ + const boundMeasures = new Set(allWidgets.flatMap((entry) => entry.widget.values ?? [])); + + it('binds a stagnation count, an overdue count, an on-time rate and a completeness rate', () => { + for (const measure of ['untouched_over_14d', 'tasks_overdue', 'on_time_rate', 'approved_rate']) { + expect(boundMeasures.has(measure), `no widget binds '${measure}' — a p20 card is missing`).toBe(true); + } + }); + + it('the work-mix chart counts DUTIES, because a standing duty has no task to count', () => { + // The one widget on this screen over `duly_duty`. Re-binding it to a + // task-based dataset would drop standing work entirely and lose nothing + // visible: the chart would still render, with two slices instead of three. + const mix = allWidgets.find((entry) => entry.widget.id === 'work_mix'); + expect(mix, 'the work-mix chart is gone').toBeDefined(); + const dataset = (dulyDatasets as unknown as DatasetLike[]) + .find((ds) => ds.name === String(mix!.widget.dataset)); + expect(dataset?.object, 'the work-mix chart counts tasks, not duties').toBe('duly_duty'); + expect(mix!.widget.dimensions).toEqual(['form']); + }); + + it('says the completion rate covers recurring work only, where the mix is shown', () => { + // p20's own caption. Beside a three-slice composition chart, an on-time + // rate reads as a rate over all three unless the screen says otherwise — + // and two of the three forms cannot have one. + const mix = allWidgets.find((entry) => entry.widget.id === 'work_mix')!; + expect(String(mix.widget.description ?? '').toLowerCase()).toContain('recurring'); + }); +}); + describe('the caliber note is on the screen, not in a tooltip', () => { it('the dashboard description carries it', () => { for (const dashboard of dashboards) { diff --git a/test/datasets.test.ts b/test/datasets.test.ts index 6e1e2eb..599e37a 100644 --- a/test/datasets.test.ts +++ b/test/datasets.test.ts @@ -75,15 +75,29 @@ describe('dataset protocol', () => { } }); - it('the barrel carries the three datasets the manager side binds', () => { + it('the barrel carries the four datasets the manager side binds', () => { // Dashboards bind BY NAME (#10). A rename here is a silent empty chart // there, so the names are pinned rather than derived. expect(dulyDatasets.map((ds) => ds.name)).toEqual([ 'duly_duty_health', + 'duly_duty_register', 'duly_stagnation', 'duly_workload', ]); }); + + it('three are task-based and exactly one asks about the duties themselves', () => { + // Not a tally for its own sake: a task-based dataset structurally cannot + // see a `standing` duty (it never generates a task) or an unapproved one + // (it never dispatches). A measure about the duty LIST added to a + // task-based dataset would answer a smaller population under the right + // name — and would do it silently, because both are just counts. + const byObject = new Map(dulyDatasets.map((ds) => [ds.name, ds.object])); + expect([...byObject].filter(([, object]) => object === 'duly_duty').map(([name]) => name)) + .toEqual(['duly_duty_register']); + expect([...byObject].filter(([, object]) => object === 'duly_task').map(([name]) => name)) + .toEqual(['duly_duty_health', 'duly_stagnation', 'duly_workload']); + }); }); describe('caliber — self-declared work is surfaced, never scored', () => { @@ -325,6 +339,32 @@ describe('duly_duty_health', () => { expect(health.include).toContain('duty'); }); + it('the overdue count reads the dispatched deadline, never a due-date window', () => { + // The grace-free approximation is two lines wherever it is written, and a + // measure is the place a reviewer is least likely to look for it: a + // `due_date < {today}` count marks late every task still inside the grace + // its own duty granted. `late_after` is that grace, resolved at dispatch + // and stored (#52), so the comparison is a plain column against a macro. + const overdue = health.measures.find((m) => m.name === 'tasks_overdue')!; + const entries = filterEntries(overdue.filter); + expect(entries.find(([key]) => key === 'late_after')?.[1]).toEqual({ $lt: '{today}' }); + expect(deepText(overdue).join(' '), 'tasks_overdue reads due_date').not.toContain('due_date'); + + const threshold = entries.find(([key]) => key === 'late_after.$lt')?.[1]; + const match = String(threshold).match(DATE_MACRO_WRAPPED_RE); + expect(match, 'the overdue threshold is not a {placeholder}').not.toBeNull(); + expect(isDateMacroToken(match![1]), 'the overdue threshold is not a known token').toBe(true); + }); + + it('overdue counts only work somebody can still act on', () => { + // Open work, so the tile answers "what is late and still mine to fix". + // Folding in done/skipped/cancelled would make it a historical count under + // a present-tense name. + const overdue = health.measures.find((m) => m.name === 'tasks_overdue')!; + const entries = filterEntries(overdue.filter); + expect(entries.find(([key]) => key === 'status')?.[1]).toEqual({ $in: ['open', 'in_progress'] }); + }); + it('does not denormalise grace_days onto the task', () => { // A copy on `duly_task` would be a second writer that drifts the day a duty // is re-graced — AGENTS.md rule 5. If a measure ever needs grace, it reads @@ -336,6 +376,61 @@ describe('duly_duty_health', () => { }); }); +describe('duly_duty_register — the duty list, not the tasks it produces', () => { + const register = dulyDatasets.find((ds) => ds.name === 'duly_duty_register')!; + + it('is based on duly_duty, which is what makes standing duties countable at all', () => { + // A standing duty NEVER generates a task, so no task-based dataset can see + // one. This is the only dataset that can, and the work-mix chart is the + // only place on the manager screen where standing work appears. + expect(register.object).toBe('duly_duty'); + }); + + it('completeness is measured over review_status, never over how many items a list has', () => { + // The product invariant this measure is one careless ticket away from + // breaking: "Item counts are never ranked or compared, anywhere in the UI" + // (AGENTS.md). "Whose list is shortest" and "how much of the list was + // agreed" are both called completeness, and only one of them is allowed. + const approved = register.measures.find((m) => m.name === 'duties_approved')!; + const entries = filterEntries(approved.filter); + expect(entries.find(([key]) => key === 'review_status')?.[1]).toBe('approved'); + // The ranking shape needs a person axis to be authored at all. There is + // none here, and there must not be one. + expect(register.dimensions.map((d) => d.name)).toEqual(['form']); + }); + + it('the rate is a ratio of two measures of this dataset, so it cannot drift from them', () => { + const rate = register.measures.find((m) => m.name === 'approved_rate')!; + expect((rate as { derived?: { op: string; of: string[] } }).derived).toEqual({ + op: 'ratio', + of: ['duties_approved', 'duties_in_register'], + }); + // #101: `format` is a numeral PATTERN, not a keyword — 'percent' renders + // 0.94 as `1`. The on-time tile measured that; this one must not + // rediscover it. + expect((rate as { format?: string }).format).toBe('0.00'); + }); + + it('a retired duty is out of BOTH halves of the rate — withdrawn, therefore never owed', () => { + // Asymmetry here would be the invisible bug: retired duties left in the + // denominator make the register look worse every time it is tidied up. + for (const name of ['duties_in_register', 'duties_approved']) { + const measure = register.measures.find((m) => m.name === name)!; + const entries = filterEntries(measure.filter); + expect(entries.find(([key]) => key === 'status')?.[1], name).toEqual({ $ne: 'retired' }); + } + }); + + it('a paused duty is still on the register — it is owed, just not dispatching', () => { + // The converse of the rule above, and the reason the filter is `!= retired` + // rather than `== active`: pausing is a scheduling state, not a withdrawal. + for (const measure of register.measures) { + const text = deepText(measure).join(' '); + expect(text, `${measure.name} excludes paused duties`).not.toContain('paused'); + } + }); +}); + describe('duly_workload — the forward look', () => { const workload = dulyDatasets.find((ds) => ds.name === 'duly_workload')!; diff --git a/test/i18n-coverage.test.ts b/test/i18n-coverage.test.ts index d328ccd..1d4d01c 100644 --- a/test/i18n-coverage.test.ts +++ b/test/i18n-coverage.test.ts @@ -308,7 +308,14 @@ describe('untranslatable display text is declared rather than dropped', () => { // label still has no bundle key anywhere in the platform's schema, so each // new one enlarges the same declared gap rather than opening a new kind of // one; the number moves with the code because that is what this pin is for. - expect(count('dataset.'), 'dataset labels behind chart axes').toBe(29); + // + // 29 -> 36 with the p20 dashboard set: `duly_duty_health.Overdue` (+1) and + // the whole of `duly_duty_register` — its label, its description, one + // dimension label and three measure labels (+6). Same declared gap, seven + // strings wider. It closes for all of them at once when the platform makes + // datasets translatable (duly#106 / objectstack#14253); nothing here works + // around it in the meantime. + expect(count('dataset.'), 'dataset labels behind chart axes').toBe(36); // Was 6 before #99 (#69) landed: three `notify` nodes' inline title and // message. They now reference an email template, whose per-locale rows are // checked below — so this is a gap that CLOSED, pinned at zero so it