@@ -26,7 +26,7 @@ import type { Dashboard } from '@objectstack/spec/ui';
2626 *
2727 * `DashboardRenderer` merges the date-range filter into EVERY widget's query,
2828 * and `DatasetWidget` lowers each bounded `{ $gte, $lte }` window it finds into
29- * a `timeDimensions` entry. `approval_throughput ` below dates its own window on
29+ * a `timeDimensions` entry. `approval_turnaround ` below dates its own window on
3030 * `approved_at` so that `compareTo` has something to shift; a second dashboard
3131 * window on another field would make two dated dimensions, and the executor
3232 * refuses that with `compareTo.dimension is ambiguous` — a widget that renders
@@ -73,15 +73,17 @@ import type { Dashboard } from '@objectstack/spec/ui';
7373export const LegalDashboard : Dashboard = {
7474 name : 'legal_workbench' ,
7575 label : 'Legal Workbench' ,
76- description : 'Intake queue, review load, negotiation stalls and pipeline shape for the legal team.' ,
76+ description : 'Intake queue, review load, negotiation stalls, pipeline shape and stage durations for the legal team.' ,
7777
7878 columns : 12 ,
7979 gap : 4 ,
8080
8181 // Both are persisted columns on `clm_contract` (`category` a select,
8282 // `department` a lookup), and every widget on this dashboard is bound to
83- // `contract_metrics` (base object `clm_contract`), so neither filter can
84- // reach a widget whose object lacks the column.
83+ // `contract_metrics` or `contract_cycle_time` — both on base object
84+ // `clm_contract` — so neither filter can reach a widget whose object lacks
85+ // the column. Both apply a literal value, which a duration measure accepts
86+ // (it refuses only a field-to-field comparison).
8587 globalFilters : [
8688 {
8789 name : 'category' ,
@@ -157,13 +159,20 @@ export const LegalDashboard: Dashboard = {
157159 * merged, PR #70 `30fd863`) and asks in its place for exactly this: a
158160 * fixed threshold, self-declared in the brick's title.
159161 *
160- * The measurement behind that ruling was taken here, which is why it is
161- * recorded here. The SLA is PER TYPE (`clm_contract_type.review_sla_days`:
162- * 2, 3, 5 or 10 days across the nine seeded types), and a breach is a
163- * row-wise comparison of `review_started_at` against another object's
164- * column. Analytics cannot express that: there is no formula filter
165- * (§12 gap #10), and a cross-object filter is refused outright on the
166- * strategy every bucketed query lands on.
162+ * The SLA is PER TYPE (`clm_contract_type.review_sla_days`: 2, 3, 5 or
163+ * 10 days across the nine seeded types), and a breach is a row-wise
164+ * comparison of `review_started_at` against "now minus that type's SLA".
165+ * 18.2.0 added the now-anchored comparand
166+ * (`{ $now: true, addDays: { $field } }`, objectstack#25808), but its
167+ * offset reads a numeric column of the SAME record only. Measured on
168+ * 18.2.0 as the head of legal, `addDays: { $field:
169+ * 'contract_type.review_sla_days' }` answers `400 INVALID_FILTER` — as a
170+ * runtime filter "the addDays offset … is a dotted path, and SQL
171+ * push-down compiles same-table column references only", and as a
172+ * measure filter the same code with the reason withheld — while
173+ * `os validate` passes it. The same anchor with a literal `-30` answers
174+ * 200. So the per-type breach is still not expressible, and this tile
175+ * stays.
167176 *
168177 * ⛔ The remaining route — a persisted `review_due_at` stamped by a daily
169178 * job, the §12 gap-#7 shape — is closed for this metric, and not merely
@@ -200,47 +209,54 @@ export const LegalDashboard: Dashboard = {
200209 layout : { x : 9 , y : 0 , w : 3 , h : 2 } ,
201210 } ,
202211
203- // ─── Row 2: throughput , period over period ───────────────────────────
212+ // ─── Row 2: turnaround , period over period ───────────────────────────
204213 {
205214 /**
206- * §09's 审批吞吐 brick: how many contracts legal got to `approved` in the
207- * window, against the previous period — and the period comparison uses
208- * the platform primitive §09 names (`compareTo`, the one shape
209- * `DatasetSelection.compareTo` implements) rather than a hardcoded delta.
210- * This tile IS that brick; it is not a substitute for a missing one.
215+ * §09's 平均周转 brick: the average number of days from submission to
216+ * approval for the contracts approved in the window, against the
217+ * previous period. The comparison is the platform's `compareTo`, not a
218+ * hardcoded delta.
211219 *
212- * §09 did ask for 平均周转 — an AVERAGE TURNAROUND — until 2026-09-09,
213- * when the maintainer ruling in PR #40 (`1127e52`) placed 各段时长 outside
214- * the V1.0 delivery surface and rewrote this brick to throughput (§09 as
215- * merged: PR #70, `30fd863`). The measurement behind that ruling was
216- * taken here: a duration cannot be computed in the semantic layer on this
217- * platform version — `Field.datetime` persists ISO text, so `AVG()` over
218- * a stage stamp answers the average YEAR (2025.9166…), and the dataset
219- * layer has no expression in which to subtract two dates. The full
220- * measurement is in `cycle-time.dataset.ts`. A number that is honest and
221- * says so beats a plausible one that is not, which is the reasoning §09
222- * itself now carries.
220+ * The measure is `avg_days_to_approval` on `contract_cycle_time`, a
221+ * `duration` measure (platform 18.2.0, objectstack#25778): the average of
222+ * `approved_at - submitted_at` per contract, in days. It replaces the
223+ * 审批吞吐 count this slot carried while a duration could not be written
224+ * (decision #31, 1C: the stand-in held until the platform shipped the
225+ * measure). Submission → approval is the part of the cycle legal owns;
226+ * signing and activation are the next four tiles' business.
223227 *
224- * ⚠️ This tile does not become an average turnaround when
225- * `objectstack-ai/objectstack#16737` closes. That in-flight fix makes the
226- * wrong path ERROR instead of returning a plausible fake number; it does
227- * not add date arithmetic, so the duration stays uncomputable after it
228- * lands. ⛔ Do not read the tracker as a queue this metric is waiting in.
228+ * Contracts approved in the window with no `submitted_at` (the demo's
229+ * backfilled in-force book) do not enter the average — a duration needs
230+ * both stamps. The tile is an average of the contracts that have them.
229231 *
230232 * The window is stated HERE, on the widget, and it is bounded on both
231233 * ends on purpose: `DatasetWidget` lowers only a `{ $gte, $lte }` pair
232234 * into the `timeDimensions` entry the executor shifts. An open-ended
233- * `$gte` alone comes back "compareTo needs a dated window to shift".
235+ * `$gte` alone comes back "compareTo needs a dated window to shift". A
236+ * bounded window is not a `granularity` bucket, so the duration measure
237+ * is served by the native-SQL strategy (a bucketed one would be refused).
238+ *
239+ * `previousPeriod` is the EQUAL-LENGTH window immediately before, as the
240+ * platform documents it — not the previous calendar month. Measured on
241+ * 2026-10-27: the October window [10-01, 10-31] was compared with
242+ * [08-31, 09-30], so August 31st's approval was in the comparison. The
243+ * description says "the equal-length period", not "last month", for
244+ * that reason.
234245 */
235- id : 'approval_throughput ' ,
236- title : 'Approved This Month' ,
237- description : 'Contracts approved this month, compared with last month. A count, not an average turnaround time .' ,
246+ id : 'approval_turnaround ' ,
247+ title : 'Avg Days to Approval ( This Month) ' ,
248+ description : 'Average days from submission to approval, for contracts approved this month, compared with the equal-length period just before it .' ,
238249 type : 'metric' ,
239- dataset : 'contract_metrics ' ,
240- values : [ 'contract_count ' ] ,
250+ dataset : 'contract_cycle_time ' ,
251+ values : [ 'avg_days_to_approval ' ] ,
241252 filter : { approved_at : { $gte : '{current_month_start}' , $lte : '{current_month_end}' } } ,
242253 compareTo : { kind : 'previousPeriod' } ,
243- colorVariant : 'success' ,
254+ // No `colorVariant`: for a duration, a bigger number is the WORSE one,
255+ // so the success green the throughput count carried would praise a
256+ // slowdown. ⚠️ The delta beside it is still drawn green when it rises:
257+ // the renderer colours "up" emerald for every measure and the platform
258+ // has no key to say lower-is-better (measured: "↑ 23%" in green for a
259+ // turnaround that got 23% longer).
244260 layout : { x : 0 , y : 2 , w : 4 , h : 4 } ,
245261 } ,
246262 {
@@ -262,9 +278,9 @@ export const LegalDashboard: Dashboard = {
262278 *
263279 * The criterion that does discriminate, and the one this filter now
264280 * states: WHETHER LEGAL HAS WORK TO DO ON THE STAGE. This is a work-queue
265- * board — the other five widgets are `awaiting_intake`, `in_review`,
266- * `review_ageing`, `negotiation_stalled` and `approval_throughput `, each
267- * one a queue or a throughput . `draft` … `signing` are contracts legal is
281+ * board — its first five widgets are `awaiting_intake`, `in_review`,
282+ * `review_ageing`, `negotiation_stalled` and `approval_turnaround `, each
283+ * one a queue or a turnaround . `draft` … `signing` are contracts legal is
268284 * moving; `active`, `expired`, `terminated`, `cancelled` and `rejected`
269285 * are contracts legal has finished moving. Legal's work on an in-force
270286 * book is renewal and expiry, and that is a DATED slice of it (the 到期日历
@@ -332,7 +348,8 @@ export const LegalDashboard: Dashboard = {
332348 * sits, and `active` holds sixty contracts because sixty contracts are
333349 * in force. A real conversion funnel is a DIFFERENT widget over a
334350 * DIFFERENT measure (contracts entering vs leaving each stage over a
335- * window) and needs the stage-duration data #31 is blocked on.
351+ * window); the stage DURATIONS are the row of tiles below, not a
352+ * funnel.
336353 *
337354 * A bar over the stage dimension says exactly what the data says and
338355 * cannot bowtie at ANY distribution — which is the requirement, not a
@@ -395,5 +412,55 @@ export const LegalDashboard: Dashboard = {
395412 suppressWarnings : [ 'chart-config-missing' ] ,
396413 layout : { x : 4 , y : 2 , w : 8 , h : 4 } ,
397414 } ,
415+
416+ // ─── Row 3: 各段时长 — average days per stage ────────────────────────
417+ //
418+ // DESIGN.md §09's chain submitted → review started → approved → signed →
419+ // active, one tile per stage, each a `duration` measure on
420+ // `contract_cycle_time` (average days between the two stamps, over every
421+ // contract that has both). FOUR TILES, not one four-measure chart, for the
422+ // reason measured on the executive board's routing row: a zero-dimension
423+ // dataset widget renders as a KPI card showing its FIRST measure only, so
424+ // one chart over four measures would silently show one stage.
425+ //
426+ // Not windowed: these read the whole book the viewer can see, and the
427+ // dashboard's two filters narrow them like every other tile. Each title
428+ // says "days" because the result field carries no unit.
429+ {
430+ id : 'days_to_review' ,
431+ title : 'Submitted → Review Started (Avg Days)' ,
432+ description : 'Average days from submission to a lawyer accepting the contract for review.' ,
433+ type : 'metric' ,
434+ dataset : 'contract_cycle_time' ,
435+ values : [ 'avg_days_to_review' ] ,
436+ layout : { x : 0 , y : 6 , w : 3 , h : 2 } ,
437+ } ,
438+ {
439+ id : 'days_in_review' ,
440+ title : 'Review Started → Approved (Avg Days)' ,
441+ description : 'Average days from the start of legal review to approval.' ,
442+ type : 'metric' ,
443+ dataset : 'contract_cycle_time' ,
444+ values : [ 'avg_days_in_review' ] ,
445+ layout : { x : 3 , y : 6 , w : 3 , h : 2 } ,
446+ } ,
447+ {
448+ id : 'days_to_signature' ,
449+ title : 'Approved → Signed (Avg Days)' ,
450+ description : 'Average days from approval to the contract being signed.' ,
451+ type : 'metric' ,
452+ dataset : 'contract_cycle_time' ,
453+ values : [ 'avg_days_to_signature' ] ,
454+ layout : { x : 6 , y : 6 , w : 3 , h : 2 } ,
455+ } ,
456+ {
457+ id : 'days_to_activation' ,
458+ title : 'Signed → Active (Avg Days)' ,
459+ description : 'Average days from signature to the contract coming into force.' ,
460+ type : 'metric' ,
461+ dataset : 'contract_cycle_time' ,
462+ values : [ 'avg_days_to_activation' ] ,
463+ layout : { x : 9 , y : 6 , w : 3 , h : 2 } ,
464+ } ,
398465 ] ,
399466} ;
0 commit comments