From 8d5197a348c1a929338e9fe5580784b63a1a466b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:59:53 +0000 Subject: [PATCH 1/5] refactor(service): write the service dashboard's one dataset binding once All fourteen widgets on service_dashboard bind the same dataset, `case_metrics`, and each spelled it out. Declare it once and pass it by shorthand (`dataset,`), the shape case.view.ts already uses for its one data provider. The registered ServiceDashboard value is unchanged (JSON-identical to the base's), and src/service's authored total drops from ~21,911 to ~21,863 tokens (-192 chars). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BCDqr1DqnQjEh5jA9gGkUG --- src/service/dashboards/service.dashboard.ts | 34 ++++++++++++--------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/service/dashboards/service.dashboard.ts b/src/service/dashboards/service.dashboard.ts index 45fe1268..0640e781 100644 --- a/src/service/dashboards/service.dashboard.ts +++ b/src/service/dashboards/service.dashboard.ts @@ -2,6 +2,12 @@ import type { Dashboard } from '@objectstack/spec/ui'; +/** + * The one dataset every widget below binds (`case_metrics`), written once and + * passed by shorthand (`dataset,`) rather than spelled out on each widget. + */ +const dataset = 'case_metrics'; + /** * Customer Service Dashboard * @@ -86,7 +92,7 @@ export const ServiceDashboard: Dashboard = { type: 'metric', filter: { is_closed: false }, colorVariant: 'orange', - dataset: 'case_metrics', values: ['case_count'], + dataset, values: ['case_count'], layout: { x: 0, y: 0, w: 3, h: 2 }, }, { @@ -96,7 +102,7 @@ export const ServiceDashboard: Dashboard = { type: 'metric', filter: { priority: 'critical', is_closed: false }, colorVariant: 'danger', - dataset: 'case_metrics', values: ['case_count'], + dataset, values: ['case_count'], layout: { x: 3, y: 0, w: 3, h: 2 }, }, { @@ -106,7 +112,7 @@ export const ServiceDashboard: Dashboard = { type: 'metric', filter: { is_closed: true }, colorVariant: 'blue', - dataset: 'case_metrics', values: ['avg_resolution'], + dataset, values: ['avg_resolution'], layout: { x: 6, y: 0, w: 3, h: 2 }, }, { @@ -116,7 +122,7 @@ export const ServiceDashboard: Dashboard = { type: 'metric', filter: { is_sla_violated: true }, colorVariant: 'warning', - dataset: 'case_metrics', values: ['case_count'], + dataset, values: ['case_count'], layout: { x: 9, y: 0, w: 3, h: 2 }, }, @@ -128,7 +134,7 @@ export const ServiceDashboard: Dashboard = { type: 'donut', filter: { is_closed: false }, colorVariant: 'blue', - dataset: 'case_metrics', dimensions: ['status'], values: ['case_count'], + dataset, dimensions: ['status'], values: ['case_count'], layout: { x: 0, y: 2, w: 4, h: 4 }, chartConfig: { showLegend: true, @@ -143,7 +149,7 @@ export const ServiceDashboard: Dashboard = { type: 'pie', filter: { is_closed: false }, colorVariant: 'warning', - dataset: 'case_metrics', dimensions: ['priority'], values: ['case_count'], + dataset, dimensions: ['priority'], values: ['case_count'], layout: { x: 4, y: 2, w: 4, h: 4 }, chartConfig: { showLegend: true, @@ -158,7 +164,7 @@ export const ServiceDashboard: Dashboard = { description: 'Where our cases are coming from', type: 'bar', colorVariant: 'purple', - dataset: 'case_metrics', dimensions: ['origin'], values: ['case_count'], + dataset, dimensions: ['origin'], values: ['case_count'], layout: { x: 8, y: 2, w: 4, h: 4 }, chartConfig: { showLegend: false, @@ -190,7 +196,7 @@ export const ServiceDashboard: Dashboard = { // is the intended reading. filterBindings: { dateRange: false }, colorVariant: 'blue', - dataset: 'case_metrics', dimensions: ['created_at'], values: ['case_count'], + dataset, dimensions: ['created_at'], values: ['case_count'], layout: { x: 0, y: 6, w: 8, h: 4 }, chartConfig: { showLegend: false, @@ -216,7 +222,7 @@ export const ServiceDashboard: Dashboard = { // compliance. The key is gone rather than left pretending: a measure // that means what the widget says is the fix, and the measure's own // label prints under the number, so the two cannot drift apart again. - dataset: 'case_metrics', values: ['sla_compliance_rate'], + dataset, values: ['sla_compliance_rate'], layout: { x: 8, y: 6, w: 4, h: 4 }, chartConfig: { showLegend: false, @@ -249,7 +255,7 @@ export const ServiceDashboard: Dashboard = { description: 'Share of closed cases resolved with a knowledge article', type: 'metric', colorVariant: 'success', - dataset: 'case_metrics', values: ['kb_deflection_rate'], + dataset, values: ['kb_deflection_rate'], layout: { x: 0, y: 10, w: 4, h: 2 }, }, { @@ -258,7 +264,7 @@ export const ServiceDashboard: Dashboard = { description: 'Closed cases pointing at the article that resolved them', type: 'metric', colorVariant: 'blue', - dataset: 'case_metrics', values: ['kb_resolved_count'], + dataset, values: ['kb_resolved_count'], layout: { x: 4, y: 10, w: 4, h: 2 }, }, { @@ -267,7 +273,7 @@ export const ServiceDashboard: Dashboard = { description: 'The denominator behind the deflection rate', type: 'metric', colorVariant: 'default', - dataset: 'case_metrics', values: ['closed_count'], + dataset, values: ['closed_count'], layout: { x: 8, y: 10, w: 4, h: 2 }, }, { @@ -277,7 +283,7 @@ export const ServiceDashboard: Dashboard = { type: 'table', filter: { is_closed: true }, colorVariant: 'default', - dataset: 'case_metrics', dimensions: ['resolved_article'], values: ['kb_resolved_count'], + dataset, dimensions: ['resolved_article'], values: ['kb_resolved_count'], layout: { x: 0, y: 12, w: 12, h: 4 }, options: { sortBy: 'kb_resolved_count', @@ -302,7 +308,7 @@ export const ServiceDashboard: Dashboard = { // ANDing an agent pick into "mine" empties the widget for anyone else. filterBindings: { owner_id: false }, colorVariant: 'default', - dataset: 'case_metrics', dimensions: ['priority'], values: ['case_count', 'avg_sla_violated'], + dataset, dimensions: ['priority'], values: ['case_count', 'avg_sla_violated'], layout: { x: 0, y: 16, w: 12, h: 4 }, options: { sortBy: 'case_count', From ae2b2b02e0a87f27aa1718f5f7d9a650646bd59d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:00:22 +0000 Subject: [PATCH 2/5] fix(service): the SLA Violations tile counts open breaches at read time The tile counted only `is_sla_violated`, which nothing sets on an open case unless the scheduled case_sla_monitor sweep runs, and scheduled work is off by default. It now counts a flagged case OR a case still open (status not resolved/closed) whose sla_due_date is strictly before the current instant: the selection the sweep itself makes. It counts; it does not flag, escalate or alert, which still need the sweep. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BCDqr1DqnQjEh5jA9gGkUG --- src/service/dashboards/service.dashboard.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/service/dashboards/service.dashboard.ts b/src/service/dashboards/service.dashboard.ts index 0640e781..2c002688 100644 --- a/src/service/dashboards/service.dashboard.ts +++ b/src/service/dashboards/service.dashboard.ts @@ -120,7 +120,20 @@ export const ServiceDashboard: Dashboard = { title: 'SLA Violations', description: 'Cases that breached their SLA', type: 'metric', - filter: { is_sla_violated: true }, + // Counted at READ time (#2070): a case already flagged, OR a case still + // open whose deadline has passed. The second branch is the selection + // `case_sla_monitor` sweeps — same status set, same strict `$lt` at the + // current instant (`{now}` is that instant in a query filter; `{NOW()}` + // in a flow template) — so the tile reads true on a deployment that runs + // no scheduled work, where nothing flags an open breach. It counts; it + // does not act: escalation and the owner's alert still need the sweep. + // `test/case-sla-breach-on-close.test.ts` pins the two to one selection. + filter: { + $or: [ + { is_sla_violated: true }, + { status: { $nin: ['resolved', 'closed'] }, sla_due_date: { $lt: '{now}' } }, + ], + }, colorVariant: 'warning', dataset, values: ['case_count'], layout: { x: 9, y: 0, w: 3, h: 2 }, From ac2ec75d0bdda52a0886092ee3a9f99a2a88c149 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:08:51 +0000 Subject: [PATCH 3/5] test(service): pin the SLA Violations tile to the sweep's own selection case-sla-breach-on-close.test.ts: the tile's filter must be the flag OR case_sla_monitor's query_breached selection (operator and status set read off the sweep; only the instant's spelling differs, `{now}` in a query filter for the flow template's `{NOW()}`). A new section counts seven cases through the dataset query path with no sweep run (4 counted, the flag alone would give 2), then runs the sweep and checks it flags exactly the cases the tile already counted, with the count unchanged. live-work-predicate-parity.test.ts: the tile joins the by-name roster of "no longer live work" consumers. dashboard-agent-global-filter.test.ts: the ground-truth model learns the tile's `$or`, and five fixture rows carry a due date so the partition check exercises the open-case branch. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BCDqr1DqnQjEh5jA9gGkUG --- test/case-sla-breach-on-close.test.ts | 113 ++++++++++++++++++++- test/dashboard-agent-global-filter.test.ts | 40 ++++++-- test/live-work-predicate-parity.test.ts | 15 +++ 3 files changed, 153 insertions(+), 15 deletions(-) diff --git a/test/case-sla-breach-on-close.test.ts b/test/case-sla-breach-on-close.test.ts index fe381c2d..8db72eed 100644 --- a/test/case-sla-breach-on-close.test.ts +++ b/test/case-sla-breach-on-close.test.ts @@ -34,6 +34,13 @@ import { hotcrmStack, signUpPerson, systemUpdate, runShippedHook, type Person } * * The truth table is spelled out longhand rather than recomputed from the * hook: the expected verdict of each row is what `caseSlaBreached` answers. + * + * Section 4 is the parity pin for the rule's THIRD reader. The SLA Violations + * tile counts at read time — a flagged case, or an open case past its due date + * — so it reads true where no sweep runs (#2070, acceptance 3, ruled C). Its + * open-case branch is pinned to the selection `case_sla_monitor` makes, first + * as metadata and then by running the sweep over the cases the tile counted: + * the sweep must flag exactly those, and the tile's number must not move. */ type Rec = Record; @@ -250,14 +257,110 @@ describe('the SLA surfaces count the late finishes, through the dataset query pa }); }); -// ──────────────────────────── 4. both writers draw the same boundary ── +// ─────────────── 4. the sweep, the close-time stamp and the tile draw one boundary ── + +describe('the sweep, the close-time stamp and the tile draw one boundary', () => { + /** The shipped sweep's selection — the one place the open-case side of the rule is written. */ + const sweepSelection = (): Rec => { + const flow = verify.metadata.items('flow').find((f) => f.name === 'case_sla_monitor'); + const query = (flow?.nodes as Rec[] | undefined)?.find((n) => n.id === 'query_breached'); + expect(query?.config?.filter, 'case_sla_monitor has no query_breached filter').toBeTruthy(); + return query!.config.filter as Rec; + }; + const tileFilter = (): Rec => { + const dashboard = verify.metadata.items('dashboard').find((d) => d.name === 'service_dashboard'); + const tile = (dashboard?.widgets as Rec[] | undefined)?.find((w) => w.id === 'sla_violations'); + expect(tile, 'service_dashboard has no sla_violations widget').toBeTruthy(); + return tile!.filter as Rec; + }; -describe('the sweep and the close-time stamp agree on the boundary instant', () => { it('case_sla_monitor selects STRICTLY past-due cases, as caseSlaBreached does', () => { // A `$lte` here would flag a case exactly on its deadline as long as it is // open, while the close-time stamp calls the same instant "met". - const flow = verify.metadata.items('flow').find((f) => f.name === 'case_sla_monitor'); - const query = (flow?.nodes as Rec[] | undefined)?.find((n) => n.id === 'query_breached'); - expect(query?.config?.filter?.sla_due_date).toEqual({ $lt: '{NOW()}' }); + expect(sweepSelection().sla_due_date).toEqual({ $lt: '{NOW()}' }); }); + + it('the SLA Violations tile counts the flag, or exactly the sweep\'s own selection', () => { + // The sweep selects "open, past due, not yet flagged"; the tile counts + // "flagged, or open and past due" — the same selection with the flag + // clause folded into the other branch. Only the instant's SPELLING + // differs, because the two slots are two dialects: a flow template reads + // `{NOW()}`, and a query filter refuses it (FILTER_TOKEN_UNKNOWN) and + // spells the current instant `{now}`. The operator is read off the sweep, + // so a `$lte` on either side, a different status set, or a third clause + // on the sweep turns this red. + const { is_sla_violated: unflagged, ...selection } = sweepSelection(); + expect(unflagged, 'the sweep no longer selects unflagged cases only').toBe(false); + const operators = Object.keys(selection.sla_due_date ?? {}); + expect(operators, 'the sweep compares sla_due_date with something other than one operator').toHaveLength(1); + expect(tileFilter()).toEqual({ + $or: [ + { is_sla_violated: true }, + { ...selection, sla_due_date: { [operators[0]!]: '{now}' } }, + ], + }); + }); +}); + +// ──────────────── 5. the tile counts an open breach nothing flagged, and the sweep agrees ── + +describe('the SLA Violations tile counts open breaches at read time, with no sweep', () => { + /** + * Cases in every state the tile has to tell apart, none of them flagged by + * a sweep — nothing in this file has run one yet. Each row's verdict is + * written longhand beside it. + */ + const rows: Record = {}; + const COUNTED = ['openLate', 'parkedLate', 'flaggedLate', 'flaggedRenegotiated']; + + beforeAll(async () => { + rows.openLate = await openCase(-5); // open, due five hours ago: counted + rows.openOnTime = await openCase(+5); // open, due in five hours: not counted + rows.parkedLate = await openCase(-5); // parked on the customer — the clock runs on: counted + await systemUpdate(verify, 'crm_case', { id: rows.parkedLate, status: 'waiting_customer' }); + rows.flaggedLate = await openCase(-5); // flagged AND past due: counted ONCE + await systemUpdate(verify, 'crm_case', { id: rows.flaggedLate, is_sla_violated: true }); + rows.flaggedRenegotiated = await openCase(+5); // flagged, deadline moved out later: counted + await systemUpdate(verify, 'crm_case', { id: rows.flaggedRenegotiated, is_sla_violated: true }); + rows.resolvedOnTime = await openCase(+5); // resolved in time, deadline passed since: not counted + await verify.hooks.run('crm_case', 'update', { id: rows.resolvedOnTime, status: 'resolved' }, { as: agent.token }); + await systemUpdate(verify, 'crm_case', { id: rows.resolvedOnTime, sla_due_date: new Date(Date.now() - HOUR).toISOString() }); + rows.noDeadline = await openCase(-5); // no due date, no clock: not counted + await systemUpdate(verify, 'crm_case', { id: rows.noDeadline, sla_due_date: null }); + }, 60_000); + + /** The tile's count over these rows, through the booted analytics service, with its SHIPPED binding. */ + const tileCount = async (): Promise => { + const dashboard = verify.metadata.items('dashboard').find((d) => d.name === 'service_dashboard'); + const tile = (dashboard?.widgets as Rec[]).find((w) => w.id === 'sla_violations')!; + const dataset = verify.metadata.items('dataset').find((d) => d.name === tile.dataset); + const analytics = verify.kernel.getService('analytics'); + const res = await analytics.queryDataset(dataset, { + measures: tile.values, + // The tile's filter ANDed with the scope, as the runtime ANDs a dashboard filter in. + runtimeFilter: { $and: [tile.filter, { id: { $in: Object.values(rows) } }] }, + }, { isSystem: true }); + return Number((res.rows as Rec[] | undefined)?.[0]?.case_count ?? 0); + }; + + /** Which of these rows carry the flag. */ + const flagged = async (): Promise => { + const stored = await verify.rows('crm_case', { id: { $in: Object.values(rows) } }); + const byId = new Map(Object.entries(rows).map(([name, id]) => [id, name])); + return stored.filter((r: Rec) => r.is_sla_violated === true).map((r: Rec) => byId.get(String(r.id))!).sort(); + }; + + it('counts the flagged cases and the open ones past due — not the flag alone', async () => { + expect(await flagged(), 'only the two flagged rows carry the flag').toEqual(['flaggedLate', 'flaggedRenegotiated']); + expect(await tileCount()).toBe(COUNTED.length); + }, 30_000); + + it('the sweep then flags exactly the open cases the tile already counted, and the number holds', async () => { + // The behavioural half of section 4: whatever the two spellings, the + // sweep's selection and the tile's open-case branch are one set of rows. + const admin = await verify.signIn(); + await verify.flows.run('case_sla_monitor', {}, { as: admin }); + expect(await flagged()).toEqual([...COUNTED].sort()); + expect(await tileCount()).toBe(COUNTED.length); + }, 120_000); }); diff --git a/test/dashboard-agent-global-filter.test.ts b/test/dashboard-agent-global-filter.test.ts index ad5e04e2..f95ac64d 100644 --- a/test/dashboard-agent-global-filter.test.ts +++ b/test/dashboard-agent-global-filter.test.ts @@ -109,6 +109,8 @@ interface FixtureCase { resolved_by_article: string | null; /** Days before now; every row is stamped at UTC NOON of that day. */ offset: number; + /** `sla_due_date`, in days from now (negative = past due); `null` = no deadline. */ + due: number | null; } const c = (owner: string, over: Partial): FixtureCase => ({ @@ -122,6 +124,7 @@ const c = (owner: string, over: Partial): FixtureCase => ({ resolution_time_hours: null, resolved_by_article: null, offset: 3, + due: null, ...over, }); @@ -141,9 +144,9 @@ const c = (owner: string, over: Partial): FixtureCase => ({ const CASE_ROWS: FixtureCase[] = [ // ── Alpha: 10 cases — 6 open, 4 closed ── c(ALPHA, { status: 'new', priority: 'critical', origin: 'email', type: 'problem', offset: 1 }), - c(ALPHA, { status: 'new', priority: 'low', origin: 'web', type: 'question', offset: 2 }), - c(ALPHA, { status: 'in_progress', priority: 'high', origin: 'phone', type: 'bug', is_sla_violated: true, offset: 4 }), - c(ALPHA, { status: 'in_progress', priority: 'high', origin: 'web', type: 'problem', offset: 6 }), + c(ALPHA, { status: 'new', priority: 'low', origin: 'web', type: 'question', offset: 2, due: -1 }), + c(ALPHA, { status: 'in_progress', priority: 'high', origin: 'phone', type: 'bug', is_sla_violated: true, offset: 4, due: -1 }), + c(ALPHA, { status: 'in_progress', priority: 'high', origin: 'web', type: 'problem', offset: 6, due: 1 }), c(ALPHA, { status: 'waiting_customer', priority: 'medium', origin: 'chat', type: 'question', is_sla_violated: true, offset: 8 }), c(ALPHA, { status: 'waiting_customer', priority: 'high', origin: 'email', type: 'feature_request', offset: 11 }), c(ALPHA, { status: 'closed', priority: 'medium', origin: 'web', type: 'question', is_closed: true, resolution_time_hours: 4, resolved_by_article: 'KB-1', offset: 13 }), @@ -155,14 +158,14 @@ const CASE_ROWS: FixtureCase[] = [ c(BETA, { status: 'new', priority: 'critical', origin: 'web', type: 'bug', is_sla_violated: true, offset: 1 }), c(BETA, { status: 'new', priority: 'medium', origin: 'social_media', type: 'question', offset: 2 }), c(BETA, { status: 'new', priority: 'low', origin: 'chat', type: 'feature_request', offset: 3 }), - c(BETA, { status: 'in_progress', priority: 'high', origin: 'email', type: 'problem', offset: 5 }), + c(BETA, { status: 'in_progress', priority: 'high', origin: 'email', type: 'problem', offset: 5, due: -2 }), c(BETA, { status: 'in_progress', priority: 'medium', origin: 'phone', type: 'problem', is_sla_violated: true, offset: 7 }), c(BETA, { status: 'escalated', priority: 'critical', origin: 'phone', type: 'bug', is_sla_violated: true, offset: 9 }), c(BETA, { status: 'escalated', priority: 'high', origin: 'web', type: 'problem', offset: 10 }), c(BETA, { status: 'waiting_support', priority: 'medium', origin: 'social_media', type: 'question', offset: 12 }), - c(BETA, { status: 'waiting_support', priority: 'low', origin: 'web', type: 'question', offset: 14 }), + c(BETA, { status: 'waiting_support', priority: 'low', origin: 'web', type: 'question', offset: 14, due: -1 }), c(BETA, { status: 'closed', priority: 'critical', origin: 'email', type: 'bug', is_closed: true, is_sla_violated: true, resolution_time_hours: 96, resolved_by_article: 'KB-3', offset: 16 }), - c(BETA, { status: 'closed', priority: 'high', origin: 'web', type: 'problem', is_closed: true, resolution_time_hours: 12, resolved_by_article: 'KB-1', offset: 17 }), + c(BETA, { status: 'closed', priority: 'high', origin: 'web', type: 'problem', is_closed: true, resolution_time_hours: 12, resolved_by_article: 'KB-1', offset: 17, due: -3 }), c(BETA, { status: 'closed', priority: 'medium', origin: 'chat', type: 'question', is_closed: true, resolution_time_hours: 8, resolved_by_article: 'KB-2', offset: 19 }), c(BETA, { status: 'closed', priority: 'medium', origin: 'phone', type: 'question', is_closed: true, resolution_time_hours: 20, offset: 20 }), c(BETA, { status: 'closed', priority: 'low', origin: 'web', type: 'question', is_closed: true, resolution_time_hours: 3, resolved_by_article: 'KB-3', offset: 23 }), @@ -186,12 +189,28 @@ const utcNoonDaysAgo = (offset: number): string => { /** * Does a fixture row satisfy a widget filter? Only the shapes the Service - * dashboard actually authors are understood — field equality and a - * `{N_days_ago}` floor on the range field. Anything else THROWS, so a widget - * filter this function silently mis-models cannot pass by accident. + * dashboard actually authors are understood — field equality, a + * `{N_days_ago}` floor on the range field, and the SLA Violations tile's `$or` + * of the flag with "open and past due" (`$nin` on `status`, `$lt '{now}'` on + * `sla_due_date`, #2070). Anything else THROWS, so a widget filter this + * function silently mis-models cannot pass by accident. */ const truthMatches = (row: FixtureCase, filter: AnyRec | undefined): boolean => { for (const [field, cond] of Object.entries(filter ?? {})) { + if (field === '$or') { + if (!(cond as AnyRec[]).some((branch) => truthMatches(row, branch))) return false; + continue; + } + const ops = cond !== null && typeof cond === 'object' ? Object.keys(cond as AnyRec).join() : ''; + if (field === 'status' && ops === '$nin') { + if (((cond as AnyRec).$nin as string[]).includes(row.status)) return false; + continue; + } + if (field === 'sla_due_date' && ops === '$lt' && (cond as AnyRec).$lt === '{now}') { + // A missing deadline never compares: SQL NULL, and no clock to have run out. + if (row.due === null || row.due >= 0) return false; + continue; + } if (field === AGENT_FIELD) { // `{current_user_id}` is resolved by the caller (see `truthFor`), so an // unresolved placeholder here is a mis-modelled filter: throw. @@ -256,7 +275,7 @@ const keyOf = (v: unknown): string => (v === null || v === undefined || v === '' const CASE_COLUMNS = [ 'owner_id', 'subject', 'description', 'status', 'priority', 'origin', 'type', 'is_closed', 'is_sla_violated', 'resolution', 'resolution_time_hours', - 'resolved_by_article', 'created_at', + 'resolved_by_article', 'created_at', 'sla_due_date', ]; let ql: AnyRec; @@ -296,6 +315,7 @@ beforeAll(async () => { resolved_by_article: row.resolved_by_article, // The platform's creation stamp, which the dashboard range windows (#1992). created_at: utcNoonDaysAgo(row.offset), + sla_due_date: row.due === null ? null : new Date(Date.now() + row.due * DAY).toISOString(), // The app's own `resolution_required_for_closed` rule fires on these // inserts — a welcome sign the REAL object is under test. ...(row.is_closed ? { resolution: 'Resolved by the fixture.' } : {}), diff --git a/test/live-work-predicate-parity.test.ts b/test/live-work-predicate-parity.test.ts index 458ccb66..a177998c 100644 --- a/test/live-work-predicate-parity.test.ts +++ b/test/live-work-predicate-parity.test.ts @@ -153,6 +153,7 @@ const caseViews = views.find((v) => v.list?.data?.object === 'crm_case'); const sharingRules: AnyRec[] = ((stack as AnyRec).sharingRules ?? []) as AnyRec[]; const flows: AnyRec[] = ((stack as AnyRec).flows ?? []) as AnyRec[]; const hooks: AnyRec[] = ((stack as AnyRec).hooks ?? []) as AnyRec[]; +const dashboards: AnyRec[] = ((stack as AnyRec).dashboards ?? []) as AnyRec[]; /** A view's `filter[]`, lowered rule by rule into one `$and`. */ function loweredViewFilter(viewName: string): unknown { @@ -191,6 +192,12 @@ function flowFilters(flowName: string): unknown[] { return found; } +/** One dashboard widget's `filter` — already a lowered `FilterCondition`. */ +function widgetFilter(dashboardName: string, widgetId: string): unknown { + const dashboard = dashboards.find((d) => d.name === dashboardName); + return ((dashboard?.widgets ?? []) as AnyRec[]).find((w) => w.id === widgetId)?.filter; +} + /** * The `$nin` a sandboxed hook body spells INLINE. * @@ -283,6 +290,14 @@ const LIVE_WORK_CONSUMERS: { name: string; surface: string; excluded: () => stri surface: 'close-time breach stamp — src/service/objects/case.hook.ts', excluded: () => hookInlineStatusList('case_sla_defaults', 'settledStatuses'), }, + { + // The SLA Violations tile's open-case branch (#2070): beside the flag, it + // counts a case still live work whose due date has passed — the sweep's + // own selection, read at query time. Only that branch excludes statuses. + name: 'sla_violations', + surface: 'dashboard widget filter — src/service/dashboards/service.dashboard.ts', + excluded: () => excludedValues(widgetFilter('service_dashboard', 'sla_violations'), 'status'), + }, ]; describe('the "no longer live work" predicate is ONE set, and the roster is by name', () => { From 225647520a41fe1542c4096f311d30947df7b6e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:08:51 +0000 Subject: [PATCH 4/5] docs(service): say what the SLA Violations tile counts; changeset The SLA & Escalation and Cases pages (three locales each) described the tile as counting `is_sla_violated: true`, and Cases said every breach lands in Escalated Cases. The tile now also counts open cases past their due date at read time, and only the breaches the sweep flags are escalated. Changeset for the tile change. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BCDqr1DqnQjEh5jA9gGkUG --- .changeset/2070-sla-tile-read-time.md | 5 +++++ content/docs/service/cases.mdx | 2 +- content/docs/service/cases.zh-Hans.mdx | 2 +- content/docs/service/cases.zh-Hant.mdx | 2 +- content/docs/service/sla-and-escalation.mdx | 2 +- content/docs/service/sla-and-escalation.zh-Hans.mdx | 2 +- content/docs/service/sla-and-escalation.zh-Hant.mdx | 2 +- 7 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/2070-sla-tile-read-time.md diff --git a/.changeset/2070-sla-tile-read-time.md b/.changeset/2070-sla-tile-read-time.md new file mode 100644 index 00000000..1876c35c --- /dev/null +++ b/.changeset/2070-sla-tile-read-time.md @@ -0,0 +1,5 @@ +--- +'hotcrm': patch +--- + +The **SLA Violations** tile on the service dashboard now counts open breaches when it is read, so it is right on every deployment, including one that runs no scheduled work (the platform default). It used to count only cases flagged **SLA Violated**, and an open case is flagged only by the hourly SLA sweep, which never runs where scheduled work is off. Such a deployment showed far fewer violations than it had: on a fresh demo database the tile read **13** while **29** cases had breached. The tile now counts a case that is flagged, or a case that is still open (neither *Resolved* nor *Closed*) and whose **SLA Due Date** has passed, using the same rule the sweep applies. A fresh demo database now shows **29**. The tile only counts: escalating a breached case and alerting its owner still need the sweep, so where it does not run, an open breach is in the count but not yet in **Escalated Cases**. diff --git a/content/docs/service/cases.mdx b/content/docs/service/cases.mdx index 41e35b8b..92159c84 100644 --- a/content/docs/service/cases.mdx +++ b/content/docs/service/cases.mdx @@ -184,7 +184,7 @@ Nothing else claims a case, and each exclusion is deliberate: Of the **seven** names this section used to list, only **My Open Cases** was real — it survives in the table above. The other six are not views at all: -- **Critical Cases** is a metric tile, not a list. *Breached SLA* is not a name this app carries at all: the tile that counts breaches is **SLA Violations**. [Service Overview](/docs/analytics/dashboards) — the dashboard's own title is **Customer Service** — carries **Critical Cases** (open and Critical) and **SLA Violations** (`is_sla_violated` is true); no list view in this app filters on a breach. For a list you can actually work through, use **Escalated Cases** — the SLA sweep escalates every case it flags, so each breach lands there. +- **Critical Cases** is a metric tile, not a list. *Breached SLA* is not a name this app carries at all: the tile that counts breaches is **SLA Violations**. [Service Overview](/docs/analytics/dashboards) — the dashboard's own title is **Customer Service** — carries **Critical Cases** (open and Critical) and **SLA Violations** (flagged **SLA Violated**, or still open past its **SLA Due Date**, counted when the tile is read); no list view in this app filters on a breach. For a list you can actually work through, use **Escalated Cases** — the SLA sweep escalates every case it flags, so each breach it flags lands there. - **Service Board** is not this app's name for anything. The kanban is **Service Workflow**, and that is what its tab reads. - **Cases Due Today**, **Recently Closed** and **By Account** do not exist in any form: no view filters on today's due date or on `closed_date`, and `crm_case` has no grouped view. diff --git a/content/docs/service/cases.zh-Hans.mdx b/content/docs/service/cases.zh-Hans.mdx index cde46b5b..fc6b98f7 100644 --- a/content/docs/service/cases.zh-Hans.mdx +++ b/content/docs/service/cases.zh-Hans.mdx @@ -184,7 +184,7 @@ description: 客户支持工单——主题、优先级、状态、解决方案 本节原先列的七个名字里,有六个根本不是视图: -- **Critical Cases** 是指标磁贴,不是列表。*Breached SLA* 这个名字本应用根本没有:按违约计数的是 **SLA Violations** 磁贴。[服务概览](/zh-Hans/docs/analytics/dashboards)(Service Overview;该仪表盘自身的标题是 **Customer Service**)上有 **Critical Cases**(未结且 Critical)和 **SLA Violations**(`is_sla_violated` 为真)两个磁贴;本应用里没有任何一个列表视图按违约过滤。真要有一份能逐条处理的清单,用 **已升级工单**——SLA 扫描会把它标记的每一条都升级,所以每次违约都会落到那里。 +- **Critical Cases** 是指标磁贴,不是列表。*Breached SLA* 这个名字本应用根本没有:按违约计数的是 **SLA Violations** 磁贴。[服务概览](/zh-Hans/docs/analytics/dashboards)(Service Overview;该仪表盘自身的标题是 **Customer Service**)上有 **Critical Cases**(未结且 Critical)和 **SLA Violations**(已标记 **SLA Violated**,或仍未结且 **SLA 截止日期**已过,读取磁贴时计数)两个磁贴;本应用里没有任何一个列表视图按违约过滤。真要有一份能逐条处理的清单,用 **已升级工单**——SLA 扫描会把它标记的每一条都升级,所以它标记的每次违约都会落到那里。 - **Service Board** 不是本应用里任何东西的名字。看板叫 **服务流转**,页签上写的也是这个名字。 - **Cases Due Today**、**Recently Closed** 与 **By Account** 以任何形式都不存在:没有任何视图按「截止日期 = 今天」或 `closed_date` 过滤,`crm_case` 上也没有任何分组视图。 diff --git a/content/docs/service/cases.zh-Hant.mdx b/content/docs/service/cases.zh-Hant.mdx index c7a045f7..94076755 100644 --- a/content/docs/service/cases.zh-Hant.mdx +++ b/content/docs/service/cases.zh-Hant.mdx @@ -186,7 +186,7 @@ description: 客戶支援工單——主題、優先順序、狀態、解決方 本節原先列的七個名字裡,有六個根本不是檢視: -- **Critical Cases** 是指標磁貼,不是列表。*Breached SLA* 這個名字本應用根本沒有:按違約計數的是 **SLA Violations** 磁貼。[服務概覽](/zh-Hant/docs/analytics/dashboards)(Service Overview;該儀表板自身的標題是 **Customer Service**)上有 **Critical Cases**(未結且 Critical)和 **SLA Violations**(`is_sla_violated` 為真)兩個磁貼;本應用裡沒有任何一個列表檢視按違約過濾。真要有一份能逐條處理的清單,用 **已升級工單**——SLA 掃描會把它標記的每一條都升級,所以每次違約都會落到那裡。 +- **Critical Cases** 是指標磁貼,不是列表。*Breached SLA* 這個名字本應用根本沒有:按違約計數的是 **SLA Violations** 磁貼。[服務概覽](/zh-Hant/docs/analytics/dashboards)(Service Overview;該儀表板自身的標題是 **Customer Service**)上有 **Critical Cases**(未結且 Critical)和 **SLA Violations**(已標記 **SLA Violated**,或仍未結且 **SLA 截止日期**已過,讀取磁貼時計數)兩個磁貼;本應用裡沒有任何一個列表檢視按違約過濾。真要有一份能逐條處理的清單,用 **已升級工單**——SLA 掃描會把它標記的每一條都升級,所以它標記的每次違約都會落到那裡。 - **Service Board** 不是本應用裡任何東西的名字。看板叫 **服務流轉**,頁籤上寫的也是這個名字。 - **Cases Due Today**、**Recently Closed** 與 **By Account** 以任何形式都不存在:沒有任何檢視按「截止日期 = 今天」或 `closed_date` 過濾,`crm_case` 上也沒有任何分組檢視。 diff --git a/content/docs/service/sla-and-escalation.mdx b/content/docs/service/sla-and-escalation.mdx index 403c8630..fed9b525 100644 --- a/content/docs/service/sla-and-escalation.mdx +++ b/content/docs/service/sla-and-escalation.mdx @@ -143,7 +143,7 @@ If priority resolves to **Critical**, the Copilot immediately recommends escalat | **Service Director** | SLA Performance report | Weekly | | **Executive** | Service Overview | Weekly | -Two names this table used to carry are not views at all. **Breached SLA** and **Critical Cases** do not exist as list views: `crm_case` ships eight — *All Cases*, *Service Workflow*, *SLA Calendar*, *Case Timeline*, *My Open Cases*, *Unassigned — triage*, *Escalated Cases* and *⏰ SLA at Risk* (`src/service/views/case.view.ts`) — and not one of them filters on **SLA Violated**. The only two surfaces that do filter on a breach are metric tiles on [Service Overview](/docs/analytics/dashboards): **SLA Violations** (`is_sla_violated: true`) and **Critical Cases** (open and Critical). For a list you can actually work through, **Escalated Cases** is the closest thing: `case_sla_monitor` escalates every case it flags as breached, so each breach found on an open case lands in it — mixed in with the cases escalated on priority alone. (A case flagged on the save that resolved or closed it late is finished work: the **SLA Violations** tile counts it, and there is nothing left in it to work.) +Two names this table used to carry are not views at all. **Breached SLA** and **Critical Cases** do not exist as list views: `crm_case` ships eight — *All Cases*, *Service Workflow*, *SLA Calendar*, *Case Timeline*, *My Open Cases*, *Unassigned — triage*, *Escalated Cases* and *⏰ SLA at Risk* (`src/service/views/case.view.ts`) — and not one of them filters on **SLA Violated**. The only two surfaces that do filter on a breach are metric tiles on [Service Overview](/docs/analytics/dashboards): **SLA Violations** (flagged **SLA Violated**, or still open with its **SLA Due Date** passed — counted when the tile is read, so it is right with or without the sweep, which is still what escalates them) and **Critical Cases** (open and Critical). For a list you can actually work through, **Escalated Cases** is the closest thing: `case_sla_monitor` escalates every case it flags as breached, so each breach found on an open case lands in it — mixed in with the cases escalated on priority alone. (A case flagged on the save that resolved or closed it late is finished work: the **SLA Violations** tile counts it, and there is nothing left in it to work.) **My Open Cases is sorted by priority, not by SLA Due Date.** Its keys are `priority_rank` descending first, and `sla_due_date` ascending only as a tie-breaker (`src/service/views/case.view.ts`) — so a Low case due within the hour still sits below every Critical one. The tie-breaker does now have values to work with at every priority, so within a band the soonest deadline comes first. Keep **SLA at Risk** or **SLA Calendar** open next to it if you are working to deadlines. diff --git a/content/docs/service/sla-and-escalation.zh-Hans.mdx b/content/docs/service/sla-and-escalation.zh-Hans.mdx index 2f4b843c..3d13a37a 100644 --- a/content/docs/service/sla-and-escalation.zh-Hans.mdx +++ b/content/docs/service/sla-and-escalation.zh-Hans.mdx @@ -143,7 +143,7 @@ SLA(服务级别协议)系统为**每一张工单**打上一个**截止时 | **服务总监** | SLA 表现报表 | 每周 | | **高管** | 服务概览 | 每周 | -这张表过去写的两个名字根本不是视图。**Breached SLA** 与 **Critical Cases** 都不存在于列表视图里:`crm_case` 随产品提供的视图一共八个——*全部工单*、*服务流转*、*SLA 日历*、*工单时间线*、*我的待处理工单*、*未分派 — 待分诊*、*已升级工单* 与 *⏰ SLA 风险预警*(`src/service/views/case.view.ts`)——其中没有任何一个以 **SLA 已违约**为过滤条件。真正按违约过滤的只有[服务概览](/zh-Hans/docs/analytics/dashboards)上的两个指标磁贴:**SLA Violations**(`is_sla_violated: true`)与 **Critical Cases**(未结 + Critical)。如果你要的是一份能逐条处理的列表,**已升级工单** 是最接近的那个:`case_sla_monitor` 会把它标记为违约的每一个工单都升级掉,所以工单仍然打开时发现的每一次违约都会落进这个视图——只是会和那些仅因优先级而升级的工单混在一起。(在解决或关闭的那次保存里因超时而被标记的工单已经结束:**SLA Violations** 磁贴会把它计入,但它已经没有什么可再处理的了。) +这张表过去写的两个名字根本不是视图。**Breached SLA** 与 **Critical Cases** 都不存在于列表视图里:`crm_case` 随产品提供的视图一共八个——*全部工单*、*服务流转*、*SLA 日历*、*工单时间线*、*我的待处理工单*、*未分派 — 待分诊*、*已升级工单* 与 *⏰ SLA 风险预警*(`src/service/views/case.view.ts`)——其中没有任何一个以 **SLA 已违约**为过滤条件。真正按违约过滤的只有[服务概览](/zh-Hans/docs/analytics/dashboards)上的两个指标磁贴:**SLA Violations**(已标记 **SLA 已违约**,或仍未结且 **SLA 截止日期**已过——读取磁贴时才计数,所以有没有扫描它都是准的;升级这些工单的仍然是扫描)与 **Critical Cases**(未结 + Critical)。如果你要的是一份能逐条处理的列表,**已升级工单** 是最接近的那个:`case_sla_monitor` 会把它标记为违约的每一个工单都升级掉,所以工单仍然打开时发现的每一次违约都会落进这个视图——只是会和那些仅因优先级而升级的工单混在一起。(在解决或关闭的那次保存里因超时而被标记的工单已经结束:**SLA Violations** 磁贴会把它计入,但它已经没有什么可再处理的了。) **我的待处理工单是按优先级排序的,不是按 SLA 截止日期。** 它的排序键是 `priority_rank` 降序在先、`sla_due_date` 升序仅作次级键(`src/service/views/case.view.ts`)——所以一个一小时后就到期的 Low 工单,仍然排在每一个 Critical 工单下面。不过次级键现在在每一档里都有值可排了,同一档内最早到期的排在最前面。如果你是按截止时间在干活,请在它旁边同时开着 **SLA 风险预警** 或 **SLA 日历**。 diff --git a/content/docs/service/sla-and-escalation.zh-Hant.mdx b/content/docs/service/sla-and-escalation.zh-Hant.mdx index ca7be8c9..5f3b4fe0 100644 --- a/content/docs/service/sla-and-escalation.zh-Hant.mdx +++ b/content/docs/service/sla-and-escalation.zh-Hant.mdx @@ -145,7 +145,7 @@ SLA(服務級別協議)系統為**每一張工單**打上一個**截止時 | **服務總監** | SLA 表現報表 | 每週 | | **高管** | 服務概覽 | 每週 | -這張表過去寫的兩個名字根本不是檢視。**Breached SLA** 與 **Critical Cases** 都不存在於列表檢視裡:`crm_case` 隨產品提供的檢視一共八個——*All Cases*、*Service Workflow*、*SLA Calendar*、*Case Timeline*、*My Open Cases*、*Unassigned — triage*、*Escalated Cases* 與 *⏰ SLA at Risk*(`src/service/views/case.view.ts`)——其中沒有任何一個以 **SLA 已違約**為過濾條件。真正按違約過濾的只有[服務概覽](/zh-Hant/docs/analytics/dashboards)上的兩個指標磁貼:**SLA Violations**(`is_sla_violated: true`)與 **Critical Cases**(未結 + Critical)。如果你要的是一份能逐條處理的列表,**Escalated Cases** 是最接近的那個:`case_sla_monitor` 會把它標記為違約的每一個工單都升級掉,所以工單仍然打開時發現的每一次違約都會落進這個檢視——只是會和那些僅因優先順序而升級的工單混在一起。(在解決或關閉的那次儲存裡因超時而被標記的工單已經結束:**SLA Violations** 磁貼會把它計入,但它已經沒有什麼可再處理的了。) +這張表過去寫的兩個名字根本不是檢視。**Breached SLA** 與 **Critical Cases** 都不存在於列表檢視裡:`crm_case` 隨產品提供的檢視一共八個——*All Cases*、*Service Workflow*、*SLA Calendar*、*Case Timeline*、*My Open Cases*、*Unassigned — triage*、*Escalated Cases* 與 *⏰ SLA at Risk*(`src/service/views/case.view.ts`)——其中沒有任何一個以 **SLA 已違約**為過濾條件。真正按違約過濾的只有[服務概覽](/zh-Hant/docs/analytics/dashboards)上的兩個指標磁貼:**SLA Violations**(已標記 **SLA 已違約**,或仍未結且 **SLA 截止日期**已過——讀取磁貼時才計數,所以有沒有掃描它都是準的;升級這些工單的仍然是掃描)與 **Critical Cases**(未結 + Critical)。如果你要的是一份能逐條處理的列表,**Escalated Cases** 是最接近的那個:`case_sla_monitor` 會把它標記為違約的每一個工單都升級掉,所以工單仍然打開時發現的每一次違約都會落進這個檢視——只是會和那些僅因優先順序而升級的工單混在一起。(在解決或關閉的那次儲存裡因超時而被標記的工單已經結束:**SLA Violations** 磁貼會把它計入,但它已經沒有什麼可再處理的了。) **My Open Cases 是按優先順序排序的,不是按 SLA 截止日期。** 它的排序鍵是 `priority_rank` 降序在先、`sla_due_date` 升序僅作次級鍵(`src/service/views/case.view.ts`)——所以一個一小時後就到期的 Low 工單,仍然排在每一個 Critical 工單下面。不過次級鍵現在在每一檔裡都有值可排了,同一檔內最早到期的排在最前面。如果你是按截止時間在幹活,請在它旁邊同時開著 **SLA at Risk** 或 **SLA Calendar**。 From 7647070e6250fa858d5a8757f12f613cc9cc3190 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:19:09 +0000 Subject: [PATCH 5/5] test(service): give the date-window fixture the SLA Violations tile's column dashboard-date-range-window.test.ts runs every service widget over a hand-shaped crm_case with no sla_due_date column, and the driver refuses a filter on a column the table lacks. The fixture gains the column and four rows with a deadline (two open and past due, one open and not yet due, one closed and past due), and the ground-truth model learns the tile's `$or`. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BCDqr1DqnQjEh5jA9gGkUG --- test/dashboard-date-range-window.test.ts | 46 +++++++++++++++++++----- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/test/dashboard-date-range-window.test.ts b/test/dashboard-date-range-window.test.ts index b3a0da64..abc14f49 100644 --- a/test/dashboard-date-range-window.test.ts +++ b/test/dashboard-date-range-window.test.ts @@ -146,6 +146,8 @@ interface CaseRow { is_sla_violated: boolean; resolution_time_hours: number | null; resolved_by_article: string | null; + /** `sla_due_date`, in days from now (negative = past due); `null` = no deadline. */ + due: number | null; } const mk = ( @@ -169,8 +171,12 @@ const mk = ( is_sla_violated, resolution_time_hours, resolved_by_article, + due: null, }); +/** A row with an SLA deadline `days` from now, for the SLA Violations tile's open-case branch (#2070). */ +const due = (row: CaseRow, days: number): CaseRow => ({ ...row, due: days }); + /** * 13 cases spanning 200 days. Each preset window below selects a strictly * smaller slice than the one before it, and every widget on the Service @@ -179,15 +185,15 @@ const mk = ( * from a broken window. */ const CASE_ROWS: CaseRow[] = [ - mk(0, 'new', 'critical', 'web', 'problem', false, false), + due(mk(0, 'new', 'critical', 'web', 'problem', false, false), 0.25), mk(1, 'closed', 'high', 'email', 'question', true, false, 4, 'KB-1'), mk(2, 'in_progress', 'medium', 'phone', 'problem', false, true), mk(5, 'closed', 'low', 'web', 'request', true, true, 52, 'KB-2'), - mk(10, 'new', 'high', 'chat', 'problem', false, false), + due(mk(10, 'new', 'high', 'chat', 'problem', false, false), -2), mk(20, 'closed', 'critical', 'email', 'problem', true, false, 12, 'KB-1'), mk(29, 'escalated', 'critical', 'phone', 'problem', false, true), - mk(45, 'closed', 'medium', 'web', 'question', true, false, 30, null), - mk(60, 'in_progress', 'low', 'chat', 'request', false, false), + due(mk(45, 'closed', 'medium', 'web', 'question', true, false, 30, null), -40), + due(mk(60, 'in_progress', 'low', 'chat', 'request', false, false), -50), mk(89, 'closed', 'high', 'web', 'problem', true, true, 96, 'KB-3'), mk(100, 'closed', 'medium', 'email', 'question', true, false, 8, 'KB-2'), mk(120, 'new', 'low', 'web', 'request', false, false), @@ -222,6 +228,7 @@ const CASE_OBJECT = { is_sla_violated: { type: 'boolean' }, resolution_time_hours: { type: 'number' }, resolved_by_article: { type: 'text' }, + sla_due_date: { type: 'datetime' }, // No creation column here: `created_at` is the platform's, added by // `applySystemFields` below exactly as it is on the real object (#1992). }, @@ -261,8 +268,13 @@ beforeAll(async () => { api = ql.createContext({ isSystem: true }); for (const row of CASE_ROWS) { - const { offset, ...rest } = row; - await api.object('crm_case').insert({ ...rest, owner_id: ownerOf(row), created_at: utcNoonDaysAgo(offset).toISOString() }); + const { offset, due: dueDays, ...rest } = row; + await api.object('crm_case').insert({ + ...rest, + owner_id: ownerOf(row), + created_at: utcNoonDaysAgo(offset).toISOString(), + ...(dueDays === null ? {} : { sla_due_date: new Date(Date.now() + dueDays * DAY).toISOString() }), + }); } analytics = new AnalyticsService({ @@ -507,13 +519,29 @@ describe('every datetime-windowed dashboard answers under its own picker', () => * Ground truth for a widget's own filter plus the injected window, computed * over the fixture rows rather than asked of the engine. * - * Only the shapes the dashboards actually author are understood: field equality - * and a `{N_days_ago}` floor on the range field. Anything else throws, so a - * widget filter this function silently mis-models cannot pass by accident. + * Only the shapes the dashboards actually author are understood: field equality, + * a `{N_days_ago}` floor on the range field, and the SLA Violations tile's `$or` + * of the flag with "open and past due" (`$nin` on `status`, `$lt '{now}'` on + * `sla_due_date`, #2070). Anything else throws, so a widget filter this + * function silently mis-models cannot pass by accident. */ function truthMatches(row: CaseRow, filter: AnyRec | undefined, windowDays: number | null): boolean { if (windowDays !== null && row.offset > windowDays) return false; for (const [field, cond] of Object.entries(filter ?? {})) { + if (field === '$or') { + if (!(cond as AnyRec[]).some((branch) => truthMatches(row, branch, null))) return false; + continue; + } + const ops = cond !== null && typeof cond === 'object' ? Object.keys(cond as AnyRec).join() : ''; + if (field === 'status' && ops === '$nin') { + if (((cond as AnyRec).$nin as string[]).includes(row.status)) return false; + continue; + } + if (field === 'sla_due_date' && ops === '$lt' && (cond as AnyRec).$lt === '{now}') { + // A missing deadline never compares: SQL NULL, and no clock to have run out. + if (row.due === null || row.due >= 0) return false; + continue; + } if (field === 'created_at') { const gte = (cond as AnyRec)?.$gte; const m = typeof gte === 'string' ? gte.match(/^\{(\d+)_days_ago\}$/) : null;