From 462b0c1142bff510c36379c4c4ca4bad0a29ff78 Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Tue, 3 Nov 2026 15:22:09 +0000 Subject: [PATCH 1/6] analytics: compile on React 19 and Router 7 as well as 18 and 6 Widen SavePortletModal's builder ref to RefObject, drop the removed v7 future flags from the date-filter test's MemoryRouter, and resolve the @nivo/heatmap alias with import.meta.dirname so Vite 8's native config loader stops warning. Co-Authored-By: Claude Opus 5.5 --- .../apps/web/src/plugins/analytics/plugin.json | 2 +- .../analytics/tests/ui/date-filter.test.tsx | 7 +------ .../analytics/ui/pages/QueryBuilderPage.tsx | 2 +- plugins/analytics/docs/upgrades/unreleased.md | 16 +++++++++++++--- plugins/analytics/rocketflare-plugin.json | 2 +- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/plugins/analytics/apps/web/src/plugins/analytics/plugin.json b/plugins/analytics/apps/web/src/plugins/analytics/plugin.json index 919ce23..5789452 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/plugin.json +++ b/plugins/analytics/apps/web/src/plugins/analytics/plugin.json @@ -173,7 +173,7 @@ "file": "apps/web/vite.config.ts", "after": "'@': path.resolve(", "lines": [ - "'@nivo/heatmap': path.resolve(__dirname, './src/plugins/analytics/ui/lib/nivo-heatmap.tsx')," + "'@nivo/heatmap': path.resolve(import.meta.dirname, './src/plugins/analytics/ui/lib/nivo-heatmap.tsx')," ] } ], diff --git a/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx b/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx index 5921bca..2c537b8 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx +++ b/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx @@ -87,12 +87,7 @@ describe('date filter — pure helpers', () => { describe('useDashboardDateFilter — URL sync', () => { function setup(initial = '/analytics/x') { const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - + {children} ) return renderHook(() => ({ filter: useDashboardDateFilter(), location: useLocation() }), { wrapper, diff --git a/plugins/analytics/apps/web/src/plugins/analytics/ui/pages/QueryBuilderPage.tsx b/plugins/analytics/apps/web/src/plugins/analytics/ui/pages/QueryBuilderPage.tsx index a83dab5..cb4b609 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/ui/pages/QueryBuilderPage.tsx +++ b/plugins/analytics/apps/web/src/plugins/analytics/ui/pages/QueryBuilderPage.tsx @@ -87,7 +87,7 @@ function SavePortletModal({ }: { open: boolean onClose: () => void - builder: React.RefObject + builder: React.RefObject }) { const navigate = useNavigate() const pages = useAnalyticsPages() diff --git a/plugins/analytics/docs/upgrades/unreleased.md b/plugins/analytics/docs/upgrades/unreleased.md index 807de51..e4e188f 100644 --- a/plugins/analytics/docs/upgrades/unreleased.md +++ b/plugins/analytics/docs/upgrades/unreleased.md @@ -4,7 +4,7 @@ previous: 3.7.0 date: null breaking: false migrations: [] -areas: [] +areas: [ui, tests, config] touches_surfaces: [] requires_surfaces: [] manual: false @@ -12,11 +12,21 @@ manual: false ## What changed -_Nothing yet. Add an entry here in the same pull request as the change — see `README.md` beside this -file for the fields and for what "How to apply" has to say._ +analytics now compiles on React 19 with React Router 7 as well as on React 18 with Router 6, so a host can move to either. Imports stay on `react-router-dom`. + +- `ui/pages/QueryBuilderPage.tsx`: `SavePortletModal`'s `builder` prop is `React.RefObject`. React 19's `useRef(null)` returns that type, and React 18's `RefObject` still fits it. +- `tests/ui/date-filter.test.tsx`: the `` loses its `future` prop. Router 7 removed the v7 future flags. Router 6 warns once instead, and the kit's `tests/ui/setup.ts` filters that warning. +- `rocketflare-plugin.json` and its anchor copy, the `@nivo/heatmap` alias in `apps/web/vite.config.ts`: `path.resolve(import.meta.dirname, …)` replaces `__dirname`, which Vite 8's native config loader warns about. ## How to apply +1. Run `pnpm plugin upgrade analytics --apply`. It replaces the two source files and the `'@nivo/heatmap'` line in `apps/web/vite.config.ts`. + ## Conflicts to expect +`apps/web/vite.config.ts` → if the app already changed the `'@nivo/heatmap'` line by hand, keep one line, the one that uses `import.meta.dirname`. + ## Verify + +1. `rg -c "'@nivo/heatmap'" apps/web/vite.config.ts` prints `1`, and the line uses `import.meta.dirname`. +2. `pnpm gate` passes and prints no `(!)` warning about `configLoader: 'native'`. diff --git a/plugins/analytics/rocketflare-plugin.json b/plugins/analytics/rocketflare-plugin.json index 919ce23..5789452 100644 --- a/plugins/analytics/rocketflare-plugin.json +++ b/plugins/analytics/rocketflare-plugin.json @@ -173,7 +173,7 @@ "file": "apps/web/vite.config.ts", "after": "'@': path.resolve(", "lines": [ - "'@nivo/heatmap': path.resolve(__dirname, './src/plugins/analytics/ui/lib/nivo-heatmap.tsx')," + "'@nivo/heatmap': path.resolve(import.meta.dirname, './src/plugins/analytics/ui/lib/nivo-heatmap.tsx')," ] } ], From 346eb857cc9554bfcf351f2cad95f11602b91315 Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Tue, 3 Nov 2026 15:25:20 +0000 Subject: [PATCH 2/6] analytics: bump drizzle-cube to 0.12.2 0.12.2 declares React 19 peers and fixes a stored XSS in markdown portlets. Its types only gain optional fields and it reads stored dashboard configs unchanged. Since 0.10.1 drizzle-cube warns on a portlet mapped to a universal time filter it cannot reach, so Recent Members now pins the filter to TenantUsers.joinedAt, and the template test fails such a mapping. The skills and docs describe 0.12.2's query rules: no server gap fill, granularity-less time dimensions filter only, unparseable dates and ungrouped measure filters are 400s. Co-Authored-By: Claude Opus 5.5 --- plugins/analytics/README.md | 4 +-- .../plugins/analytics/dashboards/CLAUDE.md | 5 ++-- .../dashboards/DASHBOARD_PATTERNS.md | 3 ++ .../general-templates/tenant-overview.ts | 3 +- .../web/src/plugins/analytics/plugin.json | 2 +- .../tests/config/all-templates.test.ts | 22 ++++++++++++++ .../web/src/plugins/analytics/ui/CLAUDE.md | 2 +- plugins/analytics/docs/upgrades/unreleased.md | 18 ++++++++++-- plugins/analytics/rocketflare-plugin.json | 2 +- .../analytics/skills/analytics-cubes/SKILL.md | 10 +++---- .../skills/analytics-cubes/reference.md | 6 ++-- .../skills/analytics-dashboards/SKILL.md | 2 +- .../skills/analytics-queries/SKILL.md | 29 ++++++++++++------- plugins/analytics/skills/analytics/SKILL.md | 4 +-- 14 files changed, 79 insertions(+), 33 deletions(-) diff --git a/plugins/analytics/README.md b/plugins/analytics/README.md index 23c74fc..3e23180 100644 --- a/plugins/analytics/README.md +++ b/plugins/analytics/README.md @@ -27,7 +27,7 @@ one is malformed. - Rocketflare kit **0.17.1** or later (`minKit`; earlier plugin releases support older kits). - No other plugin, no bindings and no secrets. -- npm dependencies the install adds to `apps/web`: `drizzle-cube` (pinned 0.8.3), `recharts`, `d3`, +- npm dependencies the install adds to `apps/web`: `drizzle-cube` (pinned 0.12.2), `recharts`, `d3`, `react-grid-layout`, `react-is`. ## Install @@ -78,7 +78,7 @@ is an optional complement. ## Known gaps -- drizzle-cube is pinned to 0.8.3; newer drizzle-cube features (for example the MCP chart tool) are +- drizzle-cube is pinned to 0.12.2; newer drizzle-cube features (for example the MCP chart tool) are not enabled. - One example fact table; anything else you model is yours to add (the `analytics-cubes` skill walks through it). diff --git a/plugins/analytics/apps/web/src/plugins/analytics/dashboards/CLAUDE.md b/plugins/analytics/apps/web/src/plugins/analytics/dashboards/CLAUDE.md index b45b08d..6161217 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/dashboards/CLAUDE.md +++ b/plugins/analytics/apps/web/src/plugins/analytics/dashboards/CLAUDE.md @@ -6,8 +6,9 @@ categories are folders (`general-templates/`), each exporting `Record { } }) + it('lets every portlet mapped to a universal time filter reach it', () => { + const universal = new Set((config.filters ?? []).filter(f => f.isUniversalTime).map(f => f.id)) + for (const p of portlets) { + const query = JSON.parse(p.query ?? '{}') as { timeDimensions?: unknown[] } + for (const entry of p.dashboardFilterMapping ?? []) { + const filterId = typeof entry === 'string' ? entry : entry.filterId + if (!universal.has(filterId)) continue + const member = typeof entry === 'string' ? undefined : entry.member + if (member) { + expect(KNOWN_MEMBERS.has(member), `${id} portlet ${p.id} pins unknown ${member}`).toBe( + true + ) + } else { + expect( + query.timeDimensions?.length ?? 0, + `${id} portlet ${p.id} needs timeDimensions or a member for ${filterId}` + ).toBeGreaterThan(0) + } + } + } + }) + it('keeps gauges out of rows too short to draw them', () => { const rowOf = new Map() const groupById = new Map(groups.map(g => [g.id, g])) diff --git a/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md b/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md index d6e3e3e..01f1d54 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md +++ b/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md @@ -22,7 +22,7 @@ Paths below are relative to `apps/web/src/plugins/analytics/`. - **GM wrote no chart code.** drizzle-cube renders everything: `AnalyticsDashboard` (react-grid-layout editor, portlet editor with its own query builder, drill-down, charts) and `AnalysisBuilder` (`/analytics/explore`). The kit owns the glue only: pages, hooks over `/api/analytics/*` - (`@rocketflare/shared/analytics`), the provider wiring and the theme mapping. drizzle-cube 0.8.3 client + (`@rocketflare/shared/analytics`), the provider wiring and the theme mapping. drizzle-cube 0.12.2 client API actually used: `CubeProvider` from `drizzle-cube/client/providers` (`apiOptions`, `queryClient`, `features`), `AnalyticsDashboard` (`config`, `editable`, `dashboardFilters`, `onConfigChange`, `onSave`, `loadingComponent`), `AnalysisBuilder` + `AnalysisBuilderRef` diff --git a/plugins/analytics/docs/upgrades/unreleased.md b/plugins/analytics/docs/upgrades/unreleased.md index e4e188f..7078136 100644 --- a/plugins/analytics/docs/upgrades/unreleased.md +++ b/plugins/analytics/docs/upgrades/unreleased.md @@ -4,7 +4,7 @@ previous: 3.7.0 date: null breaking: false migrations: [] -areas: [ui, tests, config] +areas: [ui, tests, config, docs] touches_surfaces: [] requires_surfaces: [] manual: false @@ -12,21 +12,33 @@ manual: false ## What changed -analytics now compiles on React 19 with React Router 7 as well as on React 18 with Router 6, so a host can move to either. Imports stay on `react-router-dom`. +analytics now compiles on React 19 with React Router 7 as well as on React 18 with Router 6, so a host can move to either. Imports stay on `react-router-dom`. drizzle-cube moves from 0.8.3 to 0.12.2, which declares React 19 and fixes a stored XSS in markdown portlets (GHSA-g3qv-p2w6-c95h). - `ui/pages/QueryBuilderPage.tsx`: `SavePortletModal`'s `builder` prop is `React.RefObject`. React 19's `useRef(null)` returns that type, and React 18's `RefObject` still fits it. - `tests/ui/date-filter.test.tsx`: the `` loses its `future` prop. Router 7 removed the v7 future flags. Router 6 warns once instead, and the kit's `tests/ui/setup.ts` filters that warning. - `rocketflare-plugin.json` and its anchor copy, the `@nivo/heatmap` alias in `apps/web/vite.config.ts`: `path.resolve(import.meta.dirname, …)` replaces `__dirname`, which Vite 8's native config loader warns about. +- `rocketflare-plugin.json` and its anchor copy: `drizzle-cube` is pinned at `0.12.2`. Every type the plugin imports changed only by gaining optional fields, and 0.12.2 reads a stored `analytics_pages` config as it is. Three behaviour changes reach an app: + - `/cubejs-api/v1/load` and MCP `load` return only the time buckets that have rows. Dashboards and Explore fill the gaps on the client and look the same. A caller that wants a zero row per empty bucket sets `fillMissingDates: true` on the time dimension. + - A `timeDimensions` entry without `granularity` filters and no longer groups, so the Organisation Overview "Active Users (30d)" KPI is one distinct count over 30 days. + - A date string drizzle-cube cannot parse, an unknown operator, or a measure filter in an ungrouped query is a 400 instead of being dropped. +- `dashboards/general-templates/tenant-overview.ts`: the Organisation Overview "Recent Members" table pins the date filter to `TenantUsers.joinedAt`. It has no `timeDimensions`, so the filter never narrowed it, and drizzle-cube 0.10.1 and later shows a warning on such a card. `tests/config/all-templates.test.ts` now fails a portlet mapped to a universal time filter it cannot reach. +- The `analytics*` skills, `README.md` and the dashboard docs name 0.12.2 and its query rules. ## How to apply -1. Run `pnpm plugin upgrade analytics --apply`. It replaces the two source files and the `'@nivo/heatmap'` line in `apps/web/vite.config.ts`. +1. Run `pnpm plugin upgrade analytics --apply`. It replaces the changed source, test and skill files, the `'@nivo/heatmap'` line in `apps/web/vite.config.ts`, and the `drizzle-cube` pin in `apps/web/package.json`. +2. Run `pnpm install`. +3. Existing Organisation Overview pages keep the old mapping and show the warning on "Recent Members". To fix one, a member with manage Dashboard resets it: `POST /api/analytics/pages/:id/reset`, or `POST /api/analytics/templates/recreate` for every template page in the organisation. A reset discards that page's own edits, so skip it for a page the organisation has customised and remap the card in the editor instead. ## Conflicts to expect `apps/web/vite.config.ts` → if the app already changed the `'@nivo/heatmap'` line by hand, keep one line, the one that uses `import.meta.dirname`. +`apps/web/package.json` → if the app pinned `drizzle-cube` itself, take `0.12.2`. + ## Verify 1. `rg -c "'@nivo/heatmap'" apps/web/vite.config.ts` prints `1`, and the line uses `import.meta.dirname`. 2. `pnpm gate` passes and prints no `(!)` warning about `configLoader: 'native'`. +3. `pnpm --filter @rocketflare/web why drizzle-cube` resolves `0.12.2` only. +4. On a reset Organisation Overview page, "Recent Members" shows no warning and follows the date control. diff --git a/plugins/analytics/rocketflare-plugin.json b/plugins/analytics/rocketflare-plugin.json index 5789452..d56e72d 100644 --- a/plugins/analytics/rocketflare-plugin.json +++ b/plugins/analytics/rocketflare-plugin.json @@ -152,7 +152,7 @@ "dependencies": { "apps/web": { "d3": "^7.9.0", - "drizzle-cube": "0.8.3", + "drizzle-cube": "0.12.2", "react-grid-layout": "^2.2.4", "react-is": "^19.3.0", "recharts": "^3.10.1" diff --git a/plugins/analytics/skills/analytics-cubes/SKILL.md b/plugins/analytics/skills/analytics-cubes/SKILL.md index 6b654e1..3e081c5 100644 --- a/plugins/analytics/skills/analytics-cubes/SKILL.md +++ b/plugins/analytics/skills/analytics-cubes/SKILL.md @@ -9,7 +9,7 @@ argument-hint: "[add-measure | new-cube | fact-table | con Cubes live in `apps/web/src/plugins/analytics/cubes/`, one file per cube. They are registered in `cubes/index.ts` (`ANALYTICS_CUBES`, then `allCubes()`) and served per request by `api/routes/cube-api.ts`. **Read `cubes/CLAUDE.md` before you write anything.** `reference.md` -beside this file has the drizzle-cube **0.8.3** API: measure and dimension types, join shapes and +beside this file has the drizzle-cube **0.12.2** API: measure and dimension types, join shapes and the event-stream metadata. **Ground rules, in every mode:** @@ -27,7 +27,7 @@ the event-stream metadata. 1. The app runs (`pnpm dev`) and the test database is up (`pnpm test:db:up`). 2. The plugin is healthy: the `analytics` skill, **Health** mode. -## The shape of a cube in this app (0.8.3) +## The shape of a cube in this app (0.12.2) ```ts import type { BaseQueryDefinition, Cube, QueryContext } from 'drizzle-cube/server' @@ -65,7 +65,7 @@ export const ordersCube: Cube = defineCube('Orders', { // name is the FIRST ar **Warning for anyone reading generic drizzle-cube examples**, including the upstream `dc-cube-definition` skill: they show `defineCube({ name, sql: securityContext => eq(...) })`, -`sql: () => column` and `filters: [{ sql: () => … }]`. Those forms **do not type-check on 0.8.3**, +`sql: () => column` and `filters: [{ sql: () => … }]`. Those forms **do not type-check on 0.12.2**, or on the current 0.9.x. In this app: - the name is the first argument; - `sql` returns `{ from, where }`; @@ -208,7 +208,7 @@ cd apps/web && NODE_ENV=test pnpm exec dotenv -e .env.test -- vitest run --proje | Symptom | Cause | Fix | |---|---|---| | `Cube query without a tenant in the security context` | a cube was queried outside the route, e.g. from a script | only query through `/cubejs-api` or `/mcp`; the route builds the security context | -| Type error on `defineCube({ name: … })` or `filters: [{ sql }]` | generic or upstream example syntax | use the 0.8.3 shape above | +| Type error on `defineCube({ name: … })` or `filters: [{ sql }]` | generic or upstream example syntax | use the 0.12.2 shape above | | 400 on a table-style (ungrouped) query that mixes two cubes | a declared `hasMany` between them | declare the `belongsTo` side only | | isolation test: "keys differ from allCubes()" | a new cube with no case | add the case | | a tenant sees rows it shouldn't | the tenant predicate is missing, or on the wrong column, or a join reaches an unscoped table | fix `sql`; every cube reached through a join needs its own predicate | @@ -218,6 +218,6 @@ cd apps/web && NODE_ENV=test pnpm exec dotenv -e .env.test -- vitest run --proje ## Sources - drizzle-cube docs, cube definitions: https://www.drizzle-cube.dev -- drizzle-cube 0.8.3 types, checked against the published package (`npm pack drizzle-cube@0.8.3`: `dist/server/cube-utils.d.ts` `defineCube(name, definition)`, `dist/server/types/cube.d.ts` `Measure.filters: Array<(ctx) => SQL>`, `CubeJoin`): https://www.npmjs.com/package/drizzle-cube/v/0.8.3 +- drizzle-cube 0.12.2 types, checked against the published package (`npm pack drizzle-cube@0.12.2`: `dist/server/cube-utils.d.ts` `defineCube(name, definition)`, `dist/server/types/cube.d.ts` `Measure.filters: Array<(ctx) => SQL>`, `CubeJoin`): https://www.npmjs.com/package/drizzle-cube/v/0.12.2 - Upstream skill this adapts (MIT, generic syntax corrected above): https://github.com/cliftonc/drizzle-cube-plugin/blob/main/skills/dc-cube-definition/SKILL.md - This app: `apps/web/src/plugins/analytics/cubes/CLAUDE.md`, `services/fact-tables/CLAUDE.md`, `extensions.ts`, `testing.ts` diff --git a/plugins/analytics/skills/analytics-cubes/reference.md b/plugins/analytics/skills/analytics-cubes/reference.md index d1f6dce..b329bb0 100644 --- a/plugins/analytics/skills/analytics-cubes/reference.md +++ b/plugins/analytics/skills/analytics-cubes/reference.md @@ -1,6 +1,6 @@ -# drizzle-cube 0.8.3: cube API reference (as this app uses it) +# drizzle-cube 0.12.2: cube API reference (as this app uses it) -Checked against the published `drizzle-cube@0.8.3` type declarations (`dist/server/types/cube.d.ts`, +Checked against the published `drizzle-cube@0.12.2` type declarations (`dist/server/types/cube.d.ts`, `core.d.ts`, `cube-utils.d.ts`). The current 0.9.x has the same `defineCube`, `Measure` and `CubeJoin` shapes. If you bump the pin, re-check this file against the new `.d.ts`. @@ -85,5 +85,5 @@ Built in `api/routes/cube-api.ts` → `buildSecurityContext(ctx.detached())`: - `role` is present but **no cube should restrict rows by role**. Access is membership plus `read Analytics`; row filtering is by tenant (and by group, where you choose to). -Sources: https://www.npmjs.com/package/drizzle-cube/v/0.8.3 · https://www.drizzle-cube.dev · +Sources: https://www.npmjs.com/package/drizzle-cube/v/0.12.2 · https://www.drizzle-cube.dev · `apps/web/src/plugins/analytics/cubes/{CLAUDE.md,security.ts}` diff --git a/plugins/analytics/skills/analytics-dashboards/SKILL.md b/plugins/analytics/skills/analytics-dashboards/SKILL.md index 4cc5514..d0a38e9 100644 --- a/plugins/analytics/skills/analytics-dashboards/SKILL.md +++ b/plugins/analytics/skills/analytics-dashboards/SKILL.md @@ -162,6 +162,6 @@ From the CLI: `pnpm cli analytics pages list` (add `--json` for the raw response ## Sources - drizzle-cube dashboards and `DashboardConfig`: https://www.drizzle-cube.dev -- drizzle-cube 0.8.3 client types (`DashboardConfig`, `PortletConfig`, `AnalyticsDashboard`): https://www.npmjs.com/package/drizzle-cube/v/0.8.3 +- drizzle-cube 0.12.2 client types (`DashboardConfig`, `PortletConfig`, `AnalyticsDashboard`): https://www.npmjs.com/package/drizzle-cube/v/0.12.2 - Upstream skills this adapts (MIT; they prefer `analysisConfig`, while this app's templates keep the `query`-string form its tests read): https://github.com/cliftonc/drizzle-cube-plugin/blob/main/skills/dc-dashboard-config/SKILL.md, https://github.com/cliftonc/drizzle-cube-plugin/blob/main/skills/dc-chart-config/SKILL.md - This app: `apps/web/src/plugins/analytics/dashboards/{CLAUDE.md,DASHBOARD_PATTERNS.md}`, `api/routes/analytics-pages.ts`, `ui/CLAUDE.md`, `tests/config/all-templates.test.ts` diff --git a/plugins/analytics/skills/analytics-queries/SKILL.md b/plugins/analytics/skills/analytics-queries/SKILL.md index e1d10ca..d961076 100644 --- a/plugins/analytics/skills/analytics-queries/SKILL.md +++ b/plugins/analytics/skills/analytics-queries/SKILL.md @@ -26,7 +26,7 @@ a tenant id, and you never could. Any member may query (`read Analytics`). - Never paste a key into a file that gets committed, and never echo a full key in a transcript. 3. The plugin is healthy: the `analytics` skill, **Health** mode. -## The query language (0.8.3) +## The query language (0.12.2) A query is JSON. Field names are always `CubeName.member`, exactly two parts. Get the real names from `/meta` or the MCP `discover` tool; never guess them. @@ -43,25 +43,32 @@ from `/meta` or the MCP `discover` tool; never guess them. ``` - **Filtering by date is not grouping by date.** This is the most common mistake. - - "Total events in the last 30 days" needs a `filters` entry with `inDateRange`, and no - `timeDimensions`. + - "Total events in the last 30 days" needs a `filters` entry with `inDateRange`. A + `timeDimensions` entry with a `dateRange` and no `granularity` does the same: it filters and + does not group. - "Events per week" needs `timeDimensions` with a `granularity`, and a `dateRange` on it if you want a window. +- **Missing time buckets are not filled.** `/load` and MCP `load` return only the buckets that + have rows; the dashboards fill the gaps themselves. For a zero row per empty bucket, set + `"fillMissingDates": true` on the time dimension. - **Granularities:** `second`, `minute`, `hour`, `day`, `week`, `month`, `quarter`, `year`. -- **Relative ranges that are always understood:** `'last 7 days'`, `'last 30 days'`, `'last 90 days'`, - or an ISO pair `["2026-01-01", "2026-03-31"]`. An unrecognised relative string makes drizzle-cube - drop the condition and query all time, so the dashboards stick to those forms. +- **Relative ranges, lower case only:** `'today'`, `'yesterday'`, `'tomorrow'`, + `'last N days|weeks|months|quarters|years'`, `'this|last|next week|month|quarter|year'`, or an + ISO pair `["2026-01-01", "2026-03-31"]`. Anything else (`'past 90 days'`) is a 400. - **Filter operators:** - comparison: `equals`, `notEquals`, `gt`, `gte`, `lt`, `lte`, `between`, `notBetween` - - text: `contains`, `notContains`, `startsWith`, `endsWith`, `like`, `ilike`, `regex` + - text: `contains`, `notContains`, `startsWith`, `notStartsWith`, `endsWith`, `notEndsWith`, + `like`, `ilike`, `regex` - sets and nulls: `in`, `notIn`, `set`, `notSet`, `isEmpty`, `isNotEmpty` - dates: `inDateRange`, `beforeDate`, `afterDate` - arrays: `arrayContains`, `arrayOverlaps`, `arrayContained` - `values` is always an array, even for one value. + `values` is always an array, even for one value. `set`, `notSet`, `isEmpty` and `isNotEmpty` + take none. An unknown operator is a 400. - **AND / OR groups:** `{ "type": "or", "filters": [ …, … ] }`, and groups can nest. - **Joins are automatic:** ask for `Users.email` next to `ActivityEvents.count` and it joins. -- **Raw rows instead of aggregates:** add `"ungrouped": true`, with dimensions only. +- **Raw rows instead of aggregates:** add `"ungrouped": true`, with dimensions only, and filter + on dimensions only: a measure filter in an ungrouped query is a 400. - **Comparing periods:** give the time dimension a `compareDateRange` (a list of ranges). - **Analysis modes** work on event-stream cubes (`ActivityEvents`): - `{ "funnel": { "bindingKey", "timeDimension", "steps": [ … ] } }`: conversion between steps, at @@ -209,7 +216,7 @@ The key sits in plain text in that config file. Make it a dedicated key, and rev | 403 from `/mcp` in a browser-based client | drizzle-cube rejects browser `Origin`s it doesn't know; loopback and no-Origin clients (Claude Code, `mcp-remote`, curl) are allowed | use one of those clients. Allowing a browser origin means setting `mcp.allowedOrigins` in `api/routes/cube-api.ts`, which is a code change and needs review | | HTML instead of JSON | `/cubejs-api` or `/mcp` is missing from `run_worker_first` or the Vite proxy | the `analytics` skill, Health | | 400 `Unknown member …` | a guessed field name | copy names from `/meta` or `discover` | -| totals look like all time | an unrecognised relative date string was dropped | use `last N days` or an ISO pair | +| 400 naming a date range | a relative date string drizzle-cube does not parse | use `last N days` or an ISO pair | | numbers lag reality on `TenantActivityDaily` | the fact table rebuilds hourly at `:15` | `pnpm cli analytics check-facts`, then `refresh-facts` (admin) | | a cube missing from `/meta` | its feature is off for this organisation (`FEATURE_CUBES`) | turn the feature on for the tenant, or query another cube | @@ -221,5 +228,5 @@ The key sits in plain text in that config file. Make it a dedicated key, and rev - No Bearer-header field for custom connectors: https://github.com/anthropics/claude-ai-mcp/issues/112 - `mcp-remote` (`--header`, the no-space workaround, `--allow-http` for trusted private networks only, `-y` when npx prompts, Node 18+): https://github.com/geelen/mcp-remote - drizzle-cube docs: https://www.drizzle-cube.dev -- drizzle-cube 0.8.3 package (routes in `dist/adapters/hono`: meta, load, sql, dry-run, explain, batch; MCP tools discover, validate, load; `MCPOptions.allowedOrigins` defaults; `FilterOperator` and `TimeGranularity` in `dist/server/types`): https://www.npmjs.com/package/drizzle-cube/v/0.8.3 +- drizzle-cube 0.12.2 package (routes in `dist/adapters/hono`: meta, load, sql, dry-run, explain, batch; MCP tools discover, validate, load; `MCPOptions.allowedOrigins` defaults; `FilterOperator` and `TimeGranularity` in `dist/server/types`): https://www.npmjs.com/package/drizzle-cube/v/0.12.2 - Upstream skill this adapts (MIT): https://github.com/cliftonc/drizzle-cube-plugin/blob/main/skills/dc-query-building/SKILL.md diff --git a/plugins/analytics/skills/analytics/SKILL.md b/plugins/analytics/skills/analytics/SKILL.md index d16bc75..ea284b1 100644 --- a/plugins/analytics/skills/analytics/SKILL.md +++ b/plugins/analytics/skills/analytics/SKILL.md @@ -7,7 +7,7 @@ argument-hint: "[explain | health | cubes | query | dashboards | mcp]" # Analytics — orientation, health check and routing The `analytics` plugin adds a **semantic layer** to this app. It is built on -[drizzle-cube](https://www.drizzle-cube.dev), pinned at **0.8.3**. It gives you: +[drizzle-cube](https://www.drizzle-cube.dev), pinned at **0.12.2**. It gives you: - **Cubes**: named, tenant-scoped definitions of measures (counts, sums, distinct counts) and dimensions (role, event type, dates) over the app's own Postgres tables. @@ -115,7 +115,7 @@ project root: Or set the environment variables `DRIZZLE_CUBE_SERVER_URL` and `DRIZZLE_CUBE_API_TOKEN`. It sends the token as `Authorization: Bearer …`, which is exactly what this app accepts. **Warn the person about two things:** -- Its generic skills show an older cube syntax that does not compile on drizzle-cube 0.8.3 +- Its generic skills show an older cube syntax that does not compile on drizzle-cube 0.12.2 (`analytics-cubes` explains the difference). Prefer this plugin's skills when writing code here. - `.drizzle-cube.json` holds a credential, so add it to `.gitignore`. From 55321bddee5db2f6d5add49dbca4c2853d7c003d Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Tue, 3 Nov 2026 15:27:22 +0000 Subject: [PATCH 3/6] analytics: make the date control override a saved dateRange drizzle-cube resolves a universal time filter from dateRange before values, and its filter editor saves both. dashboardDateFilters only replaced values, so once a dashboard's date filter had been edited the page's 7/30/90-day control no longer changed that dashboard. Co-Authored-By: Claude Opus 5.5 --- .../analytics/tests/ui/date-filter.test.tsx | 26 +++++++++++++++++++ .../ui/hooks/useDashboardDateFilter.ts | 14 +++++----- plugins/analytics/docs/upgrades/unreleased.md | 4 ++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx b/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx index 2c537b8..4ebae4b 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx +++ b/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx @@ -82,6 +82,32 @@ describe('date filter — pure helpers', () => { // The source config is not mutated expect(TEMPLATE_CONFIG.filters[0].filter.values).toEqual(['last 90 days']) }) + + it('overrides a saved dateRange too, which drizzle-cube reads before values', () => { + const saved = { + filters: [ + { + id: 'time-filter', + label: 'Date Range', + isUniversalTime: true, + filter: { + member: '__universal_time__', + operator: 'inDateRange' as const, + values: ['last 30 days'], + dateRange: 'last 30 days', + }, + }, + ], + } + expect(dashboardDateFilters(saved, 'last 7 days')[0]?.filter).toMatchObject({ + values: ['last 7 days'], + dateRange: 'last 7 days', + }) + expect(dashboardDateFilters(saved, ['2025-01-01', '2025-01-31'])[0]?.filter).toMatchObject({ + values: ['2025-01-01', '2025-01-31'], + dateRange: ['2025-01-01', '2025-01-31'], + }) + }) }) describe('useDashboardDateFilter — URL sync', () => { diff --git a/plugins/analytics/apps/web/src/plugins/analytics/ui/hooks/useDashboardDateFilter.ts b/plugins/analytics/apps/web/src/plugins/analytics/ui/hooks/useDashboardDateFilter.ts index ebabe53..82cd011 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/ui/hooks/useDashboardDateFilter.ts +++ b/plugins/analytics/apps/web/src/plugins/analytics/ui/hooks/useDashboardDateFilter.ts @@ -6,8 +6,8 @@ * override copies of every universal-time filter with the new `values`, and drizzle-cube merges * them into the dashboard by filter id (`AnalyticsDashboard dashboardFilters`). Only strings the * server's relative-range parser recognises are ever emitted (`last N days`, or an ISO pair): - * an unknown string is not a harmless no-op — drizzle-cube drops the condition and silently - * queries all time — so anything unparseable in the URL falls back to the default. + * the server rejects anything else with a 400, so anything unparseable in the URL falls back to + * the default. */ import type { DashboardConfig } from 'drizzle-cube/client' import { useCallback, useMemo } from 'react' @@ -78,9 +78,11 @@ export function dateRangeValue(state: DateFilterState): DateRangeValue { } /** - * Overrides for every `isUniversalTime` filter in `config` carrying `range` as its values — - * drizzle-cube merges them by `id`, so portlets mapped to the filter follow the page control and - * portlets with their own window (a "last 30 days" KPI) are untouched. Pure — unit-tested. + * Overrides for every `isUniversalTime` filter in `config` carrying `range` as its values and its + * `dateRange` — drizzle-cube merges them by `id`, so portlets mapped to the filter follow the page + * control and portlets with their own window (a "last 30 days" KPI) are untouched. `dateRange` + * must be overridden too: drizzle-cube reads it before `values`, and its filter editor saves one. + * Pure — unit-tested. */ export function dashboardDateFilters( config: Pick | undefined, @@ -89,7 +91,7 @@ export function dashboardDateFilters( const values = Array.isArray(range) ? range : [range] return (config?.filters ?? []) .filter(f => f.isUniversalTime && f.id) - .map(f => ({ ...f, filter: { ...f.filter, values } })) + .map(f => ({ ...f, filter: { ...f.filter, values, dateRange: range } })) } /** Human label for the control's current value. */ diff --git a/plugins/analytics/docs/upgrades/unreleased.md b/plugins/analytics/docs/upgrades/unreleased.md index 7078136..3b02ccc 100644 --- a/plugins/analytics/docs/upgrades/unreleased.md +++ b/plugins/analytics/docs/upgrades/unreleased.md @@ -22,6 +22,7 @@ analytics now compiles on React 19 with React Router 7 as well as on React 18 wi - A `timeDimensions` entry without `granularity` filters and no longer groups, so the Organisation Overview "Active Users (30d)" KPI is one distinct count over 30 days. - A date string drizzle-cube cannot parse, an unknown operator, or a measure filter in an ungrouped query is a 400 instead of being dropped. - `dashboards/general-templates/tenant-overview.ts`: the Organisation Overview "Recent Members" table pins the date filter to `TenantUsers.joinedAt`. It has no `timeDimensions`, so the filter never narrowed it, and drizzle-cube 0.10.1 and later shows a warning on such a card. `tests/config/all-templates.test.ts` now fails a portlet mapped to a universal time filter it cannot reach. +- `ui/hooks/useDashboardDateFilter.ts`: the page date control overrides the universal time filter's `dateRange` as well as its `values`. drizzle-cube reads `dateRange` first, and its filter editor saves one, so after anyone edited a dashboard's date filter the 7/30/90-day control stopped changing that dashboard. - The `analytics*` skills, `README.md` and the dashboard docs name 0.12.2 and its query rules. ## How to apply @@ -41,4 +42,5 @@ analytics now compiles on React 19 with React Router 7 as well as on React 18 wi 1. `rg -c "'@nivo/heatmap'" apps/web/vite.config.ts` prints `1`, and the line uses `import.meta.dirname`. 2. `pnpm gate` passes and prints no `(!)` warning about `configLoader: 'native'`. 3. `pnpm --filter @rocketflare/web why drizzle-cube` resolves `0.12.2` only. -4. On a reset Organisation Overview page, "Recent Members" shows no warning and follows the date control. +4. On a dashboard whose date filter was edited and saved in the editor, the page date control still changes every mapped chart. +5. On a reset Organisation Overview page, "Recent Members" shows no warning and follows the date control. From 7519ef35a1bdbef92f11384618822a8a00183b58 Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Tue, 3 Nov 2026 15:28:17 +0000 Subject: [PATCH 4/6] analytics: count the heatmap alias by its path.resolve call The kit's vite.config.ts names '@nivo/heatmap' in a comment, and Biome splits the alias across lines, so counting the bare name prints 2 on a correct install. Co-Authored-By: Claude Opus 5.5 --- plugins/analytics/docs/upgrades/unreleased.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/analytics/docs/upgrades/unreleased.md b/plugins/analytics/docs/upgrades/unreleased.md index 3b02ccc..059d248 100644 --- a/plugins/analytics/docs/upgrades/unreleased.md +++ b/plugins/analytics/docs/upgrades/unreleased.md @@ -39,7 +39,7 @@ analytics now compiles on React 19 with React Router 7 as well as on React 18 wi ## Verify -1. `rg -c "'@nivo/heatmap'" apps/web/vite.config.ts` prints `1`, and the line uses `import.meta.dirname`. +1. `rg -c "'@nivo/heatmap': path.resolve\(" apps/web/vite.config.ts` prints `1`, and `rg -c __dirname apps/web/vite.config.ts` prints nothing. 2. `pnpm gate` passes and prints no `(!)` warning about `configLoader: 'native'`. 3. `pnpm --filter @rocketflare/web why drizzle-cube` resolves `0.12.2` only. 4. On a dashboard whose date filter was edited and saved in the editor, the page date control still changes every mapped chart. From a53aff4b3eb9012ac008e8f30484a01cbd5a1675 Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Tue, 3 Nov 2026 15:56:32 +0000 Subject: [PATCH 5/6] analytics: keep drizzle-cube's filter bar and the date control in sync 0.12.2 shows its own date filter bar to viewers and disables a filter the host owns unless the host takes its changes. The dashboard now passes onDashboardFiltersChange, so both controls drive the same URL state. A bar choice the page buttons don't offer, such as last 3 months or this quarter, is kept as ?range=rel&rel=, checked against the relative ranges the server parses. Co-Authored-By: Claude Opus 5.5 --- .../tests/ui/dashboard-view.test.tsx | 40 +++++++ .../analytics/tests/ui/date-filter.test.tsx | 41 +++++++ .../web/src/plugins/analytics/ui/CLAUDE.md | 17 +-- .../ui/components/DashboardLoader.tsx | 29 ++++- .../ui/components/DateRangeControl.tsx | 4 +- .../ui/hooks/useDashboardDateFilter.ts | 106 +++++++++++++----- .../analytics/ui/pages/DashboardViewPage.tsx | 1 + plugins/analytics/docs/upgrades/unreleased.md | 4 +- 8 files changed, 202 insertions(+), 40 deletions(-) diff --git a/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/dashboard-view.test.tsx b/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/dashboard-view.test.tsx index f84951a..fd1c8ce 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/dashboard-view.test.tsx +++ b/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/dashboard-view.test.tsx @@ -28,6 +28,10 @@ type FakeProps = { config: FakeConfig editable?: boolean dashboardFilters?: { id: string; filter: { values: unknown[] } }[] + onDashboardFiltersChange?: ( + filters: { id: string; filter: Record }[], + changedFilterId: string + ) => void onConfigChange?: (c: FakeConfig) => void onSave?: (c: FakeConfig) => Promise | void } @@ -61,6 +65,20 @@ vi.mock('drizzle-cube/client', () => ({ + ), })) @@ -115,6 +133,28 @@ describe('Dashboard view page', () => { ) }) + it("follows drizzle-cube's own filter bar, and the page control follows it back", async () => { + mount(analyticsPage()) + await screen.findByTestId('analytics-dashboard') + + fireEvent.click(screen.getByRole('button', { name: 'fake-bar-3m' })) + await waitFor(() => + expect( + JSON.parse(screen.getByTestId('analytics-dashboard').getAttribute('data-filters') ?? '[]') + ).toMatchObject([{ filter: { values: ['last 3 months'], dateRange: 'last 3 months' } }]) + ) + for (const name of ['Last 7 days', 'Last 30 days', 'Last 90 days']) { + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'false') + } + + fireEvent.click(screen.getByRole('button', { name: 'Last 30 days' })) + await waitFor(() => + expect( + JSON.parse(screen.getByTestId('analytics-dashboard').getAttribute('data-filters') ?? '[]') + ).toMatchObject([{ filter: { values: ['last 30 days'], dateRange: 'last 30 days' } }]) + ) + }) + it('edit mode → onConfigChange → one debounced PATCH of the whole config', async () => { const fetchMock = mount(analyticsPage()) await screen.findByTestId('analytics-dashboard') diff --git a/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx b/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx index 4ebae4b..345efd6 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx +++ b/plugins/analytics/apps/web/src/plugins/analytics/tests/ui/date-filter.test.tsx @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest' import { dashboardDateFilters, dateFilterParams, + dateFilterStateOf, dateRangeLabel, dateRangeValue, parseDateFilterParams, @@ -38,11 +39,51 @@ describe('date filter — pure helpers', () => { 'range=custom&from=2025-01-31&to=2025-01-01', // inverted 'range=custom&from=yesterday&to=2025-01-01', 'range=custom&from=2025-13-40&to=2025-12-31', + 'range=rel', + 'range=rel&rel=past%2090%20days', + 'range=rel&rel=Last%203%20months', + 'range=rel&rel=last%200%20days', ]) { expect(parseDateFilterParams(params(q))).toEqual({ preset: '90d' }) } }) + it('parses any relative range the server accepts, folding our presets back into them', () => { + for (const rel of ['today', 'last 3 months', 'last 12 months', 'this year', 'next quarter']) { + expect(parseDateFilterParams(params(`range=rel&rel=${encodeURIComponent(rel)}`))).toEqual({ + preset: 'rel', + rel, + }) + } + expect(parseDateFilterParams(params('range=rel&rel=last%207%20days'))).toEqual({ preset: '7d' }) + expect(dateFilterParams({ preset: 'rel', rel: 'last 3 months' })).toEqual({ + range: 'rel', + rel: 'last 3 months', + }) + expect(dateRangeValue({ preset: 'rel', rel: 'this year' })).toBe('this year') + expect(dateRangeLabel({ preset: 'rel', rel: 'last 3 months' })).toBe('Last 3 months') + expect(dateRangeValue({ preset: 'rel' })).toBe('last 90 days') + }) + + it("maps a filter bar's choice to state, or to nothing it cannot represent", () => { + expect(dateFilterStateOf('last 30 days')).toEqual({ preset: '30d' }) + expect(dateFilterStateOf(['last 30 days'])).toEqual({ preset: '30d' }) + expect(dateFilterStateOf('this quarter')).toEqual({ preset: 'rel', rel: 'this quarter' }) + expect(dateFilterStateOf(['2025-01-01', '2025-01-31'])).toEqual({ + preset: 'custom', + from: '2025-01-01', + to: '2025-01-31', + }) + for (const bad of [ + 'since January', + [], + ['2025-01-31', '2025-01-01'], + ['2025-01-01', '2025-01-02', '2025-01-03'], + ]) { + expect(dateFilterStateOf(bad), JSON.stringify(bad)).toBeNull() + } + }) + it('emits exactly the relative strings drizzle-cube parses, or an ISO pair', () => { expect(dateRangeValue({ preset: '7d' })).toBe('last 7 days') expect(dateRangeValue({ preset: '30d' })).toBe('last 30 days') diff --git a/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md b/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md index 01f1d54..4f3c6e9 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md +++ b/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md @@ -25,8 +25,8 @@ Paths below are relative to `apps/web/src/plugins/analytics/`. (`@rocketflare/shared/analytics`), the provider wiring and the theme mapping. drizzle-cube 0.12.2 client API actually used: `CubeProvider` from `drizzle-cube/client/providers` (`apiOptions`, `queryClient`, `features`), `AnalyticsDashboard` (`config`, `editable`, `dashboardFilters`, - `onConfigChange`, `onSave`, `loadingComponent`), `AnalysisBuilder` + `AnalysisBuilderRef` - (`getAnalysisConfig()`), types `DashboardConfig`/`PortletConfig`/`CubeApiOptions`/ + `onDashboardFiltersChange`, `onConfigChange`, `onSave`, `loadingComponent`), `AnalysisBuilder` + + `AnalysisBuilderRef` (`getAnalysisConfig()`), types `DashboardConfig`/`PortletConfig`/`CubeApiOptions`/ `FeaturesConfig` from `drizzle-cube/client`, and `drizzle-cube/client/styles.css`. - **Same-origin cookie auth**: `CubeClientProvider` passes `apiOptions = { apiUrl: '/cubejs-api/v1', credentials: 'include', headers: { 'X-Requested-With': 'fetch' } }` — the @@ -66,11 +66,14 @@ Paths below are relative to `apps/web/src/plugins/analytics/`. Template pages (`templateKey !== null`) offer "Reset to template", never delete (server: 403 `template_page`); user pages the reverse. "Start from template" copies the config from the pure `src/dashboards` registry client-side (`getTemplate(key).config` → `POST /pages`). -- **Date range** is URL state (`useDashboardDateFilter`), never a store: presets emit exactly - `'last 7|30|90 days'` or an ISO pair — an unknown relative string makes drizzle-cube DROP the - condition and silently query all time, so anything unparseable falls back to 90 days. - `dashboardDateFilters(config, range)` returns override copies of the `isUniversalTime` filters; - `AnalyticsDashboard dashboardFilters` merges them by id, so a KPI with its own window is untouched. +- **Date range** is URL state (`useDashboardDateFilter`), never a store: the page buttons emit + `'last 7|30|90 days'` or an ISO pair, and drizzle-cube's own filter bar may set any relative + range its server parses (`?range=rel&rel=last 3 months`, checked against `RELATIVE_RANGE`). The + server rejects anything else with a 400, so anything unparseable falls back to 90 days. + `dashboardDateFilters(config, range)` returns override copies of the `isUniversalTime` filters, + `values` and `dateRange` both; `AnalyticsDashboard dashboardFilters` merges them by id, so a KPI + with its own window is untouched. The bar's changes come back through + `onDashboardFiltersChange`; without that callback drizzle-cube disables a host-owned filter. - **Explore → Save to dashboard** (admin+): `ref.getAnalysisConfig()` becomes a portlet (`analysisConfig`, the canonical format) appended as a full-width `rows` entry with mirrored x/y/w/h (`appendPortlet`, pure) and saved with `PATCH /pages/:id`. diff --git a/plugins/analytics/apps/web/src/plugins/analytics/ui/components/DashboardLoader.tsx b/plugins/analytics/apps/web/src/plugins/analytics/ui/components/DashboardLoader.tsx index a274c3b..a0e2c23 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/ui/components/DashboardLoader.tsx +++ b/plugins/analytics/apps/web/src/plugins/analytics/ui/components/DashboardLoader.tsx @@ -3,7 +3,8 @@ * no chart code: the grid editor, portlet editor, drill-down and charts are the library's. This * component owns the glue — the page config as local state (seeded from the row, re-seeded when * the server row changes and nothing is dirty, e.g. after a reset), the page-level date range - * applied as `dashboardFilters` overrides of the template's `isUniversalTime` filter, and + * applied as `dashboardFilters` overrides of the template's `isUniversalTime` filter (and a change + * made in drizzle-cube's own filter bar handed back to the page through `onRangeChange`), and * autosave: in edit mode every `onConfigChange` schedules a debounced whole-config PATCH * (`useAutosaveDashboardConfig`), `onSave` (the editor's explicit save) flushes it, leaving edit * mode flushes it, and while anything is unsaved a `beforeunload` guard warns the reader. @@ -17,13 +18,19 @@ import { AnalyticsDashboard, type DashboardConfig } from 'drizzle-cube/client' import { useCallback, useEffect, useMemo, useState } from 'react' import { LoadingIndicator } from '@/plugins/api/ui' import { useAutosaveDashboardConfig } from '../hooks/useAnalyticsPages' -import { type DateRangeValue, dashboardDateFilters } from '../hooks/useDashboardDateFilter' +import { + type DashboardFilter, + type DateRangeValue, + dashboardDateFilters, +} from '../hooks/useDashboardDateFilter' interface DashboardLoaderProps { page: AnalyticsPage /** Edit mode (admin+, `manage Dashboard`) — the caller has already checked the ability. */ editing: boolean range: DateRangeValue + /** A universal-time filter value chosen in drizzle-cube's filter bar. */ + onRangeChange?: (value: unknown) => void onDirtyChange?: (dirty: boolean) => void } @@ -32,7 +39,13 @@ export function asDashboardConfig(config: DashboardConfigJson): DashboardConfig return config as unknown as DashboardConfig } -export function DashboardLoader({ page, editing, range, onDirtyChange }: DashboardLoaderProps) { +export function DashboardLoader({ + page, + editing, + range, + onRangeChange, + onDirtyChange, +}: DashboardLoaderProps) { const [config, setConfig] = useState(() => asDashboardConfig(page.config)) const autosave = useAutosaveDashboardConfig(page.id) const { dirty, flush, schedule } = autosave @@ -82,12 +95,22 @@ export function DashboardLoader({ page, editing, range, onDirtyChange }: Dashboa const dashboardFilters = useMemo(() => dashboardDateFilters(config, range), [config, range]) + const handleFiltersChange = useCallback( + (filters: DashboardFilter[], changedFilterId: string) => { + const changed = filters.find(f => f.id === changedFilterId) + if (!changed?.isUniversalTime || !('member' in changed.filter)) return + onRangeChange?.(changed.filter.dateRange ?? changed.filter.values) + }, + [onRangeChange] + ) + return (
} diff --git a/plugins/analytics/apps/web/src/plugins/analytics/ui/components/DateRangeControl.tsx b/plugins/analytics/apps/web/src/plugins/analytics/ui/components/DateRangeControl.tsx index 9ec2a0c..eb17e57 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/ui/components/DateRangeControl.tsx +++ b/plugins/analytics/apps/web/src/plugins/analytics/ui/components/DateRangeControl.tsx @@ -5,14 +5,14 @@ */ import { useState } from 'react' import { + type ButtonPreset, DATE_RANGE_PRESETS, type DateFilterState, - type DateRangePreset, } from '../hooks/useDashboardDateFilter' interface DateRangeControlProps { state: DateFilterState - onPreset: (preset: Exclude) => void + onPreset: (preset: ButtonPreset) => void onCustom: (from: string, to: string) => void } diff --git a/plugins/analytics/apps/web/src/plugins/analytics/ui/hooks/useDashboardDateFilter.ts b/plugins/analytics/apps/web/src/plugins/analytics/ui/hooks/useDashboardDateFilter.ts index 82cd011..a1eb96a 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/ui/hooks/useDashboardDateFilter.ts +++ b/plugins/analytics/apps/web/src/plugins/analytics/ui/hooks/useDashboardDateFilter.ts @@ -1,11 +1,12 @@ /** * Dashboard date range (D19, D20): the page-level "last 7 / 30 / 90 days / custom" control, - * synced to the URL (`?range=7d|30d|90d|custom&from=YYYY-MM-DD&to=YYYY-MM-DD`) so a dashboard - * link carries its window and the browser back button restores it. The value feeds the + * synced to the URL (`?range=7d|30d|90d|custom&from=YYYY-MM-DD&to=YYYY-MM-DD`, or + * `?range=rel&rel=` for a choice made in drizzle-cube's own filter bar) so a + * dashboard link carries its window and the browser back button restores it. The value feeds the * template's `isUniversalTime` dashboard filter: `dashboardDateFilters(config, range)` returns * override copies of every universal-time filter with the new `values`, and drizzle-cube merges * them into the dashboard by filter id (`AnalyticsDashboard dashboardFilters`). Only strings the - * server's relative-range parser recognises are ever emitted (`last N days`, or an ISO pair): + * server's relative-range parser recognises are ever emitted (`RELATIVE_RANGE`, or an ISO pair): * the server rejects anything else with a 400, so anything unparseable in the URL falls back to * the default. */ @@ -16,24 +17,34 @@ import { useSearchParams } from 'react-router-dom' /** One dashboard-level filter as drizzle-cube types it (`DashboardConfig['filters'][n]`). */ export type DashboardFilter = NonNullable[number] -export type DateRangePreset = '7d' | '30d' | '90d' | 'custom' +export type DateRangePreset = '7d' | '30d' | '90d' | 'custom' | 'rel' -export const DATE_RANGE_PRESETS: { key: Exclude; label: string }[] = [ +/** The presets the page control offers as buttons. */ +export type ButtonPreset = Exclude + +export const DATE_RANGE_PRESETS: { key: ButtonPreset; label: string }[] = [ { key: '7d', label: 'Last 7 days' }, { key: '30d', label: 'Last 30 days' }, { key: '90d', label: 'Last 90 days' }, ] /** Matches the template's `'last 90 days'` default. */ -export const DEFAULT_DATE_RANGE_PRESET: Exclude = '90d' +export const DEFAULT_DATE_RANGE_PRESET: ButtonPreset = '90d' /** drizzle-cube's relative form: `last days`. */ -const PRESET_VALUES: Record, string> = { +const PRESET_VALUES: Record = { '7d': 'last 7 days', '30d': 'last 30 days', '90d': 'last 90 days', } +/** The relative ranges drizzle-cube's server parses (lower case only). */ +const RELATIVE_RANGE = new RegExp( + '^(today|yesterday|tomorrow' + + '|last [1-9]\\d{0,3} (days|weeks|months|quarters|years)' + + '|(this|last|next) (week|month|quarter|year))$' +) + /** A cube date range: one relative string or an inclusive ISO-date pair. */ export type DateRangeValue = string | [string, string] @@ -42,6 +53,8 @@ export interface DateFilterState { /** ISO dates, only meaningful when `preset === 'custom'`. */ from?: string to?: string + /** A `RELATIVE_RANGE` string, only meaningful when `preset === 'rel'`. */ + rel?: string } const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ @@ -50,16 +63,41 @@ export function isIsoDate(value: string | null | undefined): value is string { return typeof value === 'string' && ISO_DATE.test(value) && !Number.isNaN(Date.parse(value)) } +/** A relative range → state (a button preset when one matches); null if the server rejects it. */ +function relativeState(value: string | null): DateFilterState | null { + if (value === null || !RELATIVE_RANGE.test(value)) return null + const preset = DATE_RANGE_PRESETS.find(p => PRESET_VALUES[p.key] === value)?.key + return preset ? { preset } : { preset: 'rel', rel: value } +} + +function customState(from: string | null, to: string | null): DateFilterState | null { + return isIsoDate(from) && isIsoDate(to) && from <= to ? { preset: 'custom', from, to } : null +} + /** URL → state; anything unparseable (unknown preset, bad or inverted dates) is the default. */ export function parseDateFilterParams(params: URLSearchParams): DateFilterState { const range = params.get('range') if (range === '7d' || range === '30d' || range === '90d') return { preset: range } - if (range === 'custom') { - const from = params.get('from') - const to = params.get('to') - if (isIsoDate(from) && isIsoDate(to) && from <= to) return { preset: 'custom', from, to } - } - return { preset: DEFAULT_DATE_RANGE_PRESET } + const parsed = + range === 'custom' + ? customState(params.get('from'), params.get('to')) + : range === 'rel' + ? relativeState(params.get('rel')) + : null + return parsed ?? { preset: DEFAULT_DATE_RANGE_PRESET } +} + +/** + * A date filter's value, as drizzle-cube's filter bar reports it (a relative string, alone or in + * a one-element array, or an ISO pair) → state; null for anything the page cannot represent. + */ +export function dateFilterStateOf(value: unknown): DateFilterState | null { + const values: unknown[] = Array.isArray(value) ? value : [value] + if (!values.every(v => typeof v === 'string')) return null + const strings = values as string[] + if (strings.length === 1) return relativeState(strings[0] ?? null) + if (strings.length === 2) return customState(strings[0] ?? null, strings[1] ?? null) + return null } /** State → the query-string entries (only the keys the state needs). */ @@ -67,21 +105,29 @@ export function dateFilterParams(state: DateFilterState): Record if (state.preset === 'custom' && state.from && state.to) { return { range: 'custom', from: state.from, to: state.to } } - return { range: state.preset === 'custom' ? DEFAULT_DATE_RANGE_PRESET : state.preset } + if (state.preset === 'rel' && state.rel) return { range: 'rel', rel: state.rel } + return { range: buttonPresetOf(state) } +} + +function buttonPresetOf(state: DateFilterState): ButtonPreset { + return state.preset === 'custom' || state.preset === 'rel' + ? DEFAULT_DATE_RANGE_PRESET + : state.preset } /** State → the value drizzle-cube receives in `filter.values`. */ export function dateRangeValue(state: DateFilterState): DateRangeValue { if (state.preset === 'custom' && state.from && state.to) return [state.from, state.to] - const preset = state.preset === 'custom' ? DEFAULT_DATE_RANGE_PRESET : state.preset - return PRESET_VALUES[preset] + if (state.preset === 'rel' && state.rel) return state.rel + return PRESET_VALUES[buttonPresetOf(state)] } /** - * Overrides for every `isUniversalTime` filter in `config` carrying `range` as its values and its - * `dateRange` — drizzle-cube merges them by `id`, so portlets mapped to the filter follow the page - * control and portlets with their own window (a "last 30 days" KPI) are untouched. `dateRange` - * must be overridden too: drizzle-cube reads it before `values`, and its filter editor saves one. + * Overrides for every `isUniversalTime` filter in `config` carrying `range` as its values and + * its `dateRange` — drizzle-cube merges them by `id`, so portlets mapped to the filter follow the + * page control and portlets with their own window (a "last 30 days" KPI) are untouched. + * `dateRange` must be overridden too: drizzle-cube reads it before `values`, and its filter + * editor saves one. * Pure — unit-tested. */ export function dashboardDateFilters( @@ -97,7 +143,8 @@ export function dashboardDateFilters( /** Human label for the control's current value. */ export function dateRangeLabel(state: DateFilterState): string { if (state.preset === 'custom' && state.from && state.to) return `${state.from} → ${state.to}` - const preset = state.preset === 'custom' ? DEFAULT_DATE_RANGE_PRESET : state.preset + if (state.preset === 'rel' && state.rel) return state.rel[0]?.toUpperCase() + state.rel.slice(1) + const preset = buttonPresetOf(state) return DATE_RANGE_PRESETS.find(p => p.key === preset)?.label ?? preset } @@ -112,7 +159,7 @@ export function useDashboardDateFilter() { setParams( prev => { const out = new URLSearchParams(prev) - for (const key of ['range', 'from', 'to']) out.delete(key) + for (const key of ['range', 'from', 'to', 'rel']) out.delete(key) for (const [k, v] of Object.entries(dateFilterParams(next))) out.set(k, v) return out }, @@ -122,16 +169,21 @@ export function useDashboardDateFilter() { [setParams] ) - const setPreset = useCallback( - (preset: Exclude) => apply({ preset }), - [apply] - ) + const setPreset = useCallback((preset: ButtonPreset) => apply({ preset }), [apply]) const setCustom = useCallback( (from: string, to: string) => { if (isIsoDate(from) && isIsoDate(to) && from <= to) apply({ preset: 'custom', from, to }) }, [apply] ) + /** A value from drizzle-cube's own filter bar; one the page cannot represent is ignored. */ + const setFromFilter = useCallback( + (value: unknown) => { + const next = dateFilterStateOf(value) + if (next) apply(next) + }, + [apply] + ) - return { state, range, label: dateRangeLabel(state), setPreset, setCustom } + return { state, range, label: dateRangeLabel(state), setPreset, setCustom, setFromFilter } } diff --git a/plugins/analytics/apps/web/src/plugins/analytics/ui/pages/DashboardViewPage.tsx b/plugins/analytics/apps/web/src/plugins/analytics/ui/pages/DashboardViewPage.tsx index f32d40b..c300b98 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/ui/pages/DashboardViewPage.tsx +++ b/plugins/analytics/apps/web/src/plugins/analytics/ui/pages/DashboardViewPage.tsx @@ -200,6 +200,7 @@ export default function DashboardViewPage() { page={row} editing={editing && canManage} range={dateFilter.range} + onRangeChange={dateFilter.setFromFilter} onDirtyChange={onDirtyChange} /> diff --git a/plugins/analytics/docs/upgrades/unreleased.md b/plugins/analytics/docs/upgrades/unreleased.md index 059d248..7312401 100644 --- a/plugins/analytics/docs/upgrades/unreleased.md +++ b/plugins/analytics/docs/upgrades/unreleased.md @@ -23,6 +23,7 @@ analytics now compiles on React 19 with React Router 7 as well as on React 18 wi - A date string drizzle-cube cannot parse, an unknown operator, or a measure filter in an ungrouped query is a 400 instead of being dropped. - `dashboards/general-templates/tenant-overview.ts`: the Organisation Overview "Recent Members" table pins the date filter to `TenantUsers.joinedAt`. It has no `timeDimensions`, so the filter never narrowed it, and drizzle-cube 0.10.1 and later shows a warning on such a card. `tests/config/all-templates.test.ts` now fails a portlet mapped to a universal time filter it cannot reach. - `ui/hooks/useDashboardDateFilter.ts`: the page date control overrides the universal time filter's `dateRange` as well as its `values`. drizzle-cube reads `dateRange` first, and its filter editor saves one, so after anyone edited a dashboard's date filter the 7/30/90-day control stopped changing that dashboard. +- `ui/components/DashboardLoader.tsx`, `ui/hooks/useDashboardDateFilter.ts`: drizzle-cube 0.12.2 shows its own date filter bar to viewers too, and disables a filter the host owns unless the host takes its changes. The dashboard now passes `onDashboardFiltersChange`, so the bar and the page's 7/30/90-day control drive the same URL state. A bar choice the buttons don't offer is kept as `?range=rel&rel=`, such as `last 3 months` or `this quarter`; existing `?range=` links are unchanged. - The `analytics*` skills, `README.md` and the dashboard docs name 0.12.2 and its query rules. ## How to apply @@ -43,4 +44,5 @@ analytics now compiles on React 19 with React Router 7 as well as on React 18 wi 2. `pnpm gate` passes and prints no `(!)` warning about `configLoader: 'native'`. 3. `pnpm --filter @rocketflare/web why drizzle-cube` resolves `0.12.2` only. 4. On a dashboard whose date filter was edited and saved in the editor, the page date control still changes every mapped chart. -5. On a reset Organisation Overview page, "Recent Members" shows no warning and follows the date control. +5. On a dashboard, choosing 3M in drizzle-cube's filter bar sets `?range=rel&rel=last+3+months`, and choosing "Last 30 days" in the page control selects 30D in the bar. +6. On a reset Organisation Overview page, "Recent Members" shows no warning and follows the date control. From e17b607131c0d6a01ef00715fe62a79040ca85c8 Mon Sep 17 00:00:00 2001 From: Robin Bowes Date: Tue, 3 Nov 2026 16:07:24 +0000 Subject: [PATCH 6/6] analytics: note the format step and remapping after the upgrade plugin upgrade replaces the heatmap alias line but does not run Biome, so the gate fails lint until vite.config.ts is formatted. Pages copied from Organisation Overview keep the old mapping and cannot be reset, so the note says how to remap the card instead. Co-Authored-By: Claude Opus 5.5 --- plugins/analytics/docs/upgrades/unreleased.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/analytics/docs/upgrades/unreleased.md b/plugins/analytics/docs/upgrades/unreleased.md index 7312401..c03f507 100644 --- a/plugins/analytics/docs/upgrades/unreleased.md +++ b/plugins/analytics/docs/upgrades/unreleased.md @@ -29,8 +29,11 @@ analytics now compiles on React 19 with React Router 7 as well as on React 18 wi ## How to apply 1. Run `pnpm plugin upgrade analytics --apply`. It replaces the changed source, test and skill files, the `'@nivo/heatmap'` line in `apps/web/vite.config.ts`, and the `drizzle-cube` pin in `apps/web/package.json`. -2. Run `pnpm install`. -3. Existing Organisation Overview pages keep the old mapping and show the warning on "Recent Members". To fix one, a member with manage Dashboard resets it: `POST /api/analytics/pages/:id/reset`, or `POST /api/analytics/templates/recreate` for every template page in the organisation. A reset discards that page's own edits, so skip it for a page the organisation has customised and remap the card in the editor instead. +2. Run `pnpm exec biome format --write apps/web/vite.config.ts`. The upgrade writes the new alias as one line that is too long for Biome, and `pnpm gate` fails its lint step until the file is formatted. +3. Run `pnpm install`. +4. Pages saved before this release keep the old "Recent Members" mapping and show drizzle-cube's warning that the date filter does not narrow the card. This covers Organisation Overview template pages and any page copied from one. + - A template page: a member with manage Dashboard resets it with `POST /api/analytics/pages/:id/reset`, or resets every template page in the organisation with `POST /api/analytics/templates/recreate`. A reset discards that page's own edits. + - A customised or user-created page: in the grid editor, open the card's filter settings (the funnel icon) and set "Apply to field" for "Date Range" to "Joined" (`TenantUsers.joinedAt`). The grid editor needs a dashboard at least 1200 px wide. ## Conflicts to expect