diff --git a/src/datasets/duty-health.dataset.ts b/src/datasets/duty-health.dataset.ts new file mode 100644 index 0000000..1a2c722 --- /dev/null +++ b/src/datasets/duty-health.dataset.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +import { governed } from './governed.js'; + +/** + * `duly_duty_health` — the on-time picture, over GOVERNED work only. + * + * Dashboards bind datasets, never objects (ADR-0021): a widget that names a + * dimension or measure this file does not declare renders an empty chart and + * reports success, so the names below are a contract. `#10` binds them. + * + * ── What is NOT here, and why it is upstream rather than worked around ──── + * + * The card asks for three more measures — `done on time`, `late`, and the + * `on-time rate` derived from them. All three reduce to ONE comparison: + * + * completed_at <= due_date + duty.grace_days + * + * That comparison cannot be expressed by a dataset measure filter, and the + * reason is worth stating precisely, because one third of it DOES work and + * the other two thirds are separate platform gaps: + * + * 1. **Reaching the related field works.** `include: ['duty']` plus a + * `duty.grace_days` path is exactly what the semantic layer is for — joins + * are compiled from `include` (ADR-0071, ≤3 hops) and the author writes no + * ON clause. `frequency` below is that same reach, in production, so this + * half is proven rather than assumed. + * + * 2. **Comparing two COLUMNS is refused on the SQL path.** The filter grammar + * declares a field reference — `{ completed_at: { $lte: { $field: + * 'due_date' } } }` — and `@objectstack/spec`'s own `filter.zod.ts` records + * that `driver-sql` (and `driver-sqlite-wasm`, which inherits its compiler) + * reject it with `INVALID_FILTER` / HTTP 400, while the in-memory evaluator + * resolves it. Tracked upstream as objectstack#5222. That split is worse + * than a uniform gap for a DATASET in particular: the same declaration + * would answer on a memory driver and 400 on a SQL one, so the measure's + * correctness would depend on the deployment. + * + * 3. **There is no date arithmetic in the grammar at all.** `FILTER_OPERATORS` + * is closed — equality, ordering, set, range, string, null/exists — and + * nothing adds an interval to a column. The `{N_days_ago}` macro + * vocabulary (`DATE_MACRO_PARAM_RE`) is relative to NOW, never to another + * column, so it cannot express "+ grace_days" either. And here the interval + * is itself a column, which is strictly harder than a literal offset. So + * even if #5222 landed tomorrow, `due_date + duty.grace_days` would still + * have no spelling. + * + * The two workarounds were both considered and both rejected on the card's own + * terms. Denormalising `grace_days` (or a pre-computed `grace_deadline`) onto + * `duly_task` is a second writer that drifts the day a duty's grace is edited — + * `AGENTS.md` rule 5 forbids it outright. Reducing the rate in TypeScript over + * query results is the hand-written aggregation the metadata-first instruction + * on this card exists to prevent, and it would also put the number outside the + * semantic layer where no dashboard could bind it. + * + * So the measures are absent rather than approximated. An `on_time_rate` that + * silently ignored grace would be wrong in the direction that matters — it + * would mark late every task completed inside the grace its own duty grants — + * and it would be wrong invisibly, which is how a number nobody trusts becomes + * the number everybody reports. Note what the gap currently costs: `grace_days` + * is authored on `duly_catalog_item`, propagated to `duly_duty` at + * instantiation, and read by NOTHING. This dataset was its only intended + * consumer. + * + * Filed upstream as **objectstack-ai/objectstack#14104**, with parts 2 and 3 above + * as the two independent halves. Do not close this hole locally: an approximation + * here is exactly how a platform gap becomes permanent and invisible. + * + * ── `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 + * add up — due = done + skipped + still-open — which a dashboard author will + * assume whether or not anyone tells them. `duly_workload.tasks_due` carries + * the identical definition on purpose: one name, one meaning, across the + * semantic layer. + */ +export const DutyHealth = defineDataset({ + name: 'duly_duty_health', + label: 'Duty health', + description: + 'On-time picture over governed duties (role catalog and manager-assigned). Self-declared work is surfaced in the task views and never scored here.', + + object: 'duly_task', + + // `duty` is joined for `frequency` only — the task itself carries owner, + // business unit, period and caliber, denormalised at dispatch so a rollup + // survives a later transfer or a re-sourced duty. + include: ['duty'], + + dimensions: [ + { name: 'business_unit', label: 'Business unit', field: 'business_unit', type: 'lookup' }, + { name: 'owner', label: 'Owner', field: 'owner', type: 'lookup' }, + { name: 'period_key', label: 'Period', field: 'period_key', type: 'string' }, + { name: 'frequency', label: 'Frequency', field: 'duty.frequency', type: 'string' }, + // Available so the governed population can be split by where the work came + // from. Slicing by this NEVER reveals self-declared work — every measure + // is filtered to catalog+assigned, so the `self` bucket is empty by + // construction. That is the design, not an oversight: see `governed.ts`. + { name: 'source', label: 'Source', field: 'source', type: 'string' }, + ], + + measures: [ + { + name: 'tasks_due', + label: 'Tasks due', + aggregate: 'count', + filter: governed({ status: { $ne: 'cancelled' } }), + }, + { + name: 'tasks_done', + label: 'Tasks done', + aggregate: 'count', + filter: governed({ status: 'done' }), + }, + { + // Kept separate from `tasks_done` because it is a legitimate outcome with + // a different meaning — "the plant was down, there was nothing to + // return". Folding skips into done inflates the picture; folding them + // into late punishes an honest answer. + name: 'tasks_skipped', + label: 'Tasks skipped', + aggregate: 'count', + filter: governed({ status: 'skipped' }), + }, + ], +}); diff --git a/src/datasets/governed.ts b/src/datasets/governed.ts new file mode 100644 index 0000000..89e5a92 --- /dev/null +++ b/src/datasets/governed.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { FilterCondition } from '@objectstack/spec/data'; + +/** + * The caliber gate, in one place, for every measure in every duly dataset. + * + * ── Why this is a constant and not a convention ─────────────────────────── + * `catalog` and `assigned` duties are what the ORGANISATION put on someone: + * a rate over them means something, because somebody else decided the work + * was owed. `self` duties are the owner's own record-keeping. + * + * A single on-time rate that quietly folds self-declared work in punishes + * exactly the people who declared the most — declare five extra duties, miss + * one, and your number goes down while the colleague who declared nothing + * stays at 100%. That is not a reporting inaccuracy; it is a mechanism that + * teaches an organisation to declare nothing, and it takes about one quarter + * to work. So the filter is not a default that a measure may opt out of: it + * is on EVERY measure in `dulyDatasets`, with no exception list, and + * `test/datasets.test.ts` walks the barrel to keep it that way. + * + * There is deliberately no `ungoverned()` counterpart. An exception list is + * the erosion path — the next reasonable-looking ticket adds one measure to + * it, and the one after that adds two. Self-declared work is SURFACED, in the + * operational views (`src/views/task.view.ts` carries `source` as a column and + * scores nothing); it is not surfaced through the metric layer. + * + * `source` remains a DIMENSION on the datasets so the governed population can + * still be split by where the work came from — "how much of this unit's load + * is manager-assigned rather than role-catalog?" is a real question, and it is + * answerable without ever putting `self` into a score. + */ +export const GOVERNED_SOURCES = ['catalog', 'assigned'] as const; + +/** + * `source IN ('catalog','assigned')`, ANDed with whatever else the measure + * needs. A `FilterCondition` is a record of field conditions combined with + * AND, so spreading is the whole implementation — no `$and` wrapper needed. + * + * Read `duly_task.source` and not `duty.source`: the task carries its own copy, + * stamped at dispatch, so a task keeps the caliber it was dispatched under even + * if the duty is later re-sourced. That is history, not drift — and it means a + * governed measure does not depend on the `duty` join being present. + */ +export const governed = (extra: FilterCondition = {}): FilterCondition => ({ + source: { $in: [...GOVERNED_SOURCES] }, + ...extra, +}); diff --git a/src/datasets/index.ts b/src/datasets/index.ts index d23d382..1eab5ee 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -12,5 +12,27 @@ // resolves it against the keyed branch of `MetadataCollectionInput`, which // makes `name` optional and fails the assignment. A named array is `never[]` // while empty and infers correctly the moment something is pushed into it. +// +// ⚠ A dataset is a CONTRACT, not an implementation detail. Dashboards and +// reports bind dimensions and measures BY NAME (ADR-0021), and a widget naming +// one that does not exist renders an empty chart and reports success. Renaming +// or dropping anything declared in these files breaks its consumers silently — +// grep the dashboards barrel before you touch a name. +// +// ⚠ And the binding UNDER a dataset is not checked either: `objectstack validate` +// and `objectstack build` both exit 0 on a dataset whose base `object`, `include` +// path or dimension/measure `field` paths name nothing at all (measured, and filed +// as objectstack-ai/objectstack#14105 — a bad date-macro TOKEN in the same measure +// filter IS caught, so the traversal exists and only the reference resolution is +// missing). Until that lands, `test/datasets.test.ts` pinning the field paths is +// the only thing standing between a typo here and a chart that renders empty while +// every gate reports success. + +import { DutyHealth } from './duty-health.dataset.js'; +import { Stagnation } from './stagnation.dataset.js'; +import { Workload } from './workload.dataset.js'; + +export { DutyHealth, Stagnation, Workload }; +export { GOVERNED_SOURCES, governed } from './governed.js'; -export const dulyDatasets = []; +export const dulyDatasets = [DutyHealth, Stagnation, Workload]; diff --git a/src/datasets/stagnation.dataset.ts b/src/datasets/stagnation.dataset.ts new file mode 100644 index 0000000..5b165d5 --- /dev/null +++ b/src/datasets/stagnation.dataset.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +import { governed } from './governed.js'; + +/** + * `duly_stagnation` — the early-warning picture, and the reason this product + * does not carry a completion percentage. + * + * ── Stagnation is NOT lateness ──────────────────────────────────────────── + * Nothing below mentions `due_date`. That is the entire point and it is the + * one thing a reviewer should check first: a task that is untouched for three + * weeks and not due for another two is ALREADY the signal, and a lateness + * measure cannot see it until it is too late to act. Lateness tells a manager + * about work that has already failed; stagnation tells them about work that is + * quietly going nowhere, weeks earlier, while intervening is still cheap. + * + * A percentage would answer neither. It is a number nobody can verify, which + * is exactly why it becomes the number everyone reports — and 80% on an + * untouched task is 80% forever. `last_update_at` cannot be talked up: it is + * server-owned, stamped by `src/hooks/task.hook.ts` only when `status`, `note` + * or `skip_reason` actually CHANGED, never on an administrative or bulk write. + * Re-owning, re-dating or importing a task does not reset the clock. + * + * ── Why the buckets are expressible, when the on-time rate is not ───────── + * These thresholds are relative to NOW, not to another column, so they are + * exactly what the date-macro vocabulary is for. `{7_days_ago}` and friends + * are resolved server-side by `resolveFilterTokens`, which + * `@objectstack/spec`'s `date-macros.zod.ts` names as wired into "the + * analytics dataset executor" specifically — dataset definitions being one of + * the filter sources that never pass through a renderer. Unknown tokens fail + * the build rather than comparing as a literal string and matching nothing, + * which is why the spellings below are safe to trust; `test/datasets.test.ts` + * pins them against the spec's own token grammar. + * + * Contrast `duly_duty_health`, where the comparison is column-to-column plus + * arithmetic and there is no such vocabulary. The difference between the two + * cases is the whole answer to "can a dataset say this". + * + * ── Shape a dashboard author must know: the buckets are CUMULATIVE ──────── + * `untouched_over_14d` counts everything `untouched_over_30d` counts. They are + * nested thresholds, not disjoint bands, so they must not be summed and they + * must not go in a pie chart. Stack them as thresholds, or difference them in + * the widget if disjoint bands are wanted. Named the way the card names them + * (">7d", ">14d", ">30d") precisely so the nesting is legible from the name. + * + * `last_update_at` is never null on a task that reached the database — the + * lifecycle hook stamps it on `beforeInsert` ("a brand-new task has just been + * touched, by definition"), so a freshly dispatched task cannot slip out of + * these counts through a NULL comparison. + */ +export const Stagnation = defineDataset({ + name: 'duly_stagnation', + label: 'Stagnation', + description: + 'Open governed work that is not moving, bucketed by how long it has gone untouched. Deliberately independent of due date — an untouched task that is not yet due still stagnates.', + + object: 'duly_task', + + dimensions: [ + { name: 'business_unit', label: 'Business unit', field: 'business_unit', type: 'lookup' }, + { name: 'owner', label: 'Owner', field: 'owner', type: 'lookup' }, + ], + + measures: [ + { + name: 'open_tasks', + label: 'Open tasks', + aggregate: 'count', + filter: governed({ status: { $in: ['open', 'in_progress'] } }), + }, + { + name: 'untouched_over_7d', + label: 'Untouched > 7 days', + aggregate: 'count', + filter: governed({ + status: { $in: ['open', 'in_progress'] }, + last_update_at: { $lt: '{7_days_ago}' }, + }), + }, + { + name: 'untouched_over_14d', + label: 'Untouched > 14 days', + aggregate: 'count', + filter: governed({ + status: { $in: ['open', 'in_progress'] }, + last_update_at: { $lt: '{14_days_ago}' }, + }), + }, + { + name: 'untouched_over_30d', + label: 'Untouched > 30 days', + aggregate: 'count', + filter: governed({ + status: { $in: ['open', 'in_progress'] }, + last_update_at: { $lt: '{30_days_ago}' }, + }), + }, + { + // The single oldest untouched moment in the group. A KPI tile bound to + // this answers "what is the worst thing here" without ranking anybody + // against anybody — it is a timestamp, not a score, and it names a date + // rather than a person. + name: 'oldest_last_update_at', + label: 'Oldest touch', + aggregate: 'min', + field: 'last_update_at', + filter: governed({ status: { $in: ['open', 'in_progress'] } }), + }, + ], +}); diff --git a/src/datasets/workload.dataset.ts b/src/datasets/workload.dataset.ts new file mode 100644 index 0000000..2b879f2 --- /dev/null +++ b/src/datasets/workload.dataset.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineDataset } from '@objectstack/spec/ui'; + +import { governed } from './governed.js'; + +/** + * `duly_workload` — the forward look: which period is about to overload. + * + * The only dataset here that is about work not yet done. Its job is to make a + * pile-up visible while it can still be moved — a quarter-end where four + * annual duties, a semi-annual and the usual monthlies all land in the same + * week is a problem you can only solve in advance. + * + * ── Shape a dashboard author must know ──────────────────────────────────── + * `due_week` and `due_month` are the SAME column at two granularities, not two + * columns. Group by one or the other, never both at once — crossing them + * produces one populated cell per week and empties everywhere else. Weeks are + * ISO (Monday start) wherever the driver does not truncate server-side. + * + * There is no status dimension and no per-person volume measure. "Who has the + * most tasks" is not a question this dataset answers, and that is deliberate: + * `owner` is here so a manager can see whether ONE person's next fortnight is + * unsurvivable, which is a workload question, not a ranking. A measure built + * to compare item counts BETWEEN people is the specific mechanism by which the + * caliber separation gets undone one reasonable-looking ticket at a time, so + * this dataset carries exactly one measure and it is the same one + * `duly_duty_health` carries. + * + * `tasks_due` is identical to `duly_duty_health.tasks_due` — governed sources, + * cancelled excluded. One name, one meaning, across the semantic layer; a + * cancelled task was withdrawn, so it is not future load. + */ +export const Workload = defineDataset({ + name: 'duly_workload', + label: 'Workload', + description: + 'Governed tasks due, bucketed forward by week and by month, so a period that is about to overload is visible while it can still be rebalanced.', + + object: 'duly_task', + + dimensions: [ + { name: 'business_unit', label: 'Business unit', field: 'business_unit', type: 'lookup' }, + { name: 'owner', label: 'Owner', field: 'owner', type: 'lookup' }, + { name: 'due_week', label: 'Due (week)', field: 'due_date', type: 'date', dateGranularity: 'week' }, + { name: 'due_month', label: 'Due (month)', field: 'due_date', type: 'date', dateGranularity: 'month' }, + ], + + measures: [ + { + name: 'tasks_due', + label: 'Tasks due', + aggregate: 'count', + filter: governed({ status: { $ne: 'cancelled' } }), + }, + ], +}); diff --git a/test/datasets.test.ts b/test/datasets.test.ts new file mode 100644 index 0000000..7343893 --- /dev/null +++ b/test/datasets.test.ts @@ -0,0 +1,336 @@ +// 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 { DatasetSchema } from '@objectstack/spec/ui'; + +import { dulyDatasets, GOVERNED_SOURCES } from '../src/datasets/index.js'; + +/** + * These tests are not a second copy of the protocol — `pnpm validate` already + * parses every dataset. They pin the two things a schema cannot see: + * + * 1. the CALIBER rule, which is a product invariant rather than a shape, and + * 2. the absences that are load-bearing — the log entry that no dataset may + * read, the per-person comparison that must never be built, the + * completion percentage, and the `due_date` that must stay OUT of + * stagnation. + * + * Every walk below iterates `dulyDatasets` rather than naming files, so a + * fourth dataset added later is covered the moment it enters the barrel. That + * is deliberate: a rule with an opt-out list is a rule with a countdown on it. + */ + +/** Every measure in the app, tagged with the dataset it came from. */ +const allMeasures = dulyDatasets.flatMap((ds) => + ds.measures.map((m) => ({ dataset: ds.name, measure: m })), +); + +/** Every dimension in the app, tagged with the dataset it came from. */ +const allDimensions = dulyDatasets.flatMap((ds) => + ds.dimensions.map((d) => ({ dataset: ds.name, dimension: d })), +); + +/** Recursively collect every `{ key: value }` leaf of a filter condition. */ +const filterEntries = (node: unknown, path = ''): Array<[string, unknown]> => { + if (node === null || typeof node !== 'object') return []; + if (Array.isArray(node)) return node.flatMap((child) => filterEntries(child, path)); + return Object.entries(node as Record).flatMap(([key, value]) => [ + [path ? `${path}.${key}` : key, value] as [string, unknown], + ...filterEntries(value, path ? `${path}.${key}` : key), + ]); +}; + +/** + * Every string anywhere in a node — KEYS INCLUDED. + * + * The keys matter at least as much as the values, and getting this wrong is + * silent. In a filter condition the COLUMN is the key — `{ due_date: { $lt: + * '{today}' } }` — so a values-only walk cannot see which column is being read + * at all; it sees `{today}` and nothing else. + * + * That is not hypothetical. The first revision of this file walked + * `Object.values` only, and the "stagnation never looks at due_date" + * assertion below stayed GREEN with a real `due_date: { $lt: '{today}' }` + * condition injected into a stagnation bucket. The single most important + * assertion here was asserting nothing, and it took an ablation to find it — + * a passing test is not evidence that a guard works. + */ +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 Record).flatMap(([key, value]) => [ + key, + ...deepText(value), + ]); +}; + +describe('dataset protocol', () => { + it('every dataset parses against the ADR-0021 dataset schema', () => { + for (const ds of dulyDatasets) { + const parsed = DatasetSchema.safeParse(ds); + expect(parsed.success, `${ds.name}: ${JSON.stringify(parsed.error?.issues)}`).toBe(true); + } + }); + + it('the barrel carries the three 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_stagnation', + 'duly_workload', + ]); + }); +}); + +describe('caliber — self-declared work is surfaced, never scored', () => { + it('EVERY measure is filtered to source IN (catalog, assigned)', () => { + expect(allMeasures.length).toBeGreaterThan(0); + for (const { dataset, measure } of allMeasures) { + const filter = measure.filter as Record | undefined; + expect(filter, `${dataset}.${measure.name} has no filter at all`).toBeDefined(); + expect( + filter?.source, + `${dataset}.${measure.name} must filter on source — an unscored measure that folds in ` + + 'self-declared work punishes whoever declared the most', + ).toEqual({ $in: [...GOVERNED_SOURCES] }); + } + }); + + it('the governed set is exactly catalog + assigned — self is never in it', () => { + expect(GOVERNED_SOURCES).toEqual(['catalog', 'assigned']); + expect(GOVERNED_SOURCES as readonly string[]).not.toContain('self'); + }); + + it('source stays available as a dimension, so the governed split is visible', () => { + const health = dulyDatasets.find((ds) => ds.name === 'duly_duty_health'); + expect(health?.dimensions.map((d) => d.name)).toContain('source'); + }); + + it('no measure reaches source through the duty join instead of the task column', () => { + // `duly_task.source` is stamped at dispatch, so a task keeps the caliber it + // was dispatched under. Reading `duty.source` would retroactively rewrite + // history the day a duty is re-sourced. + for (const { dataset, measure } of allMeasures) { + const keys = filterEntries(measure.filter).map(([key]) => key); + expect(keys, `${dataset}.${measure.name}`).not.toContain('duty.source'); + } + }); +}); + +describe('the absences that are the product', () => { + it('no dataset reads duly_log_entry, at any depth', () => { + // Base object, join paths, dimension/measure field paths, filter keys and + // filter values — all of it. The work log is unscoreable by construction + // and a dataset is the one place that could quietly undo it. + for (const ds of dulyDatasets) { + expect(ds.object, `${ds.name} base object`).not.toBe('duly_log_entry'); + for (const text of deepText(ds)) { + expect(text, `${ds.name} references the work log`).not.toContain('duly_log_entry'); + } + } + }); + + it('no measure ranks or compares item counts across people', () => { + // A leaderboard never arrives called a leaderboard; it arrives as + // "tasks logged" or "most active" on an otherwise reasonable ticket. + const banned = [ + 'rank', 'ranking', 'leaderboard', 'top_', 'most_active', 'activity', + 'tasks_logged', 'logged', 'contribution', 'contributor', 'score', 'points', + ]; + for (const { dataset, measure } of allMeasures) { + const haystack = `${measure.name} ${JSON.stringify(measure.label ?? '')}`.toLowerCase(); + for (const word of banned) { + expect(haystack, `${dataset}.${measure.name} smells like a ranking`).not.toContain(word); + } + } + for (const { dataset, dimension } of allDimensions) { + for (const word of banned) { + expect(dimension.name.toLowerCase(), `${dataset}.${dimension.name}`).not.toContain(word); + } + } + }); + + it('no completion-percentage measure exists', () => { + // Progress lives in `status` and `last_update_at`. A percentage is a number + // nobody can verify, which is exactly why it becomes the number everyone + // reports — and 80% on an untouched task stays 80% forever. + for (const { dataset, measure } of allMeasures) { + const name = measure.name.toLowerCase(); + for (const word of ['percent', 'pct', 'completion', 'progress']) { + expect(name, `${dataset}.${measure.name}`).not.toContain(word); + } + const fields = deepText(measure).join(' '); + expect(fields, `${dataset}.${measure.name}`).not.toContain('progress_percent'); + } + }); + + it('no measure filter uses a $field column-to-column reference', () => { + // Declared in the filter grammar, resolved by the in-memory evaluator, and + // REFUSED by driver-sql with INVALID_FILTER / 400 (objectstack#5222). In a + // dataset that split is the worst kind: the same measure would answer on a + // memory driver and 400 on a SQL one, so correctness would depend on the + // deployment. This is the tripwire for the on-time workaround that must not + // be written locally — see the docblock in `duty-health.dataset.ts`. + for (const { dataset, measure } of allMeasures) { + const keys = filterEntries(measure.filter).map(([key]) => key); + const offenders = keys.filter((k) => k.endsWith('$field')); + expect(offenders, `${dataset}.${measure.name} uses $field`).toEqual([]); + } + }); + + it('lateness is never stored — no measure reads a derived flag', () => { + for (const { dataset, measure } of allMeasures) { + const text = deepText(measure).join(' '); + for (const flag of ['is_late', 'is_overdue', 'is_open', 'is_completed']) { + expect(text, `${dataset}.${measure.name}`).not.toContain(flag); + } + } + }); +}); + +describe('duly_stagnation — stagnation is not lateness', () => { + const stagnation = dulyDatasets.find((ds) => ds.name === 'duly_stagnation')!; + + it('exists with business_unit and owner as its axes', () => { + expect(stagnation).toBeDefined(); + expect(stagnation.dimensions.map((d) => d.name)).toEqual(['business_unit', 'owner']); + }); + + it('NO stagnation measure mentions due_date — an untouched, not-yet-due task still counts', () => { + // The single most important assertion in this file. The moment a due-date + // condition appears here the dataset stops answering "what is going + // nowhere" and starts answering "what has already failed", which the + // lateness measures are for and which arrives weeks too late to act on. + for (const measure of stagnation.measures) { + const text = deepText(measure).join(' '); + expect(text, `${measure.name} must not look at due_date`).not.toContain('due_date'); + } + }); + + it('every bucket is computed from last_update_at', () => { + const buckets = stagnation.measures.filter((m) => m.name.startsWith('untouched_over_')); + expect(buckets.map((m) => m.name)).toEqual([ + 'untouched_over_7d', + 'untouched_over_14d', + 'untouched_over_30d', + ]); + for (const bucket of buckets) { + const entries = filterEntries(bucket.filter); + const touch = entries.find(([key]) => key === 'last_update_at'); + expect(touch, `${bucket.name} must threshold last_update_at`).toBeDefined(); + } + }); + + it('the bucket thresholds are real date-macro tokens, resolved server-side', () => { + // A token outside the vocabulary is not a no-op: before `resolveFilterTokens` + // it compared as a literal string and matched nothing, silently. Judged + // against the spec's OWN grammar rather than a regex written here, so a + // vocabulary change upstream turns this red instead of leaving it stale. + const expected: Record = { + untouched_over_7d: '{7_days_ago}', + untouched_over_14d: '{14_days_ago}', + untouched_over_30d: '{30_days_ago}', + }; + for (const [name, token] of Object.entries(expected)) { + const measure = stagnation.measures.find((m) => m.name === name)!; + const entries = filterEntries(measure.filter); + const threshold = entries.find(([key]) => key === 'last_update_at.$lt')?.[1]; + expect(threshold, `${name} threshold`).toBe(token); + + const match = String(threshold).match(DATE_MACRO_WRAPPED_RE); + expect(match, `${name}: ${token} is not a {placeholder}`).not.toBeNull(); + expect(isDateMacroToken(match![1]), `${name}: ${token} is not a known token`).toBe(true); + } + }); + + it('buckets count only open work, and the thresholds nest', () => { + // Cumulative, not disjoint: >14d includes everything >30d. A dashboard that + // sums or pie-charts them double-counts. Pinned here because the shape is + // invisible from the widget end. + const days = [7, 14, 30]; + for (const n of days) { + const measure = stagnation.measures.find((m) => m.name === `untouched_over_${n}d`)!; + const entries = filterEntries(measure.filter); + expect(entries.find(([key]) => key === 'status')?.[1]).toEqual({ + $in: ['open', 'in_progress'], + }); + } + }); + + it('the oldest-touch measure is a timestamp, not a score', () => { + const oldest = stagnation.measures.find((m) => m.name === 'oldest_last_update_at'); + expect(oldest?.aggregate).toBe('min'); + expect(oldest?.field).toBe('last_update_at'); + }); +}); + +describe('duly_duty_health', () => { + const health = dulyDatasets.find((ds) => ds.name === 'duly_duty_health')!; + + it('carries the five dimensions the manager side slices by', () => { + expect(health.dimensions.map((d) => d.name)).toEqual([ + 'business_unit', 'owner', 'period_key', 'frequency', 'source', + ]); + }); + + it('reaches frequency through the declared duty join, not a denormalised copy', () => { + // Proof that the semantic layer CAN reach a related object's field. It is + // the arithmetic and the column-to-column comparison that it cannot do — + // see the docblock in `duty-health.dataset.ts` for why the on-time measures + // are filed upstream rather than approximated here. + const frequency = health.dimensions.find((d) => d.name === 'frequency'); + expect(frequency?.field).toBe('duty.frequency'); + expect(health.include).toContain('duty'); + }); + + 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 + // it through the join or it does not exist. + for (const measure of health.measures) { + const text = deepText(measure).join(' '); + expect(text).not.toContain('grace_days'); + } + }); +}); + +describe('duly_workload — the forward look', () => { + const workload = dulyDatasets.find((ds) => ds.name === 'duly_workload')!; + + it('buckets due_date by week and by month off one column', () => { + const week = workload.dimensions.find((d) => d.name === 'due_week'); + const month = workload.dimensions.find((d) => d.name === 'due_month'); + expect(week?.field).toBe('due_date'); + expect(week?.dateGranularity).toBe('week'); + expect(month?.field).toBe('due_date'); + expect(month?.dateGranularity).toBe('month'); + }); + + it('carries exactly one measure — volume, never a comparison', () => { + expect(workload.measures.map((m) => m.name)).toEqual(['tasks_due']); + }); +}); + +describe('one name, one meaning across the semantic layer', () => { + it('tasks_due is defined identically wherever it appears', () => { + // Two datasets declare it; a dashboard author reading a single legend + // should not have to know which one produced the number. + const definitions = allMeasures + .filter(({ measure }) => measure.name === 'tasks_due') + .map(({ measure }) => JSON.stringify({ aggregate: measure.aggregate, filter: measure.filter })); + expect(definitions.length).toBeGreaterThan(1); + expect(new Set(definitions).size, `tasks_due drifted: ${definitions.join(' vs ')}`).toBe(1); + }); + + it('a cancelled task is never counted as due — it was withdrawn, not owed', () => { + for (const { dataset, measure } of allMeasures) { + if (measure.name !== 'tasks_due') continue; + const entries = filterEntries(measure.filter); + expect(entries.find(([key]) => key === 'status')?.[1], dataset).toEqual({ $ne: 'cancelled' }); + } + }); +});