@definite-app/data-apps 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CLAUDE.md +686 -0
  2. package/LICENSE +201 -0
  3. package/README.md +643 -0
  4. package/build.mjs +459 -0
  5. package/examples/_refined_demo/app.json +15 -0
  6. package/examples/_refined_demo/data/sample.parquet +0 -0
  7. package/examples/_refined_demo/gen_preview_data.py +59 -0
  8. package/examples/_refined_demo/preview-data.json +13 -0
  9. package/examples/_refined_demo/src/App.tsx +188 -0
  10. package/examples/_refined_demo/src/main.tsx +12 -0
  11. package/examples/loan-portfolio/app.json +31 -0
  12. package/examples/loan-portfolio/data/loan_book.parquet +0 -0
  13. package/examples/loan-portfolio/gen_preview_data.py +454 -0
  14. package/examples/loan-portfolio/preview-data.json +84 -0
  15. package/examples/loan-portfolio/src/App.tsx +1103 -0
  16. package/examples/loan-portfolio/src/main.tsx +12 -0
  17. package/examples/revenue-explorer/app.json +23 -0
  18. package/examples/revenue-explorer/data/transactions.parquet +0 -0
  19. package/examples/revenue-explorer/gen_preview_data.py +129 -0
  20. package/examples/revenue-explorer/preview-data.json +49 -0
  21. package/examples/revenue-explorer/src/App.tsx +527 -0
  22. package/examples/revenue-explorer/src/main.tsx +12 -0
  23. package/package.json +55 -0
  24. package/preview.mjs +35 -0
  25. package/runtime/definite-runtime.tsx +5934 -0
  26. package/scripts/headless-smoke.mjs +196 -0
  27. package/templates/blank/app.json +15 -0
  28. package/templates/blank/src/App.tsx +41 -0
  29. package/templates/blank/src/main.tsx +12 -0
  30. package/templates/refined/app.json +15 -0
  31. package/templates/refined/src/App.tsx +198 -0
  32. package/templates/refined/src/main.tsx +12 -0
package/CLAUDE.md ADDED
@@ -0,0 +1,686 @@
1
+ # Definite Data Apps
2
+
3
+ Build source-authored React data apps that compile to a single HTML file and run through the Definite iframe bridge with DuckDB WASM and Perspective.js.
4
+
5
+ ## How data flows (read this first)
6
+
7
+ Understanding this pipeline prevents the most common bugs:
8
+
9
+ ```
10
+ app.json Definite platform Browser DuckDB WASM App.tsx
11
+ (manifest) --> (server-side fetch) --> (local tables) --> (client-side SQL)
12
+ ```
13
+
14
+ 1. **app.json** declares every data resource the app needs. Each resource has a key, a `kind`, and a `source` (SQL, Cube, or file).
15
+ 2. **The Definite platform** reads the manifest and fetches data server-side (from DuckLake, Cube, GCS, etc.). The app itself never talks to the warehouse directly.
16
+ 3. **The runtime** loads the fetched data into a **browser-side DuckDB WASM** instance as local tables. Each `kind: "dataset"` resource becomes a DuckDB table; `kind: "json"` resources are returned as plain arrays.
17
+ 4. **App.tsx** queries those local tables via `useSqlQuery(dataset, sql, deps)`. These SQL queries run in the browser against DuckDB WASM, not against the server.
18
+
19
+ **Key implication:** The column names in your `useSqlQuery` SQL must match the column names in the local DuckDB table, which are determined by how the data was fetched:
20
+
21
+ - **SQL resources (`type: "sql"`):** Column names are the aliases you define in the SQL. `SELECT foo AS myColumn` means the local table has a column called `myColumn`. **You control the names. Always use camelCase aliases.**
22
+ - **Cube resources (`type: "cube"`):** Column names are the Cube member **titles** (e.g., `"Credit Projects FICO Band"`), NOT the member names (e.g., `projects.fico_band`). These are long, have spaces, and require double-quoting in SQL. **Prefer SQL resources over Cube resources for data apps** to avoid this complexity.
23
+
24
+ ### Manifest-backed access is enforced
25
+
26
+ Apps can only access data declared in `app.json`. This is validated at two levels:
27
+
28
+ - **Build time:** `build.mjs` scans App.tsx and checks that every `useDataset("key")` and `useJsonResource("key")` call uses a literal string key that exists in `app.json` with the correct `kind`. Build fails if not.
29
+ - **Runtime:** The platform only sends data for resources listed in the manifest. There is no way to run arbitrary SQL against the warehouse from the app.
30
+
31
+ The build **cannot** validate column names inside `useSqlQuery` strings. Column name mismatches are runtime errors that show as "Binder Error: Referenced column not found" in the app.
32
+
33
+ ## What you build
34
+
35
+ Each app lives under `examples/{slug}/`:
36
+
37
+ ```
38
+ examples/{slug}/
39
+ app.json <-- manifest: declares all data resources
40
+ src/main.tsx <-- entry point (don't edit)
41
+ src/App.tsx <-- your UI code (imports from "@definite/runtime")
42
+ preview-data.json <-- optional: dummy data for local preview
43
+ dist/index.html <-- built artifact (generated by build.mjs)
44
+ ```
45
+
46
+ ### Repo layout
47
+
48
+ ```
49
+ definite-data-apps/
50
+ build.mjs <-- build script (compiles app to single HTML)
51
+ preview.mjs <-- preview script (builds with embedded dummy data)
52
+ Makefile <-- convenience commands
53
+ package.json
54
+ runtime/
55
+ definite-runtime.tsx <-- canonical runtime source
56
+ templates/
57
+ blank/ <-- starter template for new apps
58
+ app.json
59
+ src/main.tsx
60
+ src/App.tsx <-- imports from "@definite/runtime"
61
+ examples/
62
+ revenue-explorer/ <-- example app
63
+ starter/ <-- symlink to examples/revenue-explorer/
64
+ ```
65
+
66
+ ## Bootstrap flow
67
+
68
+ ### 1. Create a new app
69
+
70
+ ```bash
71
+ make new-app NAME=my-app # blank template (default)
72
+ make new-app NAME=my-app TEMPLATE=refined # sidebar shell + drill drawer
73
+ ```
74
+
75
+ This copies `templates/<template>/` to `examples/my-app/`. The runtime is resolved via the `@definite/runtime` import alias; no per-app runtime file is needed.
76
+
77
+ **Pick the template by shape, not by app size:**
78
+
79
+ | Template | When to use |
80
+ |----------|-------------|
81
+ | `blank` (default) | Single-view dashboards, embedded Doc tiles, anything that doesn't need a navigation rail. Built on `AppShell`. |
82
+ | `refined` | Multi-view analytics apps with ≥ 2 views, a sidebar, or ≥ 5 filter dimensions. Built on `ShellLayout` + `Sidebar` + `DrillProvider`. |
83
+
84
+ A `refined` app that turns out to only need one view is fine — strip the nav down to one item and keep the shell. But a `blank` app that grows to 6 views will end up reinventing the sidebar; switch templates early if you see that coming.
85
+
86
+ ### 2. Edit app.json and src/App.tsx
87
+
88
+ - Update `app.json` with your data resources (SQL queries, column aliases).
89
+ - Update `src/App.tsx` with your UI code and queries.
90
+ - Do NOT edit `src/main.tsx`. The runtime comes from the `@definite/runtime` alias, resolved at build time.
91
+
92
+ ### 3. Build
93
+
94
+ ```bash
95
+ make build NAME=my-app
96
+ # or directly:
97
+ node build.mjs examples/my-app
98
+ ```
99
+
100
+ This produces `examples/my-app/dist/index.html`.
101
+
102
+ ### 4. Preview with dummy data (optional)
103
+
104
+ Create a `preview-data.json` in your app directory, then:
105
+
106
+ ```bash
107
+ make preview NAME=my-app
108
+ # or directly:
109
+ node preview.mjs my-app
110
+ ```
111
+
112
+ This builds with embedded preview data so you can open the HTML directly in a browser without the Definite platform.
113
+
114
+ ### Build validation
115
+
116
+ The build validates manifest-backed resource usage before emitting `dist/index.html`. It fails if:
117
+
118
+ - `useDataset()` or `useJsonResource()` is called without a literal string key
119
+ - A hook key is missing from `app.json`
120
+ - A hook key exists in `app.json` but has the wrong `kind`
121
+
122
+ ## Critical constraints
123
+
124
+ 1. Keep DuckDB WASM at `1.29.0`, Apache Arrow at `17.0.0`, and Perspective at `4.3.0`. These are pinned because the current Arrow ingestion path is version-sensitive.
125
+ 2. **Resource access is manifest-backed.** Every dataset or JSON resource must be declared in `app.json` before App.tsx can use it. Do not hardcode arbitrary bridge SQL in the component tree.
126
+ 3. **Always use SQL resources (`type: "sql"`) instead of Cube resources (`type: "cube"`) for data apps.** SQL resources give you full control over column names via aliases. Cube resources return columns named by their Cube *titles* (e.g., `"Credit Projects FICO Band"`) which are long, have spaces, and require double-quoting everywhere. This is the #1 source of bugs.
127
+ 4. **Column names in `useSqlQuery` must exactly match the aliases in your app.json SQL.** If app.json has `SELECT fico::INTEGER AS fico`, the local table column is `fico`. If you write `useSqlQuery(data, "SELECT ficoBand FROM ...")` but `ficoBand` isn't an alias in app.json, you get a runtime Binder Error.
128
+ 5. Keep Perspective table columns camelCase and convert DuckDB dates/timestamps to `VARCHAR` via `STRFTIME(col, '%Y-%m-%d')` in the app.json SQL. Do not pass raw underscored warehouse column names through.
129
+ 6. If a resource is a `.duckdb` file, prefer manifest fields like `alias` and `table` so the app can address it cleanly after attach.
130
+ 7. Public embeds are snapshot-only. If a resource needs to work publicly, give it a `snapshot` block in `app.json` or use a downloadable `duckdbFile` / Arrow / Parquet source.
131
+ 8. The runtime uses IndexedDB with a 24-hour TTL for datasets and JSON resources. Cache keys include the resource key, mode, and the embedded manifest resource definition so rebuilt apps invalidate naturally.
132
+ 9. `refresh()` on `useDataset()` and `useJsonResource()` is a hard refresh. It bypasses IndexedDB and repopulates the cache.
133
+ 10. **Never use complex CASE WHEN expressions in client-side `useSqlQuery`.** DuckDB WASM silently returns 0 for compound boolean conditions (especially `NOT IN` with many values, chained `AND`/`LIKE` conditions). Pre-compute all flags as 0/1 integer columns in the app.json SQL (server-side), then use `SUM(flag)::INTEGER` in client-side queries. See "DuckDB WASM limitations" below.
134
+ 11. **Always cast `SUM` results to `::INTEGER`** in client-side SQL. DuckDB WASM may return HUGEINT from SUM which JavaScript cannot handle cleanly. Write `SUM(col)::INTEGER AS myCount`, not `SUM(col) AS myCount`.
135
+
136
+ ## Manifest shape
137
+
138
+ ```json
139
+ {
140
+ "version": 2,
141
+ "name": "Revenue Explorer",
142
+ "entry": "src/main.tsx",
143
+ "resources": {
144
+ "transactions": {
145
+ "kind": "dataset",
146
+ "source": {
147
+ "type": "sql",
148
+ "sql": "SELECT id AS transactionId, STRFTIME(created_at, '%Y-%m-%d') AS transactionDate, amount::DOUBLE AS amount FROM LAKE.SCHEMA.transactions LIMIT 200000"
149
+ },
150
+ "public": false
151
+ },
152
+ "semanticModel": {
153
+ "kind": "dataset",
154
+ "source": {
155
+ "type": "duckdbFile",
156
+ "drivePath": "apps-v2/revenue-explorer/snapshots/model.duckdb",
157
+ "alias": "model",
158
+ "table": "transactions"
159
+ },
160
+ "public": true
161
+ },
162
+ "branches": {
163
+ "kind": "json",
164
+ "source": {
165
+ "type": "sql",
166
+ "sql": "SELECT branch_id AS branchId, branch_name AS branchName FROM LAKE.SCHEMA.branches ORDER BY 2 LIMIT 3000"
167
+ },
168
+ "snapshot": {
169
+ "format": "json",
170
+ "drivePath": "apps-v2/revenue-explorer/snapshots/branches.json"
171
+ },
172
+ "public": true
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
178
+ ### Resource kinds
179
+
180
+ - **`kind: "dataset"`**: Loaded into browser-side DuckDB WASM as a table. Queried via `useSqlQuery()`.
181
+ - **`kind: "json"`**: Returned as a plain JavaScript array. Accessed via `useJsonResource()`.
182
+
183
+ ### Source types
184
+
185
+ - **`type: "sql"`**: SQL query run server-side against DuckLake. Column names are your `AS` aliases.
186
+ - **`type: "duckdbFile"`**: A `.duckdb` file downloaded and attached locally. Use `alias` and `table` fields.
187
+ - **`type: "cube"`**: Cube semantic layer query. **Avoid for data apps** (see constraint #3).
188
+
189
+ ## Runtime helpers
190
+
191
+ Exported from `@definite/runtime`:
192
+
193
+ ### Hooks
194
+
195
+ | Hook | Purpose |
196
+ |------|---------|
197
+ | `useDataset(key, opts?)` | Load a `kind: "dataset"` resource into DuckDB WASM. Returns `{ loading, error, tableRef, perspectiveTable, refresh }`. |
198
+ | `useJsonResource(key, opts?)` | Load a `kind: "json"` resource. Returns `{ loading, error, data, refresh }`. |
199
+ | `useSqlQuery(dataset, sql, deps?)` | Run SQL against a loaded dataset's DuckDB table. Returns `{ loading, error, data }`. `sql` must reference columns by their app.json aliases. |
200
+ | `useTheme()` | Returns current theme (`"dark"` or `"light"`). |
201
+ | `usePerspective(dataset)` | Get Perspective client and table for a dataset. Returns `{ client, table }`. |
202
+
203
+ ### Components
204
+
205
+ See the "UI components" section below for full details on each.
206
+
207
+ **Layout:** `AppShell`, `Card`, `TabGroup`
208
+
209
+ **Data display:** `KpiCard`, `DataTable`, `ReportTable`, `Badge`, `EChart`, `PerspectivePanel`
210
+
211
+ **Inputs:** `MultiSelect`, `Select`, `FilterPills`, `TextInput`, `DateInput`
212
+
213
+ **Overlay:** `Tooltip`
214
+
215
+ **Feedback:** `LoadingState`, `ErrorState`, `ResourceCacheBadge`
216
+
217
+ ## UI components
218
+
219
+ ### Layout
220
+
221
+ - **`AppShell`**: Page wrapper with title, subtitle, theme toggle, meta slot.
222
+ - **`Card`**: Container with optional `title`, `headerRight`, `noPadding`. Use for all content sections.
223
+ - **`TabGroup`**: Tab bar with accent underline. Props: `tabs` (Array\<string\>, NOT objects), `activeTab` (string), `onTabChange` (string => void), `children`. Example: `<TabGroup tabs={["Approved", "Funded"]} activeTab={tab} onTabChange={setTab}>`. Do NOT pass `{key, label}` objects; `tabs` must be a plain string array.
224
+
225
+ ### Data display
226
+
227
+ - **`KpiCard`**: Metric card with hover lift, accent top-line, shimmer loading. Props: `title`, `value`, `format` ("number" | "currency" | "percent"), `loading`, `detail` (ReactNode for comparison deltas, badges, etc. below the value). The `format` prop is **required**. Behavior:
228
+ - `format="number"`: formats with commas, no decimals (e.g., `15,212`)
229
+ - `format="currency"`: formats as USD (e.g., `$1,234`)
230
+ - `format="percent"`: appends "%" with 1 decimal (e.g., `value={82.2} format="percent"` renders `82.2%`)
231
+ - **Pre-formatted strings** (e.g., `"53.6s"`, `"82.2%"`) are passed through as-is when they can't be parsed as a number
232
+ - NaN/Infinity values display as a dash
233
+ - **`DataTable`**: Simple table with row hover. Props: `columns` (key/label), `rows`, `emptyState`.
234
+ - **`ReportTable`**: Rich management report table. Supports grouped column headers with colored bands, section dividers, subtotal/total rows, and per-cell conditional styling. Props: `headerGroups` (label, colSpan, color, subHeaders[]), `rows` (type: data/section/subtotal/total, indent, cells), `emptyState`.
235
+ - **`Badge`**: Status indicator with colored dot. Props: `variant` ("default" | "success" | "warning" | "error" | "info"), `dot`, `children`.
236
+ - **`EChart`**: Apache ECharts wrapper. Handles init/dispose lifecycle, theme switching, resize, and click events. Pass the full ECharts option spec. Props: `option`, `height`, `theme`, `onClick`. See "ECharts configuration" section below.
237
+ - **`PerspectivePanel`**: Wrapper for perspective-viewer. Props: `client`, `table`, `theme`, `config`, `onSelect`. See "Perspective chart configuration" section below.
238
+
239
+ ### Inputs
240
+
241
+ - **`MultiSelect`**: Searchable checkbox dropdown. Props: `options`, `selected`, `onChange`, `labelKey`, `valueKey`, `label`, `placeholder`.
242
+ - **`Select`**: Single-select dropdown. Props: `options` ({value, label}[]), `value`, `onChange`, `label`, `placeholder`.
243
+ - **`FilterPills`**: Single-select horizontal toggle group. Props: `options` ({value, label}[]), `value`, `onChange`, `label`.
244
+ - **`TextInput`**: Input with focus ring and optional icon. Props: `value`, `onChange`, `placeholder`, `label`, `icon`.
245
+ - **`DateInput`**: Native date picker with design system styling. Props: `value`, `onChange`, `label`, `max`, `min`.
246
+
247
+ ### Overlay
248
+
249
+ - **`Tooltip`**: Hover tooltip with arbitrary ReactNode content. Use for metric definitions, column explanations, filter descriptions. Props: `content` (ReactNode), `children` (trigger element), `position` ("top" | "bottom"), `maxWidth`.
250
+
251
+ ### Feedback
252
+
253
+ - **`LoadingState`**: Full-page pulsing dots. Props: `message`.
254
+ - **`ErrorState`**: Full-page error with red left bar. Props: `title`, `message`.
255
+ - **`ResourceCacheBadge`**: Cache metadata popover. Props: `rows`, `cache`, `onClearAndReload`.
256
+
257
+ ## Refined SaaS shell (multi-view apps)
258
+
259
+ A second track of primitives designed for apps with a sidebar and multiple views. Driven by a palette object instead of CSS vars, so customer brand accents flow through every chrome surface. Ships as the `refined` template.
260
+
261
+ Canonical structure:
262
+
263
+ ```tsx
264
+ import {
265
+ buildPalette, PaletteProvider, usePalette,
266
+ ShellLayout, Sidebar, SaasKpiCard, CachePopover,
267
+ DrillProvider, useDrill,
268
+ callFiFast, buildDrillPrompt,
269
+ useDataset, useSqlQuery, useTheme,
270
+ } from "@definite/runtime";
271
+
272
+ export default function App() {
273
+ const { theme, toggleTheme } = useTheme();
274
+ const palette = useMemo(() => buildPalette(theme, { accent: "#FF006E" }), [theme]);
275
+ const data = useDataset("main");
276
+ if (data.loading) return <LoadingState />;
277
+ if (data.error) return <ErrorState title="..." message={data.error} />;
278
+
279
+ return (
280
+ <PaletteProvider value={palette}>
281
+ <DrillProvider
282
+ aiChat={{
283
+ onAsk: (q, entity) => callFiFast({ prompt: buildDrillPrompt(q, entity) }),
284
+ }}
285
+ >
286
+ <InnerApp theme={theme} onThemeChange={(t) => t !== theme && toggleTheme()} dataset={data} />
287
+ </DrillProvider>
288
+ </PaletteProvider>
289
+ );
290
+ }
291
+ ```
292
+
293
+ ### Shell primitives
294
+
295
+ | Export | Role |
296
+ |--------|------|
297
+ | `buildPalette(theme, { accent? })` | Palette tokens with optional brand accent. |
298
+ | `PaletteProvider` / `usePalette()` | Context for descendants. |
299
+ | `ShellLayout` | Flex container with sidebar slot, breadcrumb, title, headerRight slot. Wraps children in `PaletteProvider`. |
300
+ | `Sidebar` | Logo, nav, `dateRangeSlot`, `filterGroups` + `filters` (renders `FilterAccordion`), theme toggle, footer. |
301
+ | `FilterAccordion` | Collapsible groups with per-group counts, global + per-group search, selected chips, swatch dots. |
302
+ | `SaasKpiCard` | Accent top-line + sparkline + delta pill + loading shimmer. |
303
+ | `CachePopover` | Click-to-inspect cache pill. |
304
+ | `Sparkline` / `SkeletonShimmer` / `Breadcrumb` | Tiny primitives. |
305
+
306
+ ### Drill drawer
307
+
308
+ Context-driven slide-over. Any descendant calls `useDrill().open(entity)`:
309
+
310
+ ```tsx
311
+ drill.open({
312
+ kind: "kpi" | "row" | "chart",
313
+ id: "total_outstanding",
314
+ title: "Total outstanding",
315
+ value: "$50.8M",
316
+ breadcrumb: "Overview",
317
+ stats: [["Active", "2,511"]],
318
+ breakdown: [{ label: "2026-04", value: 1_780_000 }],
319
+ sql: "SELECT SUM(balance) FROM loans WHERE ...",
320
+ narrative: "Total principal balance across active contracts.",
321
+ });
322
+ ```
323
+
324
+ Author drill content inline at the call site — don't try to compute it in a shared helper yet. The pattern is young; wait until we have 3+ apps before extracting.
325
+
326
+ ### AI follow-up chat
327
+
328
+ Pass `aiChat` to `DrillProvider` to render a chat input at the bottom of the drawer. `onAsk(userMessage, entity)` returns a string; throw to show an error message. The `callFiFast()` helper wraps `/v4/fi-fast`, extracting `response.content.parts[0].text`:
329
+
330
+ ```tsx
331
+ <DrillProvider
332
+ aiChat={{
333
+ onAsk: (q, entity) => callFiFast({
334
+ prompt: buildDrillPrompt(q, entity),
335
+ // endpoint defaults to "/v4/fi-fast"; authToken optional
336
+ }),
337
+ placeholder: "Ask a follow-up…",
338
+ }}
339
+ >
340
+ ```
341
+
342
+ `callFiFast` defaults to same-origin cookies; pass `authToken` for Bearer auth. It's safe to leave wired in preview builds — the fetch will just 401 and the error bubbles into the chat as a message.
343
+
344
+ ### When to use `ShellLayout` vs `AppShell`
345
+
346
+ | Use case | Component |
347
+ |----------|-----------|
348
+ | Multi-view analytics app (Loans, Risk, Originations...) | `ShellLayout` + `Sidebar` |
349
+ | Single-view dashboard tile inside a Definite Doc | `AppShell` |
350
+ | Embedded email-receipt-style full-width view | `AppShell` |
351
+
352
+ Both are supported; they co-exist.
353
+
354
+ ## Best practices
355
+
356
+ ### Default recommendation
357
+
358
+ - **Always use `type: "sql"` for dataset resources.** Write the SQL in app.json with camelCase aliases for all columns. This gives you full control over column names and avoids Cube title issues.
359
+ - Use `kind: "json"` resources for small lookup lists (installer names, dropdown options, config maps).
360
+ - Use `.duckdb` file resources when you need multiple tables, views, or semantic snapshots on the client.
361
+ - **Never use `type: "cube"` in data apps** unless you have a specific reason. Cube responses use long title-based column names that are painful to work with in client-side SQL.
362
+
363
+ ### Column naming pattern
364
+
365
+ Always alias columns to camelCase in app.json SQL. Then reference those exact aliases in `useSqlQuery`.
366
+
367
+ ```json
368
+ // app.json: alias everything to camelCase
369
+ "sql": "SELECT installer_account_name AS installerAccountName, fico::INTEGER AS fico, STRFTIME(application_date, '%Y-%m-%d') AS applicationDate, approval_cnt::INTEGER AS approvalCnt FROM LAKE.credit.projects WHERE ... LIMIT 500000"
370
+ ```
371
+
372
+ ```tsx
373
+ // App.tsx: useSqlQuery references the camelCase aliases, NOT warehouse column names
374
+ const result = useSqlQuery(data, data.tableRef
375
+ ? `SELECT installerAccountName, SUM(approvalCnt)::INTEGER AS approvals
376
+ FROM ${data.tableRef} WHERE applicationDate >= '2025-01-01' GROUP BY 1`
377
+ : "", []);
378
+ ```
379
+
380
+ The column names in `useSqlQuery` (`installerAccountName`, `approvalCnt`, `applicationDate`) must exactly match the `AS` aliases in the app.json SQL. If they don't match, you get a runtime "Binder Error: Referenced column not found".
381
+
382
+ ### Pre-computed flags pattern (for conditional aggregation)
383
+
384
+ When your dashboard needs conditional counts (e.g., "handled calls", "SLA calls"), **always** pre-compute the flags server-side:
385
+
386
+ ```json
387
+ // app.json: compute flags server-side where complex CASE WHEN works correctly
388
+ "sql": "SELECT ..., CASE WHEN Agent_Time > 0 AND Skill_No NOT IN ('123','456') AND Campaign NOT LIKE '%Outbound' THEN 1 ELSE 0 END AS isHandled, CASE WHEN Campaign LIKE '%Outbound' THEN 1 ELSE 0 END AS isOutbound FROM ..."
389
+ ```
390
+
391
+ ```tsx
392
+ // App.tsx: simple aggregation of pre-computed flags (always works in DuckDB WASM)
393
+ const kpis = useSqlQuery(data, data.tableRef ? `
394
+ SELECT
395
+ COUNT(*) AS totalCalls,
396
+ SUM(isHandled)::INTEGER AS handledCalls,
397
+ SUM(isOutbound)::INTEGER AS obAttempts,
398
+ ROUND(SUM(isHandled)::INTEGER * 100.0 / NULLIF(COUNT(*), 0), 1) AS handleRatePct
399
+ FROM ${data.tableRef}
400
+ WHERE startDate >= '${dateFrom}' AND startDate <= '${dateTo}'
401
+ ` : "", [dateFrom, dateTo]);
402
+
403
+ // Display in KpiCard
404
+ <KpiCard title="Handled Calls" value={handledCalls} format="number" />
405
+ <KpiCard title="Handle Rate" value={handleRatePct} format="percent" />
406
+ ```
407
+
408
+ Do NOT put compound CASE WHEN logic in `useSqlQuery`. It will silently return 0 in DuckDB WASM.
409
+
410
+ ### General guidance
411
+
412
+ - Keep client-side SQL in `useSqlQuery` as simple as possible: GROUP BY, SUM, COUNT, simple WHERE filters.
413
+ - Push all complex logic (joins, subqueries, CTEs, compound CASE WHEN, regex, LIKE patterns) into the app.json server-side SQL.
414
+ - Always cast SUM results to `::INTEGER` in client-side SQL.
415
+ - Convert dates to VARCHAR with `STRFTIME(col, '%Y-%m-%d')` in app.json SQL.
416
+ - Use the `AppShell` component as your top-level wrapper.
417
+ - Use `Card` for every content section.
418
+
419
+ ## DuckDB WASM limitations
420
+
421
+ DuckDB WASM 1.29.0 has known issues with complex expressions in client-side SQL. These work correctly on the server but silently produce wrong results (usually 0) in the browser.
422
+
423
+ ### Compound CASE WHEN returns 0
424
+
425
+ **This is the #1 source of silent data bugs.** Any `CASE WHEN` with compound boolean conditions (AND/OR chains, NOT IN with many values, LIKE patterns) may return 0 for all rows in DuckDB WASM, even though the same query returns correct results server-side.
426
+
427
+ **Bad (client-side SQL in App.tsx):**
428
+ ```sql
429
+ -- This silently returns 0 in DuckDB WASM
430
+ SELECT SUM(CASE WHEN agent_time > 0
431
+ AND skill_no NOT IN ('123','456','789',...)
432
+ AND campaign NOT LIKE '%Outbound'
433
+ THEN 1 ELSE 0 END) AS handled
434
+ FROM ${t} WHERE ${DATE_FILTER}
435
+ ```
436
+
437
+ **Good (pre-compute in app.json, aggregate client-side):**
438
+ ```json
439
+ // app.json: compute the flag server-side
440
+ "sql": "SELECT ..., CASE WHEN Agent_Time > 0 AND Skill_No NOT IN ('123','456','789',...) AND Campaign_Name NOT LIKE '%Outbound' THEN 1 ELSE 0 END AS isHandled FROM ..."
441
+ ```
442
+ ```tsx
443
+ // App.tsx: simple SUM of pre-computed flag
444
+ const kpis = useSqlQuery(data, `
445
+ SELECT SUM(isHandled)::INTEGER AS handled
446
+ FROM ${t} WHERE ${DATE_FILTER}
447
+ `, [dateFrom, dateTo]);
448
+ ```
449
+
450
+ ### General rule: keep client-side SQL simple
451
+
452
+ | Server-side (app.json SQL) | Client-side (useSqlQuery) |
453
+ |---|---|
454
+ | Complex joins, subqueries, CTEs | Simple GROUP BY + aggregation |
455
+ | CASE WHEN with compound booleans | SUM/COUNT of pre-computed columns |
456
+ | NOT IN, LIKE, regex filters | Simple WHERE on date/string equality |
457
+ | Type casts (BIGINT, DOUBLE) | `::INTEGER` on SUM results |
458
+ | Flag computation, banding logic | Date range filters |
459
+
460
+ If you find yourself writing a CASE WHEN with more than one condition in `useSqlQuery`, move it to app.json instead.
461
+
462
+ ## Perspective chart configuration
463
+
464
+ `PerspectivePanel` accepts a `config` object passed to `viewer.restore()`. The key properties:
465
+
466
+ ### Plugin types
467
+
468
+ | Plugin | Type | Best for |
469
+ |--------|------|----------|
470
+ | `"Datagrid"` | Table | Raw data, record views |
471
+ | `"Y Bar"` | Vertical bar | Category comparisons |
472
+ | `"X Bar"` | Horizontal bar | Ranked categories, long labels |
473
+ | `"Y Line"` | Line chart | Time series, trends |
474
+ | `"Y Area"` | Area chart | Stacked volumes over time |
475
+ | `"Y Scatter"` | Scatter plot | Correlations |
476
+ | `"Heatmap"` | Heatmap | Two-dimensional patterns |
477
+ | `"Treemap"` | Treemap | Hierarchical proportions |
478
+ | `"Sunburst"` | Sunburst | Hierarchical categories |
479
+
480
+ ### Config properties
481
+
482
+ ```tsx
483
+ <PerspectivePanel
484
+ client={perspective.client}
485
+ table={transactions.perspectiveTable}
486
+ theme={theme}
487
+ config={{
488
+ plugin: "Y Bar",
489
+ columns: ["amount"], // Measures to display
490
+ group_by: ["branchName"], // X-axis / grouping dimension
491
+ split_by: ["status"], // Optional: series breakdown
492
+ aggregates: { amount: "sum" }, // Aggregation per column
493
+ sort: [["amount", "desc"]], // Sort order
494
+ filter: [["status", "==", "Funded"]], // Filters
495
+ settings: false, // Hide settings panel
496
+ title: "Revenue by Branch", // Chart title
497
+ columns_config: { // Number formatting (Intl.NumberFormat)
498
+ amount: { number_format: { style: "currency", currency: "USD", maximumFractionDigits: 0 } },
499
+ },
500
+ }}
501
+ />
502
+ ```
503
+
504
+ ### Chart patterns
505
+
506
+ **Bar chart (category comparison):**
507
+ ```tsx
508
+ config={{ plugin: "Y Bar", columns: ["amount"], group_by: ["branchName"], aggregates: { amount: "sum" }, sort: [["amount", "desc"]] }}
509
+ ```
510
+
511
+ **Line/Area chart (time series):**
512
+ ```tsx
513
+ config={{ plugin: "Y Line", columns: ["transactionId"], group_by: ["transactionDate"], split_by: ["status"], aggregates: { transactionId: "count" } }}
514
+ ```
515
+
516
+ **Stacked area (volume over time by category):**
517
+ ```tsx
518
+ config={{ plugin: "Y Area", columns: ["amount"], group_by: ["transactionDate"], split_by: ["branchName"], aggregates: { amount: "sum" } }}
519
+ ```
520
+
521
+ ### Drill-down (click-to-filter)
522
+
523
+ Use `perspective-select` events on the viewer element. **Do NOT use `perspective-click`**; it only fires on Datagrid. For D3FC chart plugins (bar, line, area), use `perspective-select`.
524
+
525
+ Event payload:
526
+ ```typescript
527
+ e.detail = {
528
+ selected: true, // true on select, false on deselect
529
+ row: { ... }, // data for clicked element
530
+ column_names: ["col"] // column name strings
531
+ }
532
+ ```
533
+
534
+ **Gotcha:** Keys in `e.detail.row` may not match your column names. Always inspect `Object.keys(row)` first.
535
+
536
+ To wire drill-down in a React component, attach an event listener to the `perspective-viewer` ref inside a `PerspectivePanel`:
537
+
538
+ ```tsx
539
+ // In a custom wrapper around PerspectivePanel, after the viewer loads:
540
+ viewerRef.current.addEventListener('perspective-select', (e) => {
541
+ const row = e.detail?.row;
542
+ if (!row) return;
543
+ const value = row['branchName'] ?? row[Object.keys(row)[0]];
544
+ if (value == null) return;
545
+ // Toggle: click same value to clear filter
546
+ setFilter(prev => prev === value ? '' : String(value));
547
+ });
548
+ ```
549
+
550
+ For multi-chart drill-down (clicking a bar in one chart filters all others), maintain shared filter state in the parent component and pass Perspective `filter` arrays to each `PerspectivePanel` config.
551
+
552
+ ### Perspective tips
553
+
554
+ - Keep using the provided `PerspectivePanel` / `usePerspective()` wiring unless you have a concrete reason not to.
555
+ - For `.duckdb` resources, set `source.table` if the UI needs a default table name for Perspective or local SQL.
556
+ - If the UI uses tabs, call `viewer.notifyResize?.()` when a hidden Perspective view becomes visible.
557
+
558
+ ## ECharts configuration
559
+
560
+ Apache ECharts is loaded from CDN (`window.echarts`). The `EChart` component is a thin lifecycle wrapper; pass the full ECharts option spec as the `option` prop. Docs: https://echarts.apache.org/en/option.html
561
+
562
+ ### CRITICAL: No functions in EChart options
563
+
564
+ The `EChart` component serializes the option object through `JSON.stringify` / `JSON.parse` for change detection. **This strips all JavaScript functions.** Any `formatter`, `valueFormatter`, or callback function in your option will be silently removed and ECharts will use its defaults (or crash).
565
+
566
+ **Do this instead:**
567
+
568
+ | Instead of | Do this |
569
+ |------------|---------|
570
+ | `yAxis.axisLabel.formatter: (v) => "$" + v + "M"` | Scale data to millions in your query, use string template: `formatter: "${value}M"` |
571
+ | `tooltip.valueFormatter: (v) => ...` | Remove it; ECharts will format the raw value. Or pre-format data. |
572
+ | `series.label.formatter: (params) => ...` | Pre-compute label strings and embed them per data point: `data: values.map((v, i) => ({ value: v, label: { formatter: precomputedLabels[i] } }))` |
573
+ | `tooltip.formatter: (params) => ...` | Cannot use custom tooltip formatters. Use default tooltip or restructure data so defaults look good. |
574
+
575
+ **String templates that work:** ECharts template syntax like `"{value}%"` and `"{b}: {c}"` survives JSON serialization because they're plain strings, not functions. Use `${value}` to prepend a literal dollar sign (the `$` is literal, `{value}` is replaced by ECharts).
576
+
577
+ ### When to use EChart vs PerspectivePanel
578
+
579
+ | EChart | PerspectivePanel |
580
+ |--------|-----------------|
581
+ | Custom styled charts (conditional bar colors, combo bar+line, dashed forecast lines) | Interactive data grids with sort/filter/pivot |
582
+ | Chart.js-style management report charts | Exploratory data analysis |
583
+ | Small to medium datasets (pre-aggregated) | Large datasets (DuckDB-backed, client-side SQL) |
584
+ | Full control over every visual element | Quick setup with sensible defaults |
585
+
586
+ ### Bar chart
587
+
588
+ ```tsx
589
+ <EChart theme={theme} option={{
590
+ xAxis: { type: "category", data: ["Austin", "Denver", "Chicago"] },
591
+ yAxis: { type: "value" },
592
+ series: [{ type: "bar", data: [42000, 38000, 51000], itemStyle: { borderRadius: [4, 4, 0, 0] } }],
593
+ }} />
594
+ ```
595
+
596
+ ### Combo bar + line (actual vs forecast)
597
+
598
+ ```tsx
599
+ <EChart theme={theme} option={{
600
+ tooltip: { trigger: "axis" },
601
+ legend: { data: ["Actual", "Target"] },
602
+ xAxis: { type: "category", data: ["Jan", "Feb", "Mar", "Apr"] },
603
+ yAxis: { type: "value" },
604
+ series: [
605
+ { name: "Actual", type: "bar", data: [
606
+ { value: 42000, itemStyle: { color: "#22c55e" } },
607
+ { value: 28000, itemStyle: { color: "#f87171" } },
608
+ ]},
609
+ { name: "Target", type: "line", data: [35000, 35000, 40000, 40000],
610
+ lineStyle: { type: "dashed", color: "#eab308" },
611
+ itemStyle: { color: "#eab308" } },
612
+ ],
613
+ }} />
614
+ ```
615
+
616
+ ### Per-bar conditional coloring
617
+
618
+ Use `data` as an array of objects with `itemStyle`:
619
+ ```tsx
620
+ data: values.map(v => ({
621
+ value: v.amount,
622
+ itemStyle: { color: v.amount >= v.target ? "#22c55e" : "#f87171" },
623
+ }))
624
+ ```
625
+
626
+ ### Click events (drill-down)
627
+
628
+ ```tsx
629
+ <EChart
630
+ theme={theme}
631
+ onClick={(params) => {
632
+ const name = params.name; // category label
633
+ setFilter(prev => prev === name ? "" : name);
634
+ }}
635
+ option={...}
636
+ />
637
+ ```
638
+
639
+ ### Theme switching
640
+
641
+ The `EChart` component handles theme automatically. Pass `theme` from `useTheme()` and the chart re-initializes with the correct ECharts built-in theme ("dark" or "light") on toggle.
642
+
643
+ ## Quick reference: starter App.tsx pattern
644
+
645
+ ```tsx
646
+ import React from "react";
647
+ import {
648
+ useDataset,
649
+ useSqlQuery,
650
+ useTheme,
651
+ AppShell,
652
+ Card,
653
+ KpiCard,
654
+ LoadingState,
655
+ ErrorState,
656
+ } from "@definite/runtime";
657
+
658
+ export default function App() {
659
+ const theme = useTheme();
660
+ const data = useDataset("main");
661
+
662
+ const summary = useSqlQuery(
663
+ data,
664
+ data.tableRef
665
+ ? `SELECT COUNT(*)::INTEGER AS totalRows FROM ${data.tableRef}`
666
+ : "",
667
+ [],
668
+ );
669
+
670
+ if (data.loading) return <LoadingState message="Loading data..." />;
671
+ if (data.error) return <ErrorState title="Load Error" message={data.error} />;
672
+
673
+ return (
674
+ <AppShell title="My App" subtitle="Dashboard subtitle here">
675
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
676
+ <KpiCard
677
+ title="Total Rows"
678
+ value={summary.data?.[0]?.totalRows}
679
+ format="number"
680
+ loading={summary.loading}
681
+ />
682
+ </div>
683
+ </AppShell>
684
+ );
685
+ }
686
+ ```