Skip to content

Commit 9d4d1d3

Browse files
authored
Merge pull request #558 from objectstack-ai/claude/crm-matrix-report-date-granularity-i704uu
fix(analytics): pin the matrix reports' date-bucket intent and drop the empty column (#523)
2 parents e4dca56 + 8b45e44 commit 9d4d1d3

9 files changed

Lines changed: 330 additions & 18 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'hotcrm': patch
3+
---
4+
5+
Record the matrix reports' date-bucket intent and drop the empty `` column
6+
(#523). The v9 single-form migration dropped `groupingsAcross[].dateGranularity`
7+
and never carried it to the dataset dimensions, so every date axis groups by the
8+
raw timestamp — one column per distinct value. Re-declaring it turns out to be
9+
blocked on the platform, not on us, and the blockage was measured rather than
10+
assumed: on the pinned @objectstack 16.1 a bucketed dimension does not bucket the
11+
axis, it EMPTIES the surface. A granular dimension is refused by
12+
NativeSQLStrategy, so the query falls to the auto-bridged `executeAggregate`,
13+
which calls `engine.aggregate()` with no ExecutionContext — sharing then
14+
composes `id = '__deny_all__'` for every private object (all of ours), and
15+
`lead_metrics` goes 21 rows → 0. A `Field.datetime()` column additionally buckets
16+
to a single NULL, because SQLite stores it as epoch millis and 16.1 formats it
17+
with a bare `strftime()`. Both are fixed in 17.0.0-rc.0 (#3602/#3597 and
18+
driver-sql's epoch normalisation), so the declarations belong with that upgrade,
19+
not ahead of it — which is also why #500 could not honour `dateGranularity`.
20+
21+
So this ships what 16.1 can honour and pins the rest: the intended bucket for
22+
every date dimension (and for each matrix report's axis, including the dedicated
23+
quarter dimension `pipeline_coverage_by_quarter` needs — `close_date` is shared
24+
with the revenue trends, which want month) now lives in a new guard,
25+
`test/dataset-granularity.test.ts`. It fails today if anyone re-declares a bucket
26+
on 16.x, and flips to demanding every declaration the moment the platform pin
27+
crosses 17, so the upgrade cannot go green with the migration unfinished.
28+
`lead_inflow_by_month_source` and `cases_opened_by_day_priority` now exclude
29+
records with no date, which is what produced the headerless `` column (the lead
30+
report loses it: 21 groups → 20, no null bucket). Three report comments that
31+
described a `dateGranularity` no dataset declares are corrected.

src/datasets/account.dataset.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export const AccountDataset = defineDataset({
1111
dimensions: [
1212
{ name: 'industry', label: 'Industry', field: 'industry', type: 'string' },
1313
{ name: 'type', label: 'Type', field: 'type', type: 'string' },
14-
// No `dateGranularity`: bucketed date dims hit a fail-closed read scope in
15-
// the platform's aggregate bridge (see opportunity_metrics.close_date).
14+
// No `dateGranularity` (intended: month) — see the note on
15+
// opportunity_metrics.close_date for why it cannot be declared on 16.x.
1616
{ name: 'created_at', label: 'Created', field: 'created_at', type: 'date' },
1717
],
1818
measures: [

src/datasets/case.dataset.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export const CaseDataset = defineDataset({
1717
{ name: 'priority', label: 'Priority', field: 'priority', type: 'string' },
1818
{ name: 'origin', label: 'Origin', field: 'origin', type: 'string' },
1919
{ name: 'type', label: 'Type', field: 'type', type: 'string' },
20+
// No `dateGranularity` (intended: day, for cases_opened_by_day_priority and
21+
// the Service dashboard's inflow trend) — see the note on
22+
// opportunity_metrics.close_date. `crm_case.created_date` is a
23+
// `Field.datetime()`, so it is blocked by BOTH gaps described there.
2024
{ name: 'created_date', label: 'Created', field: 'created_date', type: 'date' },
2125
],
2226

src/datasets/lead.dataset.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export const LeadDataset = defineDataset({
1212
{ name: 'status', label: 'Status', field: 'status', type: 'string' },
1313
{ name: 'lead_source', label: 'Source', field: 'lead_source', type: 'string' },
1414
{ name: 'created_at', label: 'Created', field: 'created_at', type: 'date' },
15+
// No `dateGranularity` (intended: month, for lead_inflow_by_month_source)
16+
// — see the note on opportunity_metrics.close_date for why it cannot be
17+
// declared on 16.x. `crm_lead.last_contacted_date` is a `Field.datetime()`,
18+
// so it is blocked by BOTH gaps described there.
1519
{ name: 'last_contacted_date', label: 'Last Contacted', field: 'last_contacted_date', type: 'date' },
1620
],
1721
measures: [{ name: 'lead_count', label: 'Leads', aggregate: 'count' }],

src/datasets/opportunity.dataset.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,37 @@ export const OpportunityDataset = defineDataset({
2626
{ name: 'forecast_category', label: 'Forecast Category', field: 'forecast_category', type: 'string' },
2727
{ name: 'type', label: 'Deal Type', field: 'type', type: 'string' },
2828
{ name: 'owner', label: 'Owner', field: 'owner', type: 'lookup' },
29-
// NOTE: no `dateGranularity` here — a bucketed date dimension routes the
30-
// query through the platform's ObjectQL aggregate bridge, which drops the
31-
// caller's ExecutionContext and fail-closes the read scope (id =
32-
// '__deny_all__') on @objectstack 16.1, returning zero rows. Trend widgets
33-
// group by the raw date until that is fixed upstream.
29+
// ─── Why no `dateGranularity` on any date dimension (hotcrm#523) ────
30+
// This is the canonical note; the other datasets point here.
31+
//
32+
// Declaring a bucket on a dataset dimension is the supported mechanism and
33+
// IS what these dimensions want (month for the trend widgets, quarter for
34+
// pipeline_coverage_by_quarter, day for cases_opened_by_day_priority). It
35+
// cannot be declared while the app is pinned to @objectstack 16.x: doing so
36+
// makes every affected surface render EMPTY, which is worse than the
37+
// un-bucketed columns it would fix. Two independent 16.x gaps, both
38+
// verified against the pinned packages, both fixed in 17.0.0-rc.0:
39+
//
40+
// 1. Read scope. A granular dimension is refused by NativeSQLStrategy
41+
// (service-analytics `canHandle` bails on any timeDimension carrying a
42+
// granularity), so the query falls through to ObjectQLStrategy → the
43+
// auto-bridged `executeAggregate`, which calls `engine.aggregate()`
44+
// WITHOUT the caller's ExecutionContext. The sharing middleware then
45+
// sees an empty principal and composes `{ id: '__deny_all__' }` for
46+
// every `sharingModel: 'private'` object — which is all of ours. Fixed
47+
// upstream by #3602/#3597: the bridge threads `context`, and the
48+
// strategy applies the read scope itself.
49+
// 2. Bucketing. A `Field.datetime()` column lands in SQLite as INTEGER
50+
// epoch millis, and 16.x buckets with a bare `strftime('%Y-%m', col)`
51+
// — NULL for every row, i.e. a single '—' column. 17.0's driver-sql
52+
// normalises epoch storage before formatting. `Field.date()` columns
53+
// (close_date) are TEXT and bucket correctly even on 16.x, but they
54+
// still hit gap 1.
55+
//
56+
// The intended bucket per dimension is not lost — it lives in
57+
// `test/dataset-granularity.test.ts`, whose guard flips from "must not
58+
// declare" to "must declare" the moment this app moves to @objectstack 17.
59+
// Until then the trend widgets and matrix reports group by the raw date.
3460
{ name: 'close_date', label: 'Close Date', field: 'close_date', type: 'date' },
3561
// Cross-object dimension: account industry via the crm_account relationship.
3662
{ name: 'account_industry', label: 'Account Industry', field: 'crm_account.industry', type: 'string' },

src/reports/case.report.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,25 @@ export const SlaPerformanceReport: ReportInput = {
2929
};
3030

3131
/**
32-
* Daily case inflow by priority — matrix with day-level bucketing. Support
33-
* managers use this to spot priority spikes (e.g. a P1 burst on Tuesday) and
34-
* staff accordingly. Exercises the finest `dateGranularity: 'day'` bucket and
35-
* its interaction with a small categorical axis.
32+
* Daily case inflow by priority — matrix over `created_date`. Support managers
33+
* use this to spot priority spikes (e.g. a P1 burst on Tuesday) and staff
34+
* accordingly.
35+
*
36+
* The across axis is NOT bucketed by day yet: `case_metrics` cannot declare
37+
* `dateGranularity` while the app is pinned to @objectstack 16.x (see the note
38+
* on `opportunity_metrics.close_date`; hotcrm#523). Seeded cases carry a
39+
* midnight timestamp, so the raw columns happen to READ as days — but they are
40+
* raw timestamps, and a case created mid-afternoon gets its own column. The
41+
* day bucket is pinned as intent in `test/dataset-granularity.test.ts`.
3642
*/
3743
export const CasesOpenedByDayPriorityReport: ReportInput = {
3844
name: 'cases_opened_by_day_priority',
3945
label: 'Cases Opened by Priority × Day',
4046
description: 'Daily case inflow split by priority',
4147
dataset: 'case_metrics', rows: ['priority'], columns: ['created_date'], values: ['case_count'],
4248
type: 'matrix',
49+
// `created_date` is stamped by the platform, not required by the schema — a
50+
// case that reaches the table without one would group into a headerless '—'
51+
// column. "Cases opened" needs an open date; exclude the empty bucket.
52+
runtimeFilter: { created_date: { $ne: null } },
4353
};

src/reports/lead.report.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@
33
import type { ReportInput } from '@objectstack/spec/ui';
44

55
/**
6-
* Lead engagement trend — matrix with monthly bucketing on
7-
* `last_contacted_date` (a user-managed datetime, so it can be set in seed
8-
* data and varied across months for a realistic demo). Lets a marketing-ops
9-
* lead spot which channels we're actively working month-over-month.
10-
* Exercises the `dateGranularity: 'month'` server-side aggregation path.
6+
* Lead engagement trend — matrix over `last_contacted_date` (a user-managed
7+
* datetime, so it can be set in seed data and varied across months for a
8+
* realistic demo). Lets a marketing-ops lead spot which channels we're
9+
* actively working month-over-month.
10+
*
11+
* The across axis is NOT bucketed by month yet: `lead_metrics` cannot declare
12+
* `dateGranularity` while the app is pinned to @objectstack 16.x (both gaps
13+
* are spelled out on `opportunity_metrics.close_date`; hotcrm#523), so the
14+
* columns are one-per-raw-date until the 17.0 upgrade lands. The month bucket
15+
* is pinned as intent in `test/dataset-granularity.test.ts`.
1116
*/
1217
export const LeadInflowByMonthSourceReport: ReportInput = {
1318
name: 'lead_inflow_by_month_source',
1419
label: 'Lead Engagement by Month × Source',
1520
description: 'Contacted-lead volume per month, broken down by acquisition channel',
1621
dataset: 'lead_metrics', rows: ['lead_source'], columns: ['last_contacted_date'], values: ['lead_count'],
1722
type: 'matrix',
23+
// A never-contacted lead has no engagement month, and grouping it produced a
24+
// headerless '—' column next to the real ones. This report counts CONTACTED
25+
// leads (see the label/description), so the empty bucket is excluded at the
26+
// source rather than rendered as a mystery column.
27+
runtimeFilter: { last_contacted_date: { $ne: null } },
1828
};

src/reports/opportunity.report.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,16 @@ export const WonOpportunitiesByOwnerReport: ReportInput = {
2929
* Quarterly pipeline coverage — the matrix view that powers the classic
3030
* sales-ops "pipeline coverage" conversation: each forecast category against
3131
* the quarter the deal is expected to close in. `close_date` is the across
32-
* dimension (`columns`); the dataset's `dateGranularity` buckets it into
33-
* quarters in a single server-side aggregate query.
32+
* dimension (`columns`).
33+
*
34+
* It is NOT bucketed into quarters yet — the comment here used to claim "the
35+
* dataset's dateGranularity buckets it into quarters", but no such declaration
36+
* exists (it was dropped in the v9 single-form migration and cannot be
37+
* restored on @objectstack 16.x; see the note on
38+
* `opportunity_metrics.close_date`, hotcrm#523). Restoring it needs a
39+
* DEDICATED quarter dimension rather than a bucket on `close_date` itself:
40+
* that dimension is shared with the Sales/CRM/Executive revenue trends, which
41+
* want month. Both intents are pinned in `test/dataset-granularity.test.ts`.
3442
*/
3543
export const PipelineCoverageByQuarterReport: ReportInput = {
3644
name: 'pipeline_coverage_by_quarter',

0 commit comments

Comments
 (0)