From a078c63ff8b576d6057e3248ce99620f36773abd Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 1 Sep 2026 08:08:05 +0000 Subject: [PATCH 1/2] Manager dashboard: what has stopped, and what is coming up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `duly_duty_health`, bound entirely to the semantic layer, and reaches it from the Team nav group. Two of the card's five items — "Late" and "On-time rate" — are the same missing comparison (`due_date + duty.grace_days`, objectstack#14104) and are deliberately not approximated; the product decision is open on #52. The absence is stated on the screen rather than left silent. `test/dashboard.test.ts` resolves every widget binding against the datasets barrel: nothing in the platform does, and an empty "not moving" tile reads exactly like a healthy team. `test/metadata-bindings.test.ts` is taught the `dashboard` nav type it deliberately fails on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/apps/duly.app.ts | 6 + src/dashboards/duty-health.dashboard.ts | 261 ++++++++ src/dashboards/index.ts | 16 +- test/dashboard.test.ts | 787 ++++++++++++++++++++++++ test/metadata-bindings.test.ts | 75 ++- 5 files changed, 1139 insertions(+), 6 deletions(-) create mode 100644 src/dashboards/duty-health.dashboard.ts create mode 100644 test/dashboard.test.ts diff --git a/src/apps/duly.app.ts b/src/apps/duly.app.ts index 7fbf9e2..4b017d6 100644 --- a/src/apps/duly.app.ts +++ b/src/apps/duly.app.ts @@ -42,6 +42,12 @@ export const DulyApp = App.create({ label: 'Team', icon: 'users', children: [ + // First in the group, and the only non-list entry in it: this is the + // screen a manager opens to be told what to look at, and every entry + // below it is a list they go to once it has told them. A `dashboard` + // nav item carries `dashboardName` (resolved against the dashboards + // barrel), never an `objectName` — nothing on it is entered. + { id: 'nav_duty_health', type: 'dashboard', dashboardName: 'duly_duty_health', label: 'Duty health', icon: 'activity' }, { id: 'nav_late', type: 'object', objectName: 'duly_task', viewName: 'late', label: 'Late', icon: 'alert-circle' }, { id: 'nav_stalled', type: 'object', objectName: 'duly_task', viewName: 'stalled', label: 'Not moving', icon: 'pause-circle' }, { id: 'nav_assignments', type: 'object', objectName: 'duly_assignment', viewName: 'sent_by_me', label: 'Assignments', icon: 'send' }, diff --git a/src/dashboards/duty-health.dashboard.ts b/src/dashboards/duty-health.dashboard.ts new file mode 100644 index 0000000..ac971a8 --- /dev/null +++ b/src/dashboards/duty-health.dashboard.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Dashboard } from '@objectstack/spec/ui'; + +/** + * `duly_duty_health` — the manager's one screen, entered by nobody. + * + * Every widget binds a DATASET (ADR-0021), never an object: the numbers here + * are the same numbers the semantic layer gives every other surface, and the + * caliber gate (`source IN ('catalog','assigned')`) rides on the measures + * rather than on this file. Nothing on this screen writes — managers read + * here, and assigning is their only write in the product. + * + * ── What is NOT on this dashboard, and why that is deliberate ───────────── + * + * The card asked for five things in reading order. Two of them — *"Late"* and + * *"On-time rate, current period"* — are the SAME missing comparison, not two + * separate omissions: + * + * completed_at <= due_date + duty.grace_days (on-time) + * now > due_date + duty.grace_days (late) + * + * Both need `due_date + duty.grace_days`, and the filter grammar cannot say + * it: there is no date arithmetic in `FILTER_OPERATORS`, the `{N_days_ago}` + * macro vocabulary is relative to NOW and never to another column, and the + * offset here is itself a column. Column-to-column comparison (`$field`) is + * refused by `driver-sql` while the in-memory evaluator resolves it, so even + * the half that parses would be a per-deployment answer. Filed upstream as + * **objectstack-ai/objectstack#14104**; `src/datasets/duty-health.dataset.ts` + * carries the full measurement, and it is why no dataset in this app declares + * an on-time or a late measure for a widget to bind. + * + * A grace-free approximation IS expressible here — a widget `filter` of + * `due_date < {today}` over `tasks_due` needs no platform change at all — and + * it is deliberately not built. It marks late every task still inside the + * grace window its own duty grants, so a customer who configured 7 days of + * grace gets its people listed as late the morning after the due date. That + * is not a rounding error; it is wrong in exactly the direction the customer + * configured against, and it would be wrong invisibly. The product decision is + * open on **#52** (with **#48** on the `late` LIST view, which does carry the + * grace-free definition today); when it lands, the tile lands with it. + * + * The reordering is an improvement rather than a loss: the card already wanted + * the not-moving tile to be the visual focus, and with lateness deferred it is + * unambiguously the headline. Stagnation is also the EARLIER signal — a task + * untouched for three weeks and not due for another two is already a problem, + * and no lateness measure can see it until it is too late to act. + * + * The absence is stated on the screen itself, in `description` below, for the + * same reason the caliber note is: a manager reading "not moving: 3" on a + * dashboard that says nothing about lateness will conclude there are three + * problems. An unexplained absence is a wrong number with no digits. + * + * ── Shapes this file is authored around ────────────────────────────────── + * + * - **Stagnation buckets are CUMULATIVE.** `untouched_over_14d` counts + * everything `untouched_over_30d` counts. They are nested thresholds, not + * bands: they are never summed, never stacked in one bar, and never put in + * 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. + * - **`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 + * the forward look is scoped by a date window only. Narrowing it by status + * would put a different number behind the same name. + * - **The governed filter is already on every measure.** This file neither + * adds it nor removes it. + * + * ── 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 + * fine; person comparison is a performance score, and this product does not + * have one. `test/dashboard.test.ts` pins that as a property of the barrel + * rather than of these five widgets, so a sixth widget cannot quietly add it. + * + * ── Colour, in both themes ─────────────────────────────────────────────── + * Late and not-moving are ATTENTION, not blame: the tiles use `warning` / + * `orange` and never `danger`, and the charts use one amber and one teal from + * the app's own palette. + * + * Text is never drawn on a fill anywhere on this screen. `showDataLabels` is + * explicitly `false` on both charts (it is also the default — stated because + * it is the contrast-critical key, not decoration), so every label renders as + * axis or legend text on the card background, which the theme owns and keeps + * legible in both modes. That leaves the fills themselves, which must clear + * 3:1 as graphical objects (WCAG 1.4.11) against BOTH a white and a near-black + * card. Metadata cannot carry a per-theme colour, so both hexes are mid-tones + * inside the band where that is true — relative luminance L in [0.118, 0.30]: + * `#B07C17` (L≈0.237 → 3.7:1 on white, 5.3:1 on #0B0F14) and `#2E7C8E` + * (L≈0.169 → 4.8:1 on white, 4.0:1 on #0B0F14). The app's darker palette + * entries were measured and rejected for chart FILLS on exactly this test: + * `#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 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 + * mistake that reads fine on the author's screen. Any fill that would carry a + * legible white label would itself be too dark to clear 3:1 on a dark card. + * The two constraints do not have a common solution in one hex, so the labels + * come off the fill instead of the fill coming off the palette. + */ +export const DutyHealthDashboard = Dashboard.create({ + name: 'duly_duty_health', + label: 'Duty health', + + /** + * Rendered under the title by the header (`showDescription`), which is what + * the card's "on the dashboard, not buried in a tooltip" asks for. Two + * sentences, both load-bearing: the caliber note explains why these numbers + * are smaller than a raw task count, and the second explains an absence a + * manager would otherwise read as good news. + */ + description: + 'Governed duties only — role-catalog and manager-assigned work. Self-declared duties are ' + + 'excluded from every number here. Lateness is not shown yet: it depends on each duty\'s ' + + 'grace period, which this view cannot apply — the Team → Late list is the interim answer ' + + 'and it does not account for grace.', + + header: { + showTitle: true, + // Load-bearing rather than a restated default: the caliber note lives in + // `description`, so turning this off would silently delete it. + showDescription: true, + // No actions. Nothing on this dashboard is editable, and a header action + // is the only affordance that dispatches one (a widget has no button — + // `widgets[].actionUrl` was retired in 17.0.0). + }, + + columns: 12, + gap: 4, + + widgets: [ + /** + * 1. THE HEADLINE. Top-left, and the largest tile on the screen — the + * earliest actionable signal, and the one no other tool in the stack + * gives a manager. + */ + { + id: 'not_moving_14d', + title: 'Not moving', + description: + 'Open governed tasks untouched for more than 14 days. Governed duties only; ' + + 'self-declared work is excluded.', + type: 'metric', + dataset: 'duly_stagnation', + values: ['untouched_over_14d'], + colorVariant: 'warning', + layout: { x: 0, y: 0, w: 6, h: 4 }, + }, + + /** + * 2. The >30d count, beside it. A SEPARATE tile because the thresholds + * nest: every task counted here is also counted above, and two tiles + * cannot be added up by eye the way two stacked bars invite. + * + * `orange` rather than `danger`: deeper attention, not a failure verdict. + */ + { + id: 'not_moving_30d', + title: 'Not moving over 30 days', + description: 'A subset of the tile beside it, not an addition to it.', + type: 'metric', + dataset: 'duly_stagnation', + values: ['untouched_over_30d'], + colorVariant: 'orange', + layout: { x: 6, y: 0, w: 3, 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. + */ + { + 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 }, + }, + + /** + * 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 + * the org chart and not of who is doing badly this week. Unit comparison + * is a workload question; the same chart keyed on `owner` would be a + * performance score, which is why `owner` appears nowhere in this file. + * + * One series, deliberately: `untouched_over_14d` and `untouched_over_30d` + * are nested thresholds, so a second series here would invite exactly the + * addition the nesting forbids. The >30d number is a tile of its own + * above. + */ + { + id: 'not_moving_by_unit', + title: 'Not moving, by business unit', + description: + 'Open governed tasks untouched over 14 days, per unit. Units are ordered by name, ' + + 'never by the count.', + type: 'horizontal-bar', + dataset: 'duly_stagnation', + dimensions: ['business_unit'], + values: ['untouched_over_14d'], + chartConfig: { + type: 'horizontal-bar', + colors: ['#B07C17'], + showLegend: false, + showDataLabels: false, + }, + options: { sortBy: 'business_unit', sortOrder: 'asc' }, + layout: { x: 0, y: 4, w: 7, h: 6 }, + }, + + /** + * 5. Coming up — the forward look, so an overloaded fortnight is visible + * while it can still be rebalanced. + * + * The window is the widget's own presentation-scope `filter`, ANDed into + * the dataset query as `runtimeFilter`. Both tokens are real date macros + * (`DATE_MACRO_PARAM_RE`), resolved server-side before the driver sees + * them; an unknown token fails the build rather than comparing as a + * literal string and matching nothing. + * + * No status narrowing. `tasks_due` means "governed, not cancelled" in + * both datasets that declare it, and adding `status IN (open,in_progress)` + * here would put a different number behind that name on this one screen. + * The dataset was written for this widget: "governed tasks due, bucketed + * forward by week". + */ + { + id: 'coming_up', + title: 'Coming up', + description: 'Governed tasks due in the next 14 days, by week.', + type: 'bar', + dataset: 'duly_workload', + dimensions: ['due_week'], + values: ['tasks_due'], + filter: { + due_date: { $gte: '{today}', $lte: '{14_days_from_now}' }, + }, + chartConfig: { + type: 'bar', + colors: ['#2E7C8E'], + showLegend: false, + showDataLabels: false, + }, + // Chronological, and — like the chart above — independent of the count. + options: { sortBy: 'due_week', sortOrder: 'asc' }, + layout: { x: 7, y: 4, w: 5, h: 6 }, + }, + ], +}); diff --git a/src/dashboards/index.ts b/src/dashboards/index.ts index 12232aa..f515929 100644 --- a/src/dashboards/index.ts +++ b/src/dashboards/index.ts @@ -13,4 +13,18 @@ // makes `name` optional and fails the assignment. A named array is `never[]` // while empty and infers correctly the moment something is pushed into it. -export const dulyDashboards = []; +// ⚠ A widget binds its dataset, dimensions and measures BY NAME (ADR-0021), +// and NOTHING resolves those names at author time: `pnpm validate` and +// `pnpm build` both exit 0 on a widget naming a dataset, dimension or measure +// that does not exist, and the widget then renders EMPTY. An empty "not +// moving" tile reads exactly like a healthy team, which is the worst possible +// silent failure on this particular screen. `test/dashboard.test.ts` resolves +// every binding in this barrel against `dulyDatasets` until the platform +// does — same stopgap posture as `test/metadata-bindings.test.ts`, which +// covers views, datasets and nav but does NOT reach dashboard widgets. + +import { DutyHealthDashboard } from './duty-health.dashboard.js'; + +export { DutyHealthDashboard }; + +export const dulyDashboards = [DutyHealthDashboard]; diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts new file mode 100644 index 0000000..7895791 --- /dev/null +++ b/test/dashboard.test.ts @@ -0,0 +1,787 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from '@objectstack/spec/data'; +import { SystemFieldName } from '@objectstack/spec/system'; +import { DashboardSchema } from '@objectstack/spec/ui'; + +import { dulyApps } from '../src/apps/index.js'; +import { dulyDashboards } from '../src/dashboards/index.js'; +import { dulyDatasets } from '../src/datasets/index.js'; +import { dulyObjects } from '../src/objects/index.js'; + +/** + * The manager dashboard, pinned on the two axes a schema cannot see. + * + * ── 1. Bindings, because NOTHING else resolves them ────────────────────── + * + * ⚠️ STOPGAP for the widget half of objectstack#14105 — delete this section + * when the platform resolves dataset references at author time. + * + * A widget names its dataset, dimensions and measures BY NAME (ADR-0021), and + * a name that resolves to nothing renders an EMPTY widget while `validate` + * and `build` both exit 0 (measured on `@objectstack/cli` 17.2.0; see the + * table in the ablation note at the bottom of this file). `test/ + * metadata-bindings.test.ts` walks views, datasets and nav — it does NOT walk + * dashboard widgets, and its `dulyDashboards` import exists only to resolve + * the nav entry's `dashboardName`. + * + * On this screen specifically, an empty tile is the worst available failure: + * **an empty "not moving" tile is indistinguishable from a healthy team.** A + * number that is missing reads as a number that is zero, and zero is exactly + * the answer a manager hopes for. That is why the binding walk below is a + * resolver with self-tests rather than a handful of `toBeDefined()` calls. + * + * ── 2. The product invariants ──────────────────────────────────────────── + * + * No ranking of people, nothing editable, the caliber note on the screen, the + * cumulative buckets never summed, and the lateness number that must stay + * ABSENT until #52 decides what "late" means. Every walk iterates + * `dulyDashboards`, so a second dashboard is covered the moment it enters the + * barrel — a rule with an opt-out list is a rule with a countdown on it. + */ + +type Rec = Record; + +interface Widget extends Rec { + readonly id?: string; + readonly type?: string; + readonly dataset?: string; + readonly dimensions?: readonly string[]; + readonly values?: readonly string[]; + readonly filter?: Rec; + readonly options?: Rec; + readonly layout?: { x: number; y: number; w: number; h: number }; + readonly chartConfig?: Rec; +} + +interface Dash extends Rec { + readonly name?: string; + readonly description?: unknown; + readonly header?: Rec; + readonly widgets?: readonly Widget[]; +} + +const dashboards = dulyDashboards as unknown as readonly Dash[]; + +/** Every widget in the app, tagged with the dashboard it came from. */ +const allWidgets: ReadonlyArray<{ dashboard: string; widget: Widget }> = dashboards.flatMap((d) => + (d.widgets ?? []).map((widget) => ({ dashboard: String(d.name ?? '(unnamed)'), widget })), +); + +const site = (entry: { dashboard: string; widget: Widget }): string => + `dashboard ${entry.dashboard} · widget '${entry.widget.id ?? '(no id)'}'`; + +// ─── Resolution primitives ─────────────────────────────────────────────── + +/** + * Columns the platform puts on every object, read from the spec's own + * registry rather than transcribed — the hand-copied list in + * `test/views.test.ts` has already drifted, which is the reason + * `test/metadata-bindings.test.ts` imports this too. + */ +const SYSTEM_FIELDS: ReadonlySet = new Set(Object.values(SystemFieldName)); + +interface DeclaredObject { + readonly name: string; + readonly fields: Rec; +} + +interface DatasetLike { + readonly name: string; + readonly object: string; + readonly dimensions?: ReadonlyArray<{ name: string }>; + readonly measures?: ReadonlyArray<{ name: string }>; +} + +interface Finding { + readonly where: string; + readonly reference: string; + readonly reason: string; +} + +interface WalkResult { + readonly findings: Finding[]; + /** Resolved sites, so a walk that silently stopped walking is visible. */ + readonly resolved: string[]; + /** + * Filter keys this walk cannot judge — a dotted path through a join. No + * widget authors one today; the tripwire below fails the day one does, + * rather than letting it pass as resolved. + */ + readonly boundaries: string[]; +} + +/** + * Top-level field KEYS of a filter condition. + * + * The column is the KEY, not the value — `{ due_date: { $lt: '{today}' } }`. + * `test/datasets.test.ts` records what a values-only walk costs: its most + * important assertion stayed green with a real `due_date` condition injected. + * `$and` / `$or` / `$not` re-enter; every other `$` key is an operator, which + * lives one level DOWN from the column and is never a field. + */ +const filterFieldKeys = (filter: unknown, out: string[] = []): string[] => { + if (filter === null || typeof filter !== 'object' || Array.isArray(filter)) return out; + for (const [key, value] of Object.entries(filter as Rec)) { + if (key === '$and' || key === '$or') { + for (const branch of (value as unknown[]) ?? []) filterFieldKeys(branch, out); + continue; + } + if (key === '$not') { + filterFieldKeys(value, out); + continue; + } + if (key.startsWith('$')) continue; + out.push(key); + } + return out; +}; + +/** Every string anywhere in a node, KEYS INCLUDED. */ +const deepText = (node: unknown): string[] => { + if (typeof node === 'string') return [node]; + if (node === null || typeof node !== 'object') return []; + if (Array.isArray(node)) return node.flatMap(deepText); + return Object.entries(node as Rec).flatMap(([key, value]) => [key, ...deepText(value)]); +}; + +/** + * The whole rule, in one function, so the self-tests at the bottom exercise + * the same code path this app's real metadata goes through. + */ +export const dashboardBindingFindings = (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 ?? []) { + const where = `dashboard ${dashboard.name ?? '(unnamed)'} · widget '${widget.id ?? '(no id)'}'`; + + const datasetName = String(widget.dataset ?? ''); + const dataset = datasets.get(datasetName); + if (!dataset) { + result.findings.push({ + where, + reference: datasetName || '(none)', + reason: + `binds dataset "${datasetName || '(nothing)'}", which is not in the datasets barrel — ` + + `the widget renders EMPTY and every gate exits 0. Declared: ` + + `${[...datasets.keys()].sort().join(', ') || '(none)'}`, + }); + continue; + } + result.resolved.push(`${where} · dataset → ${datasetName}`); + + const dimensionNames = new Set((dataset.dimensions ?? []).map((d) => d.name)); + const measureNames = new Set((dataset.measures ?? []).map((m) => m.name)); + + for (const name of widget.dimensions ?? []) { + if (dimensionNames.has(name)) { + result.resolved.push(`${where} · dimensions[] → ${datasetName}.${name}`); + continue; + } + result.findings.push({ + where: `${where} · dimensions[]`, + reference: name, + reason: + `${datasetName} declares no dimension named "${name}" — the axis renders empty. ` + + `Declared: ${[...dimensionNames].sort().join(', ') || '(none)'}`, + }); + } + + for (const name of widget.values ?? []) { + if (measureNames.has(name)) { + result.resolved.push(`${where} · values[] → ${datasetName}.${name}`); + continue; + } + result.findings.push({ + where: `${where} · values[]`, + reference: name, + reason: + `${datasetName} declares no measure named "${name}" — the widget renders empty, which ` + + `on a count tile is indistinguishable from a zero. Declared: ` + + `${[...measureNames].sort().join(', ') || '(none)'}`, + }); + } + + /** + * `options.sortBy` must name something this widget SELECTS — the spec + * says so in its own words ("must be one this widget actually selects"). + * An unselected name is not ordered by anything; the runtime falls back + * to the selected dimensions and the authored order silently does not + * happen. + */ + const sortBy = widget.options?.sortBy; + if (typeof sortBy === 'string' && sortBy !== '') { + const selected = new Set([...(widget.dimensions ?? []), ...(widget.values ?? [])]); + if (selected.has(sortBy)) { + result.resolved.push(`${where} · options.sortBy → ${sortBy}`); + } else { + result.findings.push({ + where: `${where} · options.sortBy`, + reference: sortBy, + reason: + `orders by "${sortBy}", which this widget does not select — the order silently ` + + `falls back to the selected dimensions. Selected: ${[...selected].sort().join(', ')}`, + }); + } + } + + // The widget's own presentation filter is a data-layer condition ANDed + // into the dataset query, so its KEYS are field paths on the dataset's + // BASE OBJECT — not dimension names. (`due_date`, never `due_week`.) + const base = objects.get(dataset.object); + for (const key of filterFieldKeys(widget.filter)) { + if (key.includes('.')) { + result.boundaries.push(`${where} · filter key "${key}" reaches through a join`); + continue; + } + if (!base) { + result.findings.push({ + where: `${where} · filter`, + reference: key, + reason: `dataset ${datasetName} is based on "${dataset.object}", which this stack does not declare`, + }); + continue; + } + if (Object.hasOwn(base.fields, key) || SYSTEM_FIELDS.has(key)) { + result.resolved.push(`${where} · filter key → ${dataset.object}.${key}`); + continue; + } + result.findings.push({ + where: `${where} · filter`, + reference: key, + reason: + `filters on "${key}", which ${dataset.object} does not declare — the condition matches ` + + `nothing and the widget renders empty`, + }); + } + + /** + * Every `{token}` in the widget filter must be real. An unknown token is + * rejected by `@objectstack/lint`'s `validate-filter-tokens` at build + * time — this asserts the same property at the unit level, where the + * failure names the widget. + */ + for (const text of deepText(widget.filter)) { + const match = text.match(DATE_MACRO_WRAPPED_RE); + if (!match) continue; + if (isDateMacroToken(match[1]!)) { + result.resolved.push(`${where} · filter token → {${match[1]}}`); + continue; + } + result.findings.push({ + where: `${where} · filter`, + reference: text, + reason: `"${text}" is not a date-macro token — it would compare as a literal string and match nothing`, + }); + } + } + } + + return result; +}; + +const result = dashboardBindingFindings({ + dashboards, + datasets: dulyDatasets as unknown as DatasetLike[], + objects: dulyObjects as unknown as DeclaredObject[], +}); + +// ─── The dashboard itself ──────────────────────────────────────────────── + +describe('dashboard protocol', () => { + it('every dashboard parses against the dashboard schema', () => { + for (const dashboard of dashboards) { + expect(() => DashboardSchema.parse(dashboard), `dashboard ${dashboard.name}`).not.toThrow(); + } + }); + + it('the barrel carries the manager dashboard', () => { + expect(dashboards.map((d) => d.name)).toEqual(['duly_duty_health']); + }); + + it('is reachable from the app — a `dashboard` nav entry in the Team group', () => { + const app = (dulyApps as unknown as Array<{ name: string; navigation: Rec[] }>)[0]!; + const team = app.navigation.find((item) => item.id === 'group_team') as + | { children?: Rec[] } + | undefined; + expect(team, 'the Team nav group is gone').toBeDefined(); + + const entries = (team!.children ?? []).filter((item) => item.type === 'dashboard'); + expect(entries.map((item) => item.dashboardName)).toEqual(['duly_duty_health']); + // A metadata file not reachable from the app is dead metadata that + // type-checks — the nav entry is the whole difference between a screen and + // a source file. (`metadata-bindings.test.ts` resolves the NAME; this pins + // the placement the card asks for.) + expect(entries[0]!.id).toBe('nav_duty_health'); + }); +}); + +// ─── Bindings ──────────────────────────────────────────────────────────── + +describe('widget bindings — every reference resolves (stopgap for the widget half of objectstack#14105)', () => { + it('every widget dataset, dimension, measure, filter key and date macro names something real', () => { + expect( + result.findings.map((f) => `${f.where}: "${f.reference}" — ${f.reason}`), + 'a dashboard binding that resolves to nothing; validate and build both exit 0 on these, and ' + + 'the widget renders empty', + ).toEqual([]); + }); + + it('resolved enough references to prove the walk ran', () => { + // Green-because-vacuous is the failure mode a binding guard dies of. + expect(result.resolved.length, 'the walk resolved implausibly few references').toBeGreaterThan(10); + for (const kind of ['dataset →', 'dimensions[] →', 'values[] →', 'filter key →', 'filter token →']) { + expect( + result.resolved.some((entry) => entry.includes(kind)), + `no ${kind} reference was resolved at all — that part of the walk is not running`, + ).toBe(true); + } + }); + + it('no widget filter reaches through a join, which this walk cannot resolve', () => { + expect( + result.boundaries, + 'a widget filters on a dotted path; teach this walk to resolve joins before trusting it', + ).toEqual([]); + }); + + it('every widget binds a DATASET, never an object', () => { + for (const entry of allWidgets) { + expect(typeof entry.widget.dataset, `${site(entry)} has no dataset`).toBe('string'); + // The pre-ADR-0021 inline shape. `DashboardWidgetSchema` rejects these + // with a prescription, so this is belt-and-braces on the ONE rule the + // card states first — and it also covers a widget object that never + // reached the schema (a fixture, a future builder). + for (const key of ['object', 'objectName', 'categoryField', 'valueField', 'aggregate']) { + expect(Object.hasOwn(entry.widget, key), `${site(entry)} carries the legacy key '${key}'`).toBe(false); + } + } + }); +}); + +// ─── Product invariants ────────────────────────────────────────────────── + +/** + * A dimension is a PERSON dimension when the field it reads is a lookup at + * `sys_user`. Read off the object rather than from a name list: `owner` is + * the only one today, and a second one added later must not need this file + * edited to be covered. + */ +const personDimensionNames = (datasetName: string): ReadonlySet => { + const dataset = (dulyDatasets as unknown as DatasetLike[]).find((d) => d.name === datasetName); + if (!dataset) return new Set(); + const base = (dulyObjects as unknown as DeclaredObject[]).find((o) => o.name === dataset.object); + if (!base) return new Set(); + const names = new Set(); + for (const dimension of (dataset.dimensions ?? []) as Array<{ name: string; field?: string }>) { + const field = base.fields[String(dimension.field ?? dimension.name)] as Rec | undefined; + if (field && field.reference === 'sys_user') names.add(dimension.name); + } + return names; +}; + +describe('no ranking of people', () => { + it('no widget slices by a person dimension at all', () => { + const offenders = allWidgets.flatMap((entry) => { + const people = personDimensionNames(String(entry.widget.dataset ?? '')); + return (entry.widget.dimensions ?? []) + .filter((name) => people.has(name)) + .map((name) => `${site(entry)} groups by '${name}'`); + }); + expect( + offenders, + 'a manager screen that groups counts by person is a performance score, whatever the title ' + + 'says. Unit comparison is a workload question and is fine; person comparison is not, and ' + + 'this product does not have one.', + ).toEqual([]); + }); + + it('no widget orders a person dimension by a count, or truncates one to a top N', () => { + // The rule that must hold even if a per-person WORKLOAD widget is ever + // added deliberately (`duly_workload` keeps `owner` for exactly that: "is + // ONE person's next fortnight unsurvivable"). Ordering that by a measure, + // or cutting it to the worst N, is the ranking the workload question is + // not. + for (const entry of allWidgets) { + const people = personDimensionNames(String(entry.widget.dataset ?? '')); + const slicesPeople = (entry.widget.dimensions ?? []).some((name) => people.has(name)); + if (!slicesPeople) continue; + const sortBy = entry.widget.options?.sortBy; + expect( + (entry.widget.values ?? []).includes(String(sortBy ?? '')), + `${site(entry)} sorts a person dimension by the measure '${String(sortBy)}'`, + ).toBe(false); + expect(entry.widget.options?.limit, `${site(entry)} truncates a person dimension to a top N`) + .toBeUndefined(); + } + }); + + it('every widget that orders at all orders by something count-independent, or by a date', () => { + // The card's rule for the unit chart, stated as a property: unit order is + // the org chart's, never this week's counts. + for (const entry of allWidgets) { + const sortBy = entry.widget.options?.sortBy; + if (typeof sortBy !== 'string' || sortBy === '') continue; + expect( + (entry.widget.dimensions ?? []).includes(sortBy), + `${site(entry)} orders by '${sortBy}', which is a measure — order by a dimension so the ` + + 'order cannot move when the numbers do', + ).toBe(true); + } + }); +}); + +describe('the numbers that must stay absent until #52 decides what "late" means', () => { + it('no widget binds an on-time or lateness measure', () => { + const offenders = allWidgets.flatMap((entry) => + (entry.widget.values ?? []) + .filter((name) => /on_?time|late|overdue/i.test(name)) + .map((name) => `${site(entry)} binds '${name}'`), + ); + expect( + offenders, + 'no dataset can express `completed_at <= due_date + duty.grace_days` (objectstack#14104), so ' + + 'a measure with one of these names is an approximation that ignores grace — see #52', + ).toEqual([]); + }); + + it('no widget rebuilds a grace-free lateness out of a due-date window', () => { + /** + * The approximation needs no platform change and no new measure: a widget + * `filter` of `due_date < {today}` over `tasks_due` is a "late" count in + * two lines. It is wrong for every duty with a non-zero `grace_days`, and + * wrong in the direction the customer configured AGAINST — a 7-day grace + * gets its people listed late the morning after the due date (#48 is that + * exact bug in the `late` LIST view). Bounding `due_date` ABOVE by now or + * by a past moment is the shape of it; bounding it above by a FUTURE token + * is the forward look, which is a different question and is allowed. + */ + const pastward = (comparand: unknown): boolean => { + if (typeof comparand !== 'string') return false; + const match = comparand.match(DATE_MACRO_WRAPPED_RE); + if (!match) return false; + const token = match[1]!; + return token === 'today' || token === 'now' || token === 'yesterday' || /_ago$/.test(token); + }; + + for (const entry of allWidgets) { + const dueDate = (entry.widget.filter ?? {}).due_date as Rec | undefined; + if (!dueDate || typeof dueDate !== 'object') continue; + for (const op of ['$lt', '$lte'] as const) { + expect( + pastward(dueDate[op]), + `${site(entry)} counts work whose due date is already past — that is a lateness number ` + + 'that ignores every duty\'s grace_days. The product decision is open on #52.', + ).toBe(false); + } + } + }); + + it('explains the absence on the screen, for as long as the number is missing', () => { + // An unexplained absence is a wrong number with no digits: a manager + // reading "not moving: 3" on a screen silent about lateness concludes + // there are three problems. This assertion dissolves on its own the day a + // lateness measure is bound — it only demands the sentence while the + // number is missing. + for (const dashboard of dashboards) { + const bindsLateness = (dashboard.widgets ?? []).some((w) => + (w.values ?? []).some((name) => /on_?time|late|overdue/i.test(name)), + ); + if (bindsLateness) continue; + expect( + String(dashboard.description ?? '').toLowerCase(), + `${dashboard.name} shows no lateness number and does not say so — silence reads as good news`, + ).toContain('grace'); + } + }); +}); + +describe('the caliber note is on the screen, not in a tooltip', () => { + it('the dashboard description carries it', () => { + for (const dashboard of dashboards) { + const description = String(dashboard.description ?? '').toLowerCase(); + expect(description, `${dashboard.name} has no caliber note`).toContain('governed'); + expect(description).toContain('self-declared'); + expect(description).toContain('excluded'); + } + }); + + it('the header renders the description — the note is not authored into a hidden slot', () => { + for (const dashboard of dashboards) { + // `showDescription` defaults to true, so the failure this catches is an + // explicit `false` added later by someone tidying the header — which + // would delete the note without touching the text. + expect(dashboard.header?.showDescription, `${dashboard.name} hides its own description`) + .not.toBe(false); + } + }); + + it('the headline tile repeats it, because a tile is read alone', () => { + const headline = allWidgets.find((entry) => entry.widget.id === 'not_moving_14d'); + expect(String(headline?.widget.description ?? '').toLowerCase()).toContain('self-declared'); + }); +}); + +describe('nothing on this dashboard is editable', () => { + it('declares no header actions — the only affordance a dashboard can dispatch', () => { + for (const dashboard of dashboards) { + const actions = (dashboard.header as { actions?: unknown[] } | undefined)?.actions ?? []; + expect(actions, `${dashboard.name} dispatches an action from its header`).toEqual([]); + } + }); + + it('no widget carries an action key', () => { + // `actionUrl` / `actionType` / `actionIcon` are retired keys — the schema + // rejects them with a prescription. Pinned here as a product rule too: + // managers read on this screen, and assigning is their only write. + for (const entry of allWidgets) { + for (const key of Object.keys(entry.widget)) { + expect(/^action/i.test(key), `${site(entry)} carries '${key}'`).toBe(false); + } + } + }); +}); + +describe('the not-moving tile is the visual focus', () => { + const headline = allWidgets.find((entry) => entry.widget.id === 'not_moving_14d')!; + + it('is the first widget, top-left', () => { + expect(allWidgets[0]!.widget.id, 'reading order starts somewhere else').toBe('not_moving_14d'); + expect(headline.widget.layout).toMatchObject({ x: 0, y: 0 }); + }); + + it('is the largest tile on the screen', () => { + /** + * "Largest" is judged against the other TILES, not against the charts: a + * bar chart needs plot area to be readable at all, and a KPI card that + * out-areas one would be a worse screen, not a more focused one. What the + * card is asking for is that no other NUMBER competes with this one — so + * that is what is asserted, plus the position above. + */ + const area = (w: Widget): number => (w.layout ? w.layout.w * w.layout.h : 0); + const tiles = allWidgets.filter((entry) => entry.widget.type === 'metric' || entry.widget.type === 'kpi'); + expect(tiles.length, 'no metric tiles at all').toBeGreaterThan(1); + for (const entry of tiles) { + if (entry.widget.id === headline.widget.id) continue; + expect(area(headline.widget), `${site(entry)} is at least as large as the headline`) + .toBeGreaterThan(area(entry.widget)); + } + }); + + it('nothing is placed above it or to its left', () => { + for (const entry of allWidgets) { + const layout = entry.widget.layout; + if (!layout) continue; + expect(layout.y, `${site(entry)} sits above the headline`).toBeGreaterThanOrEqual(0); + if (layout.y === 0 && entry.widget.id !== headline.widget.id) { + expect(layout.x, `${site(entry)} shares the top row and starts left of the headline's edge`) + .toBeGreaterThanOrEqual(headline.widget.layout!.w); + } + } + }); +}); + +describe('the stagnation buckets are cumulative, and are never drawn as if they partition', () => { + const CUMULATIVE = ['untouched_over_7d', 'untouched_over_14d', 'untouched_over_30d']; + /** Families that draw a value as a share of a whole. */ + const PART_OF_WHOLE = ['pie', 'donut', 'funnel', 'treemap', 'sankey', 'radar']; + + it('no widget puts two nested thresholds in one visualisation', () => { + for (const entry of allWidgets) { + const nested = (entry.widget.values ?? []).filter((name) => CUMULATIVE.includes(name)); + expect( + nested.length, + `${site(entry)} selects ${nested.join(' + ')} together — \`>14d\` COUNTS everything \`>30d\` ` + + 'counts, so side-by-side bars invite an addition that double-counts. Separate tiles, or ' + + 'difference them first.', + ).toBeLessThan(2); + } + }); + + it('no cumulative measure is drawn as a share of a whole, or stacked', () => { + for (const entry of allWidgets) { + const nested = (entry.widget.values ?? []).some((name) => CUMULATIVE.includes(name)); + if (!nested) continue; + expect( + PART_OF_WHOLE.includes(String(entry.widget.type)), + `${site(entry)} draws a cumulative threshold as a ${entry.widget.type}`, + ).toBe(false); + const series = (entry.widget.chartConfig?.series as Array<{ stack?: unknown }> | undefined) ?? []; + for (const one of series) { + expect(one.stack, `${site(entry)} stacks a cumulative threshold`).toBeUndefined(); + } + } + }); + + it('the >30d tile says it is a subset, in the description a reader sees', () => { + const tile = allWidgets.find((entry) => entry.widget.id === 'not_moving_30d'); + expect(String(tile?.widget.description ?? '').toLowerCase()).toContain('subset'); + }); +}); + +describe('contrast — no text is ever drawn on a fill', () => { + it('data labels are off on every chart', () => { + /** + * The one contrast failure this screen could ship: a value label printed + * ON a bar. Both chart fills are mid-tones chosen to clear 3:1 as + * graphical objects against a white AND a near-black card, and at that + * lightness white label text on the amber fill is 3.7:1 — a failure for + * text (AA wants 4.5:1). The two constraints have no common solution in + * one hex, so the labels come off the fill: every label on this screen + * renders as axis or legend text on the card background, which the theme + * owns. `showDataLabels` defaults to false; it is stated explicitly, and + * asserted here, because it is load-bearing rather than decorative. + */ + for (const entry of allWidgets) { + const config = entry.widget.chartConfig; + if (!config) continue; + expect(config.showDataLabels, `${site(entry)} prints value labels on its fills`).toBe(false); + } + }); + + it('every chart fill is a mid-tone that clears 3:1 on both a light and a dark card', () => { + // WCAG 1.4.11 for a graphical object, measured against #FFFFFF and + // #0B0F14. The band is relative luminance in [0.118, 0.30]: below it a + // fill disappears on a dark card, above it on a light one. Metadata + // carries no per-theme colour, so a single hex has to clear both. + const channel = (c: number): number => { + const s = c / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + const luminance = (hex: string): number => + 0.2126 * channel(parseInt(hex.slice(1, 3), 16)) + + 0.7152 * channel(parseInt(hex.slice(3, 5), 16)) + + 0.0722 * channel(parseInt(hex.slice(5, 7), 16)); + const ratio = (a: number, b: number): number => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + + const light = luminance('#FFFFFF'); + const dark = luminance('#0B0F14'); + let checked = 0; + for (const entry of allWidgets) { + const colors = entry.widget.chartConfig?.colors; + if (!Array.isArray(colors)) continue; + for (const hex of colors as string[]) { + expect(hex, `${site(entry)} uses a non-hex fill`).toMatch(/^#[0-9A-Fa-f]{6}$/); + const l = luminance(hex); + expect(ratio(l, light), `${site(entry)} fill ${hex} is invisible on a light card`) + .toBeGreaterThanOrEqual(3); + expect(ratio(l, dark), `${site(entry)} fill ${hex} is invisible on a dark card`) + .toBeGreaterThanOrEqual(3); + checked += 1; + } + } + expect(checked, 'no fill was checked — this assertion is vacuous').toBeGreaterThan(0); + }); + + it('nothing on this screen reads as blame', () => { + // Late and not-moving are ATTENTION, not failure. `danger` is the red the + // platform reserves for a failure state, and a stalled task is a thing to + // go and look at, not a verdict on a person. + for (const entry of allWidgets) { + expect(entry.widget.colorVariant, `${site(entry)} is painted as a failure`).not.toBe('danger'); + } + }); +}); + +// ─── The guard can fail (self-test on synthetic metadata) ──────────────── + +describe('widget bindings — the guard can fail (self-test on synthetic metadata)', () => { + /** + * A guard that has never been observed failing is indistinguishable from a + * guard that cannot fail. Same posture as `test/metadata-bindings.test.ts`: + * one fixture per reference kind, in both directions. + * + * These are also the ablation evidence for the platform gap this file + * stands in for. Measured on `@objectstack/cli` 17.2.0, with each mutation + * applied to the REAL dashboard and confirmed on disk before the run: + * + * | mutation on a widget | validate | build | this file | + * |-------------------------------|----------|-------|-----------| + * | dataset → `duly_stagnatoin` | 0 | 0 | fails | + * | measure → `untouched_over_14`| 0 | 0 | fails | + * | filter key → `due_daet` | 0 | 0 | fails | + */ + const objects: DeclaredObject[] = [ + { name: 'fx_task', fields: { status: { type: 'select' }, due_date: { type: 'date' } } }, + ]; + const datasets: DatasetLike[] = [ + { + name: 'fx_metrics', + object: 'fx_task', + dimensions: [{ name: 'week' }], + measures: [{ name: 'n' }], + }, + ]; + const widget = (overrides: Rec = {}): Widget => ({ + id: 'w', + type: 'bar', + dataset: 'fx_metrics', + dimensions: ['week'], + values: ['n'], + ...overrides, + }); + const run = (over: Rec = {}): WalkResult => + dashboardBindingFindings({ + dashboards: [{ name: 'fx_dash', widgets: [widget(over)] }], + datasets, + objects, + }); + + it('the baseline fixture is clean — otherwise every case below is meaningless', () => { + const clean = run(); + expect(clean.findings).toEqual([]); + expect(clean.boundaries).toEqual([]); + expect(clean.resolved.length).toBeGreaterThan(0); + }); + + it('fires on a dataset the barrel does not declare', () => { + const r = run({ dataset: 'fx_ghost' }); + expect(r.findings.map((f) => f.reference)).toEqual(['fx_ghost']); + }); + + it('fires on a dimension the dataset does not declare', () => { + const r = run({ dimensions: ['weekk'] }); + expect(r.findings.map((f) => f.reference)).toEqual(['weekk']); + expect(r.findings[0]!.where).toContain('dimensions[]'); + }); + + it('fires on a measure the dataset does not declare', () => { + const r = run({ values: ['nn'] }); + expect(r.findings.map((f) => f.reference)).toEqual(['nn']); + expect(r.findings[0]!.reason).toContain('indistinguishable from a zero'); + }); + + it('fires on a filter key the BASE OBJECT does not declare', () => { + const r = run({ filter: { due_daet: { $lt: '{today}' } } }); + expect(r.findings.map((f) => f.reference)).toEqual(['due_daet']); + }); + + it('fires on a filter token outside the date-macro vocabulary', () => { + const r = run({ filter: { due_date: { $lt: '{14_days_hence}' } } }); + expect(r.findings.map((f) => f.reference)).toEqual(['{14_days_hence}']); + }); + + it('fires on a sortBy naming something the widget does not select', () => { + const r = run({ options: { sortBy: 'month' } }); + expect(r.findings.map((f) => f.reference)).toEqual(['month']); + }); + + it('does not fire on a platform system column as a filter key', () => { + expect(run({ filter: { created_at: { $gte: '{30_days_ago}' } } }).findings).toEqual([]); + }); + + it('records a boundary — not a pass — for a filter key that reaches through a join', () => { + const r = run({ filter: { 'duty.frequency': 'monthly' } }); + expect(r.findings).toEqual([]); + expect(r.boundaries).toEqual([expect.stringContaining('duty.frequency')]); + }); +}); diff --git a/test/metadata-bindings.test.ts b/test/metadata-bindings.test.ts index ef40ef1..458488f 100644 --- a/test/metadata-bindings.test.ts +++ b/test/metadata-bindings.test.ts @@ -6,6 +6,7 @@ import { isPlatformProvidedObjectName } from '@objectstack/spec/system'; import { SystemFieldName } from '@objectstack/spec/system'; import { dulyApps } from '../src/apps/index.js'; +import { dulyDashboards } from '../src/dashboards/index.js'; import { dulyDatasets } from '../src/datasets/index.js'; import { dulyObjects } from '../src/objects/index.js'; import { dulyViews } from '../src/views/index.js'; @@ -413,6 +414,18 @@ interface Stack { readonly datasets: readonly unknown[]; readonly apps: readonly unknown[]; readonly objects: readonly DeclaredObject[]; + /** + * Dashboards are here for ONE reason: a `type: 'dashboard'` nav entry + * targets one by name, and an unresolvable `dashboardName` is the same + * #14108 failure as an unresolvable `viewName` — the shell has nothing to + * open and the authored label stays on the entry either way. + * + * The bindings INSIDE a dashboard (widget → dataset → dimension/measure, + * and the widget's own `filter` keys) are NOT walked here; they are the + * subject of `test/dashboard.test.ts`, which resolves them against + * `dulyDatasets`. Optional so the self-test fixtures below can omit it. + */ + readonly dashboards?: readonly unknown[]; } /** @@ -421,6 +434,9 @@ interface Stack { */ export const metadataBindingFindings = (stack: Stack): WalkResult => { const objects = new Map(stack.objects.map((o) => [o.name, o])); + const dashboardNames = new Set( + (stack.dashboards ?? []).map((d) => String((d as Rec).name ?? '')).filter(Boolean), + ); const resolveObject = makeObjectResolver(objects); const resolvePath = makePathResolver(resolveObject); const result = emptyResult(); @@ -620,11 +636,33 @@ export const metadataBindingFindings = (stack: Stack): WalkResult => { walkNav(item.children as Rec[] | undefined, appName); continue; } + if (type === 'dashboard') { + /** + * `DashboardNavItemSchema` carries `dashboardName`, not an object — + * so the reference to resolve is the DASHBOARD, and the failure it + * guards is #14108's: nothing resolves this name at author time, and + * a miss is a nav entry that opens nothing while keeping the label + * that promised a screen. + */ + const dashboardName = String(item.dashboardName ?? ''); + if (dashboardNames.has(dashboardName)) { + result.resolved.push(`${where} · dashboardName → ${dashboardName}`); + continue; + } + result.findings.push({ + where: `${where} · dashboardName`, + reference: dashboardName || '(none)', + reason: + `no dashboard named "${dashboardName || '(nothing)'}" is declared — the entry keeps its ` + + `authored label and opens nothing. Declared: ${[...dashboardNames].sort().join(', ') || '(none)'}`, + }); + continue; + } if (type !== 'object') { - // Not a hole: dashboard / page / url / report / action / component - // entries carry no object binding for this walk to resolve. The - // `knows every nav item type` tripwire is what keeps a NEW - // object-bearing type from slipping through unchecked. + // Not a hole: page / url / report / action / component entries carry + // no object binding for this walk to resolve. The `knows every nav + // item type` tripwire is what keeps a NEW object-bearing type from + // slipping through unchecked. result.unknownSlots.push(`${where} · nav type '${type}'`); continue; } @@ -728,6 +766,7 @@ const stack: Stack = { datasets: dulyDatasets as unknown[], apps: dulyApps as unknown[], objects: dulyObjects as unknown as DeclaredObject[], + dashboards: dulyDashboards as unknown[], }; const result = metadataBindingFindings(stack); @@ -831,8 +870,12 @@ describe('metadata bindings — every reference resolves (stopgap for objectstac } }; for (const app of stack.apps as Array<{ navigation?: Rec[] }>) walk(app.navigation); + // `dashboard` joined `group` / `object` when the manager dashboard landed: + // `walkNav` reads its `dashboardName` and resolves it against the + // dashboards barrel, so it is a READ type, not an ignored one. Anything + // else still fails here rather than passing unchecked. expect( - [...navTypes].filter((t) => t !== 'group' && t !== 'object').sort(), + [...navTypes].filter((t) => t !== 'group' && t !== 'object' && t !== 'dashboard').sort(), 'a nav item type this walk does not read — teach walkNav about it before trusting the nav check', ).toEqual([]); }); @@ -1084,6 +1127,28 @@ describe('metadata bindings — the guard can fail (self-test on synthetic metad expect(r.findings[0]!.reason).toContain('no default `list` view'); }); + // ── 5. Dashboard nav ─────────────────────────────────────────────────── + it('fires on a nav `dashboardName` that names no dashboard', () => { + const r = run({ + apps: [app([{ id: 'd', type: 'dashboard', dashboardName: 'fx_ghost', label: 'Ghost' }])], + dashboards: [{ name: 'fx_real' }], + }); + expect(r.findings).toHaveLength(1); + expect(r.findings[0]!.reference).toBe('fx_ghost'); + expect(r.findings[0]!.where).toContain('dashboardName'); + }); + + it('does not fire on a nav `dashboardName` that names a declared dashboard', () => { + const r = run({ + apps: [app([{ id: 'd', type: 'dashboard', dashboardName: 'fx_real', label: 'Real' }])], + dashboards: [{ name: 'fx_real' }], + }); + expect(messages(r)).toEqual([]); + expect(r.resolved.some((entry) => entry.endsWith('dashboardName → fx_real'))).toBe(true); + // And it is a READ type, not one dropped into `unknownSlots` unchecked. + expect(r.unknownSlots).toEqual([]); + }); + it('reports every dangling reference in one view, not just the first', () => { const r = run({ views: [view({ columns: [{ field: 'a_typo' }], filter: [{ field: 'b_typo', operator: 'equals', value: 1 }], sort: [{ field: 'c_typo', order: 'asc' }], grouping: { fields: [{ field: 'd_typo' }] } })], From 2d1a27e7bf5b17600f6e95b181c356035da62ead Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 1 Sep 2026 08:16:44 +0000 Subject: [PATCH 2/2] Correct every claim about what the platform actually guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card and its PM note assumed the whole dashboard binding surface was unguarded, carrying #14105 (datasets) up a layer. Measured instead, one mutation at a time on cli 17.2.0, and most of it IS guarded: widget dataset / dimensions / values and filter {tokens} all fail both gates with a named rule, and an unresolvable nav dashboardName is refused by defineStack itself. Two references are not resolved and fail silently — a widget's own filter KEYS, and options.sortBy naming something the widget does not select. Filed as objectstack#14148, with the note that the identical resolution already exists one key over (dashboard-filter-field-unknown, #3365). Comments that claimed otherwise are rewritten; the stopgap now points at the gap it actually stands in for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/dashboards/duty-health.dashboard.ts | 13 +++++ src/dashboards/index.ts | 38 +++++++++++--- test/dashboard.test.ts | 70 ++++++++++++++++--------- test/metadata-bindings.test.ts | 22 +++++--- 4 files changed, 103 insertions(+), 40 deletions(-) diff --git a/src/dashboards/duty-health.dashboard.ts b/src/dashboards/duty-health.dashboard.ts index ac971a8..c810524 100644 --- a/src/dashboards/duty-health.dashboard.ts +++ b/src/dashboards/duty-health.dashboard.ts @@ -195,6 +195,19 @@ export const DutyHealthDashboard = Dashboard.create({ * is a workload question; the same chart keyed on `owner` would be a * performance score, which is why `owner` appears nowhere in this file. * + * `business_unit` is a LOOKUP, and ordering one used to sort by the + * opaque FK id — "sorted by unit" that reads as random (objectstack#3680). + * That was fixed upstream in #3693: a select/lookup dimension is now + * ordered by the resolved display LABEL, which is what makes this the + * "unit name" order the card asks for rather than merely a count-free + * one. Worth a glance in a browser with real units — this repo cannot run + * the analytics query. + * + * ⚠ Nothing in the platform checks that `sortBy` names something this + * widget selects: a typo here exits 0 on both gates and the authored + * order silently does not happen (objectstack#14148 part B). That is why + * `test/dashboard.test.ts` resolves it. + * * One series, deliberately: `untouched_over_14d` and `untouched_over_30d` * are nested thresholds, so a second series here would invite exactly the * addition the nesting forbids. The >30d number is a tile of its own diff --git a/src/dashboards/index.ts b/src/dashboards/index.ts index f515929..a3f7a6d 100644 --- a/src/dashboards/index.ts +++ b/src/dashboards/index.ts @@ -14,14 +14,36 @@ // while empty and infers correctly the moment something is pushed into it. // ⚠ A widget binds its dataset, dimensions and measures BY NAME (ADR-0021), -// and NOTHING resolves those names at author time: `pnpm validate` and -// `pnpm build` both exit 0 on a widget naming a dataset, dimension or measure -// that does not exist, and the widget then renders EMPTY. An empty "not -// moving" tile reads exactly like a healthy team, which is the worst possible -// silent failure on this particular screen. `test/dashboard.test.ts` resolves -// every binding in this barrel against `dulyDatasets` until the platform -// does — same stopgap posture as `test/metadata-bindings.test.ts`, which -// covers views, datasets and nav but does NOT reach dashboard widgets. +// and — unlike the dataset-to-object binding one layer down (#14105) — the +// platform DOES resolve most of that at author time. Measured on +// `@objectstack/cli` 17.2.0 by mutating this dashboard, one reference at a +// time, and re-running both gates: +// +// | mutated reference | validate | build | rule | +// |---------------------------------------|----------|-------|-----------------| +// | widget `dataset` | 1 | 1 | widget-dataset-unknown | +// | widget `dimensions[]` | 1 | 1 | widget-dimension-unknown | +// | widget `values[]` (measure) | 1 | 1 | widget-measure-unknown | +// | `{token}` in a widget filter | 1 | 1 | filter-token-unknown | +// | nav `dashboardName` | 1 | 2 | defineStack cross-ref | +// | widget `filter` KEY (`due_daet`) | **0** | **0** | — | +// | `options.sortBy` naming nothing shown | **0** | **0** | — | +// +// So the last two are the holes, and the second-to-last is the sharpest one: +// on the SAME filter node, a bad date-macro token is caught path-precisely +// (`widgets[4].filter.due_date.$lte`) while a misspelt COLUMN is not — the +// traversal is there, only the field resolution is missing, which is exactly +// the asymmetry #14105 records one layer down. A widget filtering on a column +// that does not exist matches nothing and renders EMPTY, and an empty "not +// moving" tile reads exactly like a healthy team. +// +// Filed upstream as **objectstack-ai/objectstack#14148** (both halves, with +// the measurements above). `test/dashboard.test.ts` is the repo-local stopgap +// that closes them and is written to be DELETED when #14148 lands, not +// maintained — same posture as `test/flow-predicates.test.ts`. It also pins +// the product invariants, which are not going anywhere. +// `test/metadata-bindings.test.ts` covers views, datasets and nav, and does +// NOT reach inside a dashboard. import { DutyHealthDashboard } from './duty-health.dashboard.js'; diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index 7895791..c839b95 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -14,24 +14,42 @@ import { dulyObjects } from '../src/objects/index.js'; /** * The manager dashboard, pinned on the two axes a schema cannot see. * - * ── 1. Bindings, because NOTHING else resolves them ────────────────────── + * ── 1. Bindings — the TWO the platform does not resolve ────────────────── * - * ⚠️ STOPGAP for the widget half of objectstack#14105 — delete this section - * when the platform resolves dataset references at author time. + * The card and its PM note both assumed this whole surface was unguarded, + * carrying #14105 (datasets) up a layer. Measured instead, by mutating this + * dashboard one reference at a time on `@objectstack/cli` 17.2.0 — the table + * is in `src/dashboards/index.ts` — and most of it IS guarded: a bad widget + * `dataset`, `dimensions[]`, `values[]` or `{date-macro}` fails `validate` + * and `build` with a named rule and a "did you mean", and an unresolvable nav + * `dashboardName` is refused by `defineStack` itself. * - * A widget names its dataset, dimensions and measures BY NAME (ADR-0021), and - * a name that resolves to nothing renders an EMPTY widget while `validate` - * and `build` both exit 0 (measured on `@objectstack/cli` 17.2.0; see the - * table in the ablation note at the bottom of this file). `test/ - * metadata-bindings.test.ts` walks views, datasets and nav — it does NOT walk - * dashboard widgets, and its `dulyDashboards` import exists only to resolve - * the nav entry's `dashboardName`. + * Two references are not resolved, and both fail silently. Filed upstream as + * **objectstack-ai/objectstack#14148**; the binding half of this file is a + * STOPGAP and should be deleted when that lands, not kept in step with it: * - * On this screen specifically, an empty tile is the worst available failure: - * **an empty "not moving" tile is indistinguishable from a healthy team.** A - * number that is missing reads as a number that is zero, and zero is exactly - * the answer a manager hopes for. That is why the binding walk below is a - * resolver with self-tests rather than a handful of `toBeDefined()` calls. + * 1. **A widget `filter` KEY.** `due_daet: { $gte: '{today}' }` → validate 0, + * build 0. The condition matches nothing and the widget renders EMPTY. + * On the SAME node a bad `{token}` IS caught, path-precisely + * (`widgets[4].filter.due_date.$lte`) — so the traversal reaches the + * filter and only the column resolution is missing, which is exactly the + * asymmetry #14105 records one layer down. + * 2. **`options.sortBy` naming something the widget does not select.** + * validate 0, build 0; the authored order silently does not happen and + * the runtime falls back to the selected dimensions. On the by-unit chart + * that is the difference between "ordered by the org chart" and "ordered + * by whatever the runtime picked", and the card's rule — never order a + * unit chart by the count — is enforced by nothing else. + * + * On this screen an empty widget is the worst available failure: **an empty + * "not moving" tile is indistinguishable from a healthy team.** A missing + * number reads as zero, and zero is the answer a manager hopes for. So the + * walk below is a resolver with self-tests rather than a few `toBeDefined()` + * calls — and it resolves the dataset/dimension/measure names too, not + * because they need it, but because resolving a filter key requires the + * dataset in hand to reach its base object. That redundancy is stated rather + * than sold: when the two holes above close upstream, this walk goes with + * them and only the invariants below stay. * * ── 2. The product invariants ──────────────────────────────────────────── * @@ -327,7 +345,7 @@ describe('dashboard protocol', () => { // ─── Bindings ──────────────────────────────────────────────────────────── -describe('widget bindings — every reference resolves (stopgap for the widget half of objectstack#14105)', () => { +describe('widget bindings — every reference resolves (stopgap for objectstack#14148)', () => { it('every widget dataset, dimension, measure, filter key and date macro names something real', () => { expect( result.findings.map((f) => `${f.where}: "${f.reference}" — ${f.reason}`), @@ -700,15 +718,19 @@ describe('widget bindings — the guard can fail (self-test on synthetic metadat * guard that cannot fail. Same posture as `test/metadata-bindings.test.ts`: * one fixture per reference kind, in both directions. * - * These are also the ablation evidence for the platform gap this file - * stands in for. Measured on `@objectstack/cli` 17.2.0, with each mutation - * applied to the REAL dashboard and confirmed on disk before the run: + * Each case below was also run as an ablation against the REAL dashboard — + * mutation confirmed on disk with `grep -F` before the gates, restored by a + * trap after. What that measured is in `src/dashboards/index.ts`; the two + * rows this file exists for are the ones where both gates exit 0: * - * | mutation on a widget | validate | build | this file | - * |-------------------------------|----------|-------|-----------| - * | dataset → `duly_stagnatoin` | 0 | 0 | fails | - * | measure → `untouched_over_14`| 0 | 0 | fails | - * | filter key → `due_daet` | 0 | 0 | fails | + * | mutation on the real widget | validate | build | this file | + * |----------------------------------|----------|-------|-----------| + * | filter key → `due_daet` | 0 | 0 | fails | + * | sortBy → `not_selected` | 0 | 0 | fails | + * | dataset → `duly_stagnatoin` | 1 | 1 | fails | + * | measure → `untouched_over_14` | 1 | 1 | fails | + * | dimension → `business_unitt` | 1 | 1 | fails | + * | token → `{14_days_hence}` | 1 | 1 | fails | */ const objects: DeclaredObject[] = [ { name: 'fx_task', fields: { status: { type: 'select' }, due_date: { type: 'date' } } }, diff --git a/test/metadata-bindings.test.ts b/test/metadata-bindings.test.ts index 458488f..86c3754 100644 --- a/test/metadata-bindings.test.ts +++ b/test/metadata-bindings.test.ts @@ -416,9 +416,9 @@ interface Stack { readonly objects: readonly DeclaredObject[]; /** * Dashboards are here for ONE reason: a `type: 'dashboard'` nav entry - * targets one by name, and an unresolvable `dashboardName` is the same - * #14108 failure as an unresolvable `viewName` — the shell has nothing to - * open and the authored label stays on the entry either way. + * targets one by name, and the walk has to READ that name rather than skip + * the type. (The platform does resolve this one — see the branch in + * `walkNav` — unlike the `viewName` case #14108 is about.) * * The bindings INSIDE a dashboard (widget → dataset → dimension/measure, * and the widget's own `filter` keys) are NOT walked here; they are the @@ -638,11 +638,17 @@ export const metadataBindingFindings = (stack: Stack): WalkResult => { } if (type === 'dashboard') { /** - * `DashboardNavItemSchema` carries `dashboardName`, not an object — - * so the reference to resolve is the DASHBOARD, and the failure it - * guards is #14108's: nothing resolves this name at author time, and - * a miss is a nav entry that opens nothing while keeping the label - * that promised a screen. + * `DashboardNavItemSchema` carries `dashboardName`, not an object, so + * the reference to resolve is the DASHBOARD. + * + * Unlike `viewName` (#14108), this one is NOT an unguarded reference: + * measured on `@objectstack/cli` 17.2.0 by pointing the entry at a + * `duly_ghost`, `defineStack`'s own cross-reference validation refuses + * the stack — validate exits 1, build exits 2, and every test that + * imports the config goes red at once. The branch is here so the walk + * READS the type instead of dropping it into `unknownSlots`, and so a + * miss names the nav entry at unit level before the whole suite + * explodes at config load. It is not standing in for a missing gate. */ const dashboardName = String(item.dashboardName ?? ''); if (dashboardNames.has(dashboardName)) {