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/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 5921bca..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') @@ -82,17 +123,38 @@ 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', () => { 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/CLAUDE.md b/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md index d6e3e3e..4f3c6e9 100644 --- a/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md +++ b/plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md @@ -22,11 +22,11 @@ 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` - (`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 ebabe53..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,13 +1,14 @@ /** * 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): - * 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. + * 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. */ import type { DashboardConfig } from 'drizzle-cube/client' import { useCallback, useMemo } from 'react' @@ -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,20 +105,30 @@ 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 — - * 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,13 +137,14 @@ 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. */ 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 } @@ -110,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 }, @@ -120,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/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..c03f507 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, docs] touches_surfaces: [] requires_surfaces: [] manual: false @@ -12,11 +12,40 @@ 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`. 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. +- `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 +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 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 +`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': 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. +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. diff --git a/plugins/analytics/rocketflare-plugin.json b/plugins/analytics/rocketflare-plugin.json index 919ce23..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" @@ -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/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`.