@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
@@ -0,0 +1,527 @@
1
+ import React, { useDeferredValue, useMemo, useState } from "react";
2
+
3
+ import {
4
+ AppShell,
5
+ Badge,
6
+ Card,
7
+ DataTable,
8
+ DateRangeFilter,
9
+ DEFAULT_DATE_RANGE_PRESETS,
10
+ type DateRangeValue,
11
+ EChart,
12
+ ErrorState,
13
+ FilterPills,
14
+ IssueBanner,
15
+ KpiCard,
16
+ LoadingState,
17
+ MultiSelect,
18
+ PerspectivePanel,
19
+ ReportTable,
20
+ ResourceCacheBadge,
21
+ Select,
22
+ TabGroup,
23
+ TextInput,
24
+ Tooltip,
25
+ useDataset,
26
+ useJsonResource,
27
+ usePerspective,
28
+ useSqlQuery,
29
+ useTheme,
30
+ } from "@definite/runtime";
31
+
32
+ type BranchOption = {
33
+ branchId: string;
34
+ branchName: string;
35
+ };
36
+
37
+ type AppIssue = {
38
+ key: string;
39
+ title: string;
40
+ message: string;
41
+ severity: "warning";
42
+ };
43
+
44
+ const searchIcon = (
45
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
46
+ <circle cx="11" cy="11" r="8" />
47
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
48
+ </svg>
49
+ );
50
+
51
+ function escapeSql(value: string): string {
52
+ return value.replace(/'/g, "''");
53
+ }
54
+
55
+ export default function App() {
56
+ const { theme, toggleTheme } = useTheme();
57
+ const transactions = useDataset("transactions");
58
+ const branches = useJsonResource<BranchOption[]>("branches");
59
+ const [selectedBranches, setSelectedBranches] = useState<BranchOption[]>([]);
60
+ const [statusFilter, setStatusFilter] = useState("");
61
+ const [activeTab, setActiveTab] = useState("Dashboard");
62
+ const [search, setSearch] = useState("");
63
+ const [reportFilter, setReportFilter] = useState("All");
64
+ const [reportRange, setReportRange] = useState<DateRangeValue>(() =>
65
+ DEFAULT_DATE_RANGE_PRESETS.find((p) => p.key === "last90")!.compute(),
66
+ );
67
+ const deferredSearch = useDeferredValue(search);
68
+ const perspective = usePerspective(transactions);
69
+
70
+ // Build SQL WHERE clause from active filters
71
+ const whereClause = useMemo(() => {
72
+ const clauses: string[] = [];
73
+ if (selectedBranches.length > 0) {
74
+ const names = selectedBranches.map((b) => `'${escapeSql(b.branchName)}'`).join(", ");
75
+ clauses.push(`branchName IN (${names})`);
76
+ }
77
+ if (statusFilter) {
78
+ clauses.push(`status = '${escapeSql(statusFilter)}'`);
79
+ }
80
+ return clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
81
+ }, [selectedBranches, statusFilter]);
82
+
83
+ // Build Perspective filter array from active filters
84
+ const perspectiveFilters = useMemo(() => {
85
+ const filters: Array<[string, string, unknown]> = [];
86
+ if (selectedBranches.length === 1) {
87
+ filters.push(["branchName", "==", selectedBranches[0].branchName]);
88
+ } else if (selectedBranches.length > 1) {
89
+ filters.push(["branchName", "in", selectedBranches.map((b) => b.branchName)]);
90
+ }
91
+ if (statusFilter) {
92
+ filters.push(["status", "==", statusFilter]);
93
+ }
94
+ return filters;
95
+ }, [selectedBranches, statusFilter]);
96
+
97
+ // Filtered KPI metrics
98
+ const metrics = useSqlQuery(
99
+ transactions,
100
+ transactions.tableRef
101
+ ? `SELECT COUNT(*)::INTEGER AS rowCount, COALESCE(SUM(amount), 0)::DOUBLE AS totalAmount, COALESCE(AVG(amount), 0)::DOUBLE AS averageAmount FROM ${transactions.tableRef}${whereClause}`
102
+ : "",
103
+ [whereClause],
104
+ );
105
+
106
+ // Report tab: WHERE clause from date range + segment
107
+ const reportWhere = useMemo(() => {
108
+ const clauses: string[] = [];
109
+ if (reportRange.from) clauses.push(`transactionDate >= '${escapeSql(reportRange.from)}'`);
110
+ if (reportRange.to) clauses.push(`transactionDate <= '${escapeSql(reportRange.to)}'`);
111
+ if (reportFilter && reportFilter !== "All") clauses.push(`status = '${escapeSql(reportFilter)}'`);
112
+ return clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
113
+ }, [reportRange.from, reportRange.to, reportFilter]);
114
+
115
+ const reportAgg = useSqlQuery<Array<{ branchName: string; units: number; volume: number }>>(
116
+ transactions,
117
+ transactions.tableRef
118
+ ? `SELECT branchName, COUNT(*)::INTEGER AS units, COALESCE(SUM(amount), 0)::DOUBLE AS volume FROM ${transactions.tableRef}${reportWhere} GROUP BY branchName ORDER BY branchName`
119
+ : "",
120
+ [reportWhere],
121
+ );
122
+
123
+ // Filtered recent rows
124
+ const recentRows = useSqlQuery(
125
+ transactions,
126
+ transactions.tableRef
127
+ ? `SELECT transactionDate, branchName, status, amount FROM ${transactions.tableRef}${whereClause} ORDER BY transactionDate DESC LIMIT 20`
128
+ : "",
129
+ [whereClause],
130
+ );
131
+
132
+ const transactionRowCount = typeof metrics.data?.[0]?.rowCount === "number"
133
+ ? metrics.data[0].rowCount
134
+ : transactions.cache?.rowCount ?? null;
135
+ const appIssues: AppIssue[] = [
136
+ branches.error
137
+ ? { key: "branches", title: "Branch directory unavailable", message: branches.error, severity: "warning" as const }
138
+ : null,
139
+ metrics.error
140
+ ? { key: "metrics", title: "KPI metrics unavailable", message: metrics.error, severity: "warning" as const }
141
+ : null,
142
+ recentRows.error
143
+ ? { key: "recentRows", title: "Recent transactions unavailable", message: recentRows.error, severity: "warning" as const }
144
+ : null,
145
+ perspective.error
146
+ ? { key: "perspective", title: "Charts unavailable", message: perspective.error, severity: "warning" as const }
147
+ : null,
148
+ ].filter((issue): issue is AppIssue => issue !== null);
149
+
150
+ const filteredBranches = (branches.data ?? [])
151
+ .filter((branch) => {
152
+ if (!deferredSearch) return true;
153
+ return branch.branchName.toLowerCase().includes(deferredSearch.toLowerCase());
154
+ })
155
+ .slice(0, 25);
156
+
157
+ // Drill-down handlers: click a bar to toggle the corresponding filter
158
+ const handleBranchDrill = (row: Record<string, unknown> | null) => {
159
+ if (!row) return;
160
+ const value = String(row.branchName ?? row[Object.keys(row)[0]] ?? "");
161
+ if (!value) return;
162
+ // Toggle: if already the only selected branch, clear it
163
+ if (selectedBranches.length === 1 && selectedBranches[0].branchName === value) {
164
+ setSelectedBranches([]);
165
+ } else {
166
+ const match = (branches.data ?? []).find((b) => b.branchName === value);
167
+ if (match) setSelectedBranches([match]);
168
+ }
169
+ };
170
+
171
+ const handleStatusDrill = (row: Record<string, unknown> | null) => {
172
+ if (!row) return;
173
+ const value = String(row.status ?? row[Object.keys(row)[0]] ?? "");
174
+ if (!value) return;
175
+ setStatusFilter((prev) => (prev === value ? "" : value));
176
+ };
177
+
178
+ if (transactions.loading) {
179
+ return <LoadingState message="Loading DuckDB, dataset, and Perspective runtime..." />;
180
+ }
181
+
182
+ if (transactions.error) {
183
+ return <ErrorState title="Dataset failed to load" message={transactions.error} />;
184
+ }
185
+
186
+ const hasFilters = selectedBranches.length > 0 || statusFilter !== "";
187
+ const kpis: Array<{ title: string; value: unknown; format: "number" | "currency"; detail?: React.ReactNode }> = [
188
+ { title: "Transactions", value: metrics.data?.[0]?.rowCount ?? 0, format: "number" },
189
+ {
190
+ title: "Total Revenue",
191
+ value: metrics.data?.[0]?.totalAmount ?? 0,
192
+ format: "currency",
193
+ detail: (
194
+ <span className="text-xs" style={{ color: "var(--text-muted)" }}>
195
+ B: <span style={{ color: "#4ade80", fontWeight: 600 }}>+8.2%</span>
196
+ {" | "}
197
+ LY: <span style={{ color: "#f87171", fontWeight: 600 }}>-3.1%</span>
198
+ </span>
199
+ ),
200
+ },
201
+ { title: "Average Ticket", value: metrics.data?.[0]?.averageAmount ?? 0, format: "currency" },
202
+ ];
203
+
204
+ return (
205
+ <AppShell
206
+ title="Revenue Explorer"
207
+ subtitle={transactions.kind === "database" ? "Attached DuckDB model" : "Live dataset"}
208
+ theme={theme}
209
+ onToggleTheme={toggleTheme}
210
+ meta={<ResourceCacheBadge rows={transactionRowCount} cache={transactions.cache} onClearAndReload={transactions.refresh} />}
211
+ >
212
+ <TabGroup
213
+ tabs={["Dashboard", "Report", "Explorer", "Branches"]}
214
+ activeTab={activeTab}
215
+ onTabChange={setActiveTab}
216
+ />
217
+
218
+ {appIssues.length > 0 ? (
219
+ <div className="grid gap-3">
220
+ {appIssues.map((issue) => (
221
+ <IssueBanner
222
+ key={issue.key}
223
+ title={issue.title}
224
+ message={issue.message}
225
+ severity={issue.severity}
226
+ />
227
+ ))}
228
+ </div>
229
+ ) : null}
230
+
231
+ {activeTab === "Dashboard" ? (
232
+ <>
233
+ <div className="flex flex-wrap items-end gap-3">
234
+ <MultiSelect<BranchOption>
235
+ options={branches.data ?? []}
236
+ selected={selectedBranches}
237
+ onChange={setSelectedBranches}
238
+ labelKey="branchName"
239
+ valueKey="branchId"
240
+ label="Branch"
241
+ placeholder="All Branches"
242
+ searchPlaceholder="Search branches..."
243
+ />
244
+ <Select
245
+ options={[
246
+ { value: "Funded", label: "Funded" },
247
+ { value: "Pending", label: "Pending" },
248
+ { value: "Review", label: "Review" },
249
+ ]}
250
+ value={statusFilter}
251
+ onChange={setStatusFilter}
252
+ label={<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>Status<Tooltip content={<span>Transaction processing status.<br /><b>Funded</b>: completed, <b>Pending</b>: in progress, <b>Review</b>: awaiting approval.</span>}><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5, cursor: "help" }}><circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" /></svg></Tooltip></span>}
253
+ placeholder="All Statuses"
254
+ />
255
+ {hasFilters ? (
256
+ <button
257
+ type="button"
258
+ className="rounded-lg px-3 py-2 text-xs font-medium"
259
+ style={{
260
+ background: "none",
261
+ border: "1px solid var(--border)",
262
+ color: "var(--text-muted)",
263
+ cursor: "pointer",
264
+ transition: "color 120ms ease, border-color 120ms ease",
265
+ }}
266
+ onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text-primary)"; e.currentTarget.style.borderColor = "var(--border-hover)"; }}
267
+ onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-muted)"; e.currentTarget.style.borderColor = "var(--border)"; }}
268
+ onClick={() => { setSelectedBranches([]); setStatusFilter(""); }}
269
+ >
270
+ Clear filters
271
+ </button>
272
+ ) : null}
273
+ </div>
274
+
275
+ <div className="grid gap-4 md:grid-cols-3">
276
+ {kpis.map((kpi, i) => (
277
+ <div key={kpi.title} style={{ animation: `fade-up 0.4s ease-out ${i * 0.08}s both` }}>
278
+ <KpiCard
279
+ title={kpi.title}
280
+ value={kpi.value}
281
+ format={kpi.format}
282
+ loading={metrics.loading}
283
+ detail={kpi.detail}
284
+ />
285
+ </div>
286
+ ))}
287
+ </div>
288
+
289
+ <div className="grid gap-6 xl:grid-cols-2">
290
+ <Card title="Revenue by Branch" headerRight={selectedBranches.length > 0 ? <Badge variant="info">{selectedBranches.length} filtered</Badge> : null} noPadding>
291
+ <div className="h-[320px]">
292
+ <PerspectivePanel
293
+ client={perspective.client}
294
+ table={transactions.perspectiveTable}
295
+ resourceKey={transactions.key}
296
+ loading={perspective.loading}
297
+ error={perspective.error}
298
+ theme={theme}
299
+ onSelect={handleBranchDrill}
300
+ config={{
301
+ plugin: "X Bar",
302
+ columns: ["amount"],
303
+ group_by: ["branchName"],
304
+ aggregates: { amount: "sum" },
305
+ sort: [["amount", "desc"]],
306
+ settings: false,
307
+ filter: statusFilter ? [["status", "==", statusFilter]] : [],
308
+ columns_config: {
309
+ amount: { number_format: { style: "currency", currency: "USD", maximumFractionDigits: 0 } },
310
+ },
311
+ }}
312
+ />
313
+ </div>
314
+ </Card>
315
+ <Card title="Transactions by Status" headerRight={statusFilter ? <Badge variant="info">{statusFilter}</Badge> : null} noPadding>
316
+ <div className="h-[320px]">
317
+ <PerspectivePanel
318
+ client={perspective.client}
319
+ table={transactions.perspectiveTable}
320
+ resourceKey={transactions.key}
321
+ loading={perspective.loading}
322
+ error={perspective.error}
323
+ theme={theme}
324
+ onSelect={handleStatusDrill}
325
+ config={{
326
+ plugin: "Y Bar",
327
+ columns: ["transactionId"],
328
+ group_by: ["status"],
329
+ aggregates: { transactionId: "count" },
330
+ sort: [["transactionId", "desc"]],
331
+ settings: false,
332
+ filter: selectedBranches.length === 1
333
+ ? [["branchName", "==", selectedBranches[0].branchName]]
334
+ : selectedBranches.length > 1
335
+ ? [["branchName", "in", selectedBranches.map((b) => b.branchName)]]
336
+ : [],
337
+ }}
338
+ />
339
+ </div>
340
+ </Card>
341
+ </div>
342
+
343
+ <Card title="Recent Transactions" headerRight={hasFilters ? <Badge variant="info">Filtered</Badge> : null}>
344
+ <DataTable
345
+ columns={[
346
+ { key: "transactionDate", label: "Date" },
347
+ { key: "branchName", label: "Branch" },
348
+ { key: "status", label: "Status" },
349
+ { key: "amount", label: "Amount" },
350
+ ]}
351
+ rows={(recentRows.data as Array<Record<string, unknown>>) ?? []}
352
+ emptyState={recentRows.loading ? "Loading transactions..." : "No transactions match the current filters."}
353
+ />
354
+ </Card>
355
+ </>
356
+ ) : null}
357
+
358
+ {activeTab === "Report" ? (
359
+ <>
360
+ <div className="flex flex-wrap items-end gap-4">
361
+ <FilterPills
362
+ label="Segment"
363
+ options={[
364
+ { value: "All", label: "All" },
365
+ { value: "Funded", label: "Funded" },
366
+ { value: "Pending", label: "Pending" },
367
+ { value: "Review", label: "Review" },
368
+ ]}
369
+ value={reportFilter}
370
+ onChange={setReportFilter}
371
+ />
372
+ <DateRangeFilter value={reportRange} onChange={setReportRange} />
373
+ </div>
374
+
375
+ <Card>
376
+ <ReportTable
377
+ headerGroups={[
378
+ {
379
+ label: "Category",
380
+ subHeaders: [{ key: "category", label: "Category", align: "left" }],
381
+ color: "var(--bg-elevated)",
382
+ },
383
+ {
384
+ label: reportRange.label || "Range",
385
+ color: "#92400e",
386
+ subHeaders: [
387
+ { key: "units", label: "Units" },
388
+ { key: "volume", label: "Volume" },
389
+ ],
390
+ },
391
+ ]}
392
+ rows={(() => {
393
+ const data = reportAgg.data ?? [];
394
+ const fmtMoney = (v: number) => `$${Math.round(v).toLocaleString()}`;
395
+ const branchRows = data.map((r) => ({
396
+ type: "data" as const,
397
+ indent: true,
398
+ cells: {
399
+ category: r.branchName,
400
+ units: String(r.units),
401
+ volume: fmtMoney(r.volume),
402
+ },
403
+ }));
404
+ const totalUnits = data.reduce((s, r) => s + Number(r.units || 0), 0);
405
+ const totalVolume = data.reduce((s, r) => s + Number(r.volume || 0), 0);
406
+ if (branchRows.length === 0) {
407
+ return [
408
+ { type: "section" as const, cells: { category: "Branches" } },
409
+ {
410
+ type: "data" as const,
411
+ indent: true,
412
+ cells: {
413
+ category: reportAgg.loading ? "Loading..." : "No transactions in range",
414
+ units: "—",
415
+ volume: "—",
416
+ },
417
+ },
418
+ ];
419
+ }
420
+ return [
421
+ { type: "section" as const, cells: { category: "Branches" } },
422
+ ...branchRows,
423
+ {
424
+ type: "total" as const,
425
+ cells: {
426
+ category: "GRAND TOTAL",
427
+ units: String(totalUnits),
428
+ volume: fmtMoney(totalVolume),
429
+ },
430
+ },
431
+ ];
432
+ })()}
433
+ />
434
+ </Card>
435
+
436
+ <Card title="Monthly Volume vs Target" noPadding>
437
+ <div className="p-4">
438
+ <EChart
439
+ theme={theme}
440
+ height={340}
441
+ option={{
442
+ tooltip: { trigger: "axis" },
443
+ legend: { data: ["Actual", "Target"], top: 0, textStyle: { fontSize: 11 } },
444
+ grid: { left: 60, right: 20, bottom: 30, top: 40 },
445
+ xAxis: {
446
+ type: "category",
447
+ data: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
448
+ },
449
+ yAxis: {
450
+ type: "value",
451
+ axisLabel: { formatter: (v: number) => v >= 1000 ? `$${v / 1000}K` : `$${v}` },
452
+ },
453
+ series: [
454
+ {
455
+ name: "Actual",
456
+ type: "bar",
457
+ data: [
458
+ { value: 42000, itemStyle: { color: "#22c55e" } },
459
+ { value: 38000, itemStyle: { color: "#22c55e" } },
460
+ { value: 28000, itemStyle: { color: "#f87171" } },
461
+ { value: 0, itemStyle: { color: "#71717a" } },
462
+ { value: 0, itemStyle: { color: "#71717a" } },
463
+ { value: 0, itemStyle: { color: "#71717a" } },
464
+ ],
465
+ barMaxWidth: 40,
466
+ itemStyle: { borderRadius: [4, 4, 0, 0] },
467
+ },
468
+ {
469
+ name: "Target",
470
+ type: "line",
471
+ data: [35000, 35000, 40000, 40000, 45000, 45000],
472
+ lineStyle: { type: "dashed", width: 2, color: "#eab308" },
473
+ itemStyle: { color: "#eab308" },
474
+ symbol: "circle",
475
+ symbolSize: 6,
476
+ },
477
+ ],
478
+ }}
479
+ />
480
+ </div>
481
+ </Card>
482
+ </>
483
+ ) : null}
484
+
485
+ {activeTab === "Explorer" ? (
486
+ <Card title="Perspective Explorer" noPadding>
487
+ <div className="h-[600px]">
488
+ <PerspectivePanel
489
+ client={perspective.client}
490
+ table={transactions.perspectiveTable}
491
+ resourceKey={transactions.key}
492
+ loading={perspective.loading}
493
+ error={perspective.error}
494
+ theme={theme}
495
+ config={{
496
+ plugin: "Datagrid",
497
+ columns: ["transactionDate", "branchName", "status", "amount"],
498
+ sort: [["transactionDate", "desc"]],
499
+ settings: false,
500
+ }}
501
+ />
502
+ </div>
503
+ </Card>
504
+ ) : null}
505
+
506
+ {activeTab === "Branches" ? (
507
+ <Card title="Branch Directory">
508
+ <TextInput
509
+ value={search}
510
+ onChange={setSearch}
511
+ placeholder="Search branches..."
512
+ icon={searchIcon}
513
+ className="mb-4"
514
+ />
515
+ <DataTable
516
+ columns={[
517
+ { key: "branchId", label: "Branch ID" },
518
+ { key: "branchName", label: "Branch Name" },
519
+ ]}
520
+ rows={filteredBranches}
521
+ emptyState={branches.loading ? "Loading branches..." : "No branches matched."}
522
+ />
523
+ </Card>
524
+ ) : null}
525
+ </AppShell>
526
+ );
527
+ }
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+
4
+ import App from "./App";
5
+
6
+ const rootElement = document.getElementById("root");
7
+
8
+ if (!rootElement) {
9
+ throw new Error("Missing #root mount element");
10
+ }
11
+
12
+ createRoot(rootElement).render(<App />);
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@definite-app/data-apps",
3
+ "version": "1.0.0",
4
+ "description": "Build source-authored React data apps that compile to a single HTML file with DuckDB WASM and Perspective.js.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://github.com/definite-app/definite-data-apps#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/definite-app/definite-data-apps.git"
11
+ },
12
+ "bin": {
13
+ "definite-build": "./build.mjs",
14
+ "definite-preview": "./preview.mjs"
15
+ },
16
+ "files": [
17
+ "build.mjs",
18
+ "preview.mjs",
19
+ "runtime/",
20
+ "templates/",
21
+ "examples/_refined_demo/",
22
+ "examples/loan-portfolio/",
23
+ "examples/revenue-explorer/",
24
+ "starter",
25
+ "scripts/",
26
+ "CLAUDE.md",
27
+ "README.md"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "build": "node build.mjs",
34
+ "preview": "node preview.mjs",
35
+ "clean": "find examples templates -type d -name dist -exec rm -rf {} + 2>/dev/null || true",
36
+ "prepack": "npm run clean"
37
+ },
38
+ "keywords": [
39
+ "data-apps",
40
+ "duckdb",
41
+ "duckdb-wasm",
42
+ "perspective",
43
+ "react",
44
+ "definite"
45
+ ],
46
+ "dependencies": {
47
+ "esbuild": "0.25.3",
48
+ "react": "19.1.0",
49
+ "react-dom": "19.1.0",
50
+ "source-map-js": "^1.2.1"
51
+ },
52
+ "devDependencies": {
53
+ "playwright": "^1.48.0"
54
+ }
55
+ }
package/preview.mjs ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawn } from "node:child_process";
5
+
6
+ const repoRoot = path.dirname(fileURLToPath(import.meta.url));
7
+ const buildScript = path.join(repoRoot, "build.mjs");
8
+
9
+ const appName = process.argv[2];
10
+ if (!appName) {
11
+ console.error("Usage: node preview.mjs <app-name>");
12
+ console.error(" e.g. node preview.mjs revenue-explorer");
13
+ process.exit(1);
14
+ }
15
+
16
+ // Look for the app in examples/ first, then current directory
17
+ const appDir = path.resolve(
18
+ path.join(repoRoot, "examples", appName, "app.json")
19
+ ? path.join(repoRoot, "examples", appName)
20
+ : appName
21
+ );
22
+ const previewDataPath = path.join(appDir, "preview-data.json");
23
+
24
+ const child = spawn(
25
+ process.execPath,
26
+ [buildScript, appDir, "--preview-data", previewDataPath],
27
+ { stdio: "inherit" },
28
+ );
29
+
30
+ child.on("exit", (code) => {
31
+ if (code !== 0) {
32
+ process.exit(code ?? 1);
33
+ }
34
+ console.log(`Preview built at ${path.join(appDir, "dist", "index.html")}`);
35
+ });