@hachej/boring-bi-dashboard 0.1.60
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.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/front/index.d.ts +28 -0
- package/dist/front/index.js +831 -0
- package/dist/shared/index.d.ts +16 -0
- package/dist/shared/index.js +131 -0
- package/dist/types-BGZKL9Rs.d.ts +146 -0
- package/docs/issues/bi-dashboard-plugin/README.md +349 -0
- package/docs/issues/bi-dashboard-plugin/data-access-unification.md +638 -0
- package/docs/issues/bi-dashboard-plugin/data-bridge.md +425 -0
- package/example/.gitignore +16 -0
- package/example/.pi/extensions/bi-dashboard/front/index.ts +1 -0
- package/example/.pi/extensions/bi-dashboard/package.json +11 -0
- package/example/README.md +10 -0
- package/example/dashboards/people.dashboard.json +178 -0
- package/example/data/people.csv +13 -0
- package/example/eval/bi-dashboard.yaml +31 -0
- package/package.json +85 -0
- package/playground/README.md +25 -0
- package/playground/run-eval.ts +100 -0
- package/playground/smoke-dashboard.ts +101 -0
- package/skills/bi-dashboard-authoring/SKILL.md +78 -0
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hachej/boring-bi-dashboard",
|
|
3
|
+
"version": "0.1.60",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"description": "BI dashboard plugin primitives for Boring workspace: BSL-backed dashboard specs, charts, metrics, filters, and Perspective panels.",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/hachej/boring-ui"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/hachej/boring-ui",
|
|
13
|
+
"boring": {
|
|
14
|
+
"id": "bi-dashboard",
|
|
15
|
+
"label": "BI Dashboard",
|
|
16
|
+
"front": "dist/front/index.js",
|
|
17
|
+
"server": false
|
|
18
|
+
},
|
|
19
|
+
"pi": {
|
|
20
|
+
"skills": [
|
|
21
|
+
"skills/bi-dashboard-authoring"
|
|
22
|
+
],
|
|
23
|
+
"systemPrompt": "Use the bi-dashboard-authoring skill when the user asks to create, edit, or open a BI dashboard. Dashboard files should live under dashboards/*.dashboard.json."
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"skills",
|
|
28
|
+
"example",
|
|
29
|
+
"playground",
|
|
30
|
+
"docs",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/front/index.d.ts",
|
|
36
|
+
"import": "./dist/front/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./front": {
|
|
39
|
+
"types": "./dist/front/index.d.ts",
|
|
40
|
+
"import": "./dist/front/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./shared": {
|
|
43
|
+
"types": "./dist/shared/index.d.ts",
|
|
44
|
+
"import": "./dist/shared/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./package.json": "./package.json"
|
|
47
|
+
},
|
|
48
|
+
"sideEffects": false,
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
51
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
52
|
+
"@hachej/boring-workspace": "0.1.60"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"lucide-react": "^1.8.0",
|
|
56
|
+
"zod": "^4.3.6",
|
|
57
|
+
"@hachej/boring-generated-pane": "0.1.60",
|
|
58
|
+
"@hachej/boring-ui-kit": "0.1.60"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@duckdb/node-api": "1.5.2-r.1",
|
|
62
|
+
"@json-render/core": "^0.19.0",
|
|
63
|
+
"@json-render/react": "^0.19.0",
|
|
64
|
+
"@types/react": "^19.0.0",
|
|
65
|
+
"@types/react-dom": "^19.0.0",
|
|
66
|
+
"react": "^19.0.0",
|
|
67
|
+
"react-dom": "^19.0.0",
|
|
68
|
+
"tsup": "^8.4.0",
|
|
69
|
+
"tsx": "^4.21.0",
|
|
70
|
+
"typescript": "~5.9.3",
|
|
71
|
+
"vitest": "^3.2.6",
|
|
72
|
+
"@hachej/boring-agent": "0.1.60",
|
|
73
|
+
"@hachej/boring-data-bridge": "0.1.60",
|
|
74
|
+
"@hachej/boring-workspace": "0.1.60"
|
|
75
|
+
},
|
|
76
|
+
"scripts": {
|
|
77
|
+
"build": "tsup",
|
|
78
|
+
"typecheck": "tsc --noEmit",
|
|
79
|
+
"test": "vitest run --passWithNoTests",
|
|
80
|
+
"playground:eval": "tsx playground/run-eval.ts",
|
|
81
|
+
"playground:smoke": "tsx playground/smoke-dashboard.ts",
|
|
82
|
+
"lint": "pnpm run typecheck",
|
|
83
|
+
"clean": "rm -rf dist .tsbuildinfo"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# BI dashboard playground
|
|
2
|
+
|
|
3
|
+
This folder contains plugin-local playground helpers. The repo-level `workspace-playground` stays generic; BI dashboard is loaded explicitly when needed.
|
|
4
|
+
|
|
5
|
+
## Browser playground
|
|
6
|
+
|
|
7
|
+
Run the plugin in the existing workspace playground using the fixture in `../example`. The demo workspace includes `.pi/extensions/bi-dashboard`, which loads the front plugin as a workspace-local extension. For live query data, use the eval runner or another host that loads `@hachej/boring-data-bridge` as a trusted server plugin:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm --filter @hachej/boring-data-bridge build
|
|
11
|
+
pnpm --filter @hachej/boring-bi-dashboard build
|
|
12
|
+
BORING_EXTERNAL_PLUGINS=1 \
|
|
13
|
+
BORING_AGENT_WORKSPACE_ROOT="$PWD/plugins/bi-dashboard/example" \
|
|
14
|
+
pnpm --filter workspace-playground dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Eval playground
|
|
18
|
+
|
|
19
|
+
Run the dashboard authoring eval through the plugin-local runner:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm --filter @hachej/boring-bi-dashboard playground:eval
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The runner seeds a temp workspace from `../example` and loads `@hachej/boring-data-bridge` plus `@hachej/boring-bi-dashboard` explicitly.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env -S tsx
|
|
2
|
+
import { cpSync, mkdtempSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { dirname, join, resolve } from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
import { runEvalSuite, type SuiteReport } from "@hachej/boring-agent/eval"
|
|
7
|
+
import { createWorkspaceAgentServer } from "@hachej/boring-workspace/app/server"
|
|
8
|
+
import { parseDashboardSpec } from "../src/shared/validation"
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
11
|
+
const __dirname = dirname(__filename)
|
|
12
|
+
const PLUGIN_ROOT = resolve(__dirname, "..")
|
|
13
|
+
const EXAMPLE_ROOT = resolve(PLUGIN_ROOT, "example")
|
|
14
|
+
const DEFAULT_EVAL = resolve(EXAMPLE_ROOT, "eval/bi-dashboard.yaml")
|
|
15
|
+
|
|
16
|
+
function seedWorkspace(): string {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), "bi-dashboard-playground-"))
|
|
18
|
+
cpSync(EXAMPLE_ROOT, root, { recursive: true })
|
|
19
|
+
return root
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function main(): Promise<number> {
|
|
23
|
+
const fixturesPath = resolve(process.argv[2] ?? DEFAULT_EVAL)
|
|
24
|
+
const workspaceRoot = seedWorkspace()
|
|
25
|
+
console.log(`[bi-dashboard playground] running suite: ${fixturesPath}`)
|
|
26
|
+
console.log(`[bi-dashboard playground] seeded workspace: ${workspaceRoot}`)
|
|
27
|
+
|
|
28
|
+
const app = await createWorkspaceAgentServer({
|
|
29
|
+
workspaceRoot,
|
|
30
|
+
appRoot: PLUGIN_ROOT,
|
|
31
|
+
mode: "local",
|
|
32
|
+
logger: false,
|
|
33
|
+
defaultPluginPackages: ["@hachej/boring-data-bridge", "@hachej/boring-bi-dashboard"],
|
|
34
|
+
workspaceBridge: { allowInsecureLocalCliBrowserAuth: true },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const report = await runEvalSuite({ app, fixturesPath, concurrency: 1 })
|
|
39
|
+
console.log(
|
|
40
|
+
`[bi-dashboard playground] ${report.passed}/${report.total} passed (${(report.passRate * 100).toFixed(1)}%) in ${(report.totalDurationMs / 1000).toFixed(1)}s`,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const validationErrors = validateWrittenDashboards(report)
|
|
44
|
+
if (validationErrors.length > 0) {
|
|
45
|
+
console.error("\n[bi-dashboard playground] generated dashboard validation failed:")
|
|
46
|
+
for (const error of validationErrors) console.error(` - ${error}`)
|
|
47
|
+
return 1
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!report.allPassed) {
|
|
51
|
+
console.error(`\n[bi-dashboard playground] ${report.failed} prompt(s) failed:`)
|
|
52
|
+
for (const r of report.results) {
|
|
53
|
+
if (r.ok) continue
|
|
54
|
+
console.error(`\n prompt: ${r.prompt}`)
|
|
55
|
+
console.error(` reason: ${r.reason ?? "(no reason)"}`)
|
|
56
|
+
if (r.actual.length > 0) {
|
|
57
|
+
console.error(` actual calls: ${JSON.stringify(r.actual, null, 2).replace(/\n/g, "\n ")}`)
|
|
58
|
+
}
|
|
59
|
+
if (r.text) {
|
|
60
|
+
console.error(` text: ${r.text.slice(0, 200)}${r.text.length > 200 ? "…" : ""}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return 1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return 0
|
|
67
|
+
} finally {
|
|
68
|
+
await app.close()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateWrittenDashboards(report: SuiteReport): string[] {
|
|
73
|
+
const errors: string[] = []
|
|
74
|
+
for (const result of report.results) {
|
|
75
|
+
for (const call of result.actual) {
|
|
76
|
+
if (call.tool !== "write") continue
|
|
77
|
+
const path = typeof call.params.path === "string" ? call.params.path : ""
|
|
78
|
+
if (!/\.dashboard\.json$/i.test(path)) continue
|
|
79
|
+
if (typeof call.params.content !== "string") {
|
|
80
|
+
errors.push(`${path || "dashboard write"}: content is not a string`)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const parsed = parseDashboardSpec(JSON.parse(call.params.content))
|
|
85
|
+
if (!parsed.spec) errors.push(`${path}: ${parsed.errors.join("; ")}`)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
errors.push(`${path}: invalid JSON (${error instanceof Error ? error.message : String(error)})`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return errors
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main().then(
|
|
95
|
+
(code) => process.exit(code),
|
|
96
|
+
(err) => {
|
|
97
|
+
console.error("[bi-dashboard playground] fatal:", err)
|
|
98
|
+
process.exit(2)
|
|
99
|
+
},
|
|
100
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env -S tsx
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { dirname, resolve } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { DuckDBConnection, quotedIdentifier, quotedString } from "@duckdb/node-api"
|
|
6
|
+
import { createWorkspaceBridgeRegistry } from "@hachej/boring-workspace/server"
|
|
7
|
+
import { createDataBridgeServerPlugin, type DataBridgeSqlAdapter } from "@hachej/boring-data-bridge/server"
|
|
8
|
+
import type { DataBridgeTableResult } from "@hachej/boring-data-bridge/shared"
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
11
|
+
const __dirname = dirname(__filename)
|
|
12
|
+
const PLUGIN_ROOT = resolve(__dirname, "..")
|
|
13
|
+
const EXAMPLE_ROOT = resolve(PLUGIN_ROOT, "example")
|
|
14
|
+
const DASHBOARD_PATH = resolve(EXAMPLE_ROOT, "dashboards/people.dashboard.json")
|
|
15
|
+
|
|
16
|
+
async function createPeopleDuckDbAdapter(): Promise<{ adapter: DataBridgeSqlAdapter; close: () => void }> {
|
|
17
|
+
const connection = await DuckDBConnection.create()
|
|
18
|
+
await connection.run(
|
|
19
|
+
`create or replace view ${quotedIdentifier("people")} as select * from read_csv_auto(${quotedString(resolve(EXAMPLE_ROOT, "data/people.csv"))}, HEADER = true)`,
|
|
20
|
+
)
|
|
21
|
+
return {
|
|
22
|
+
adapter: {
|
|
23
|
+
maxRows: 100,
|
|
24
|
+
async execute({ sql, limit }) {
|
|
25
|
+
const reader = await connection.runAndReadUntil(sql, limit + 1)
|
|
26
|
+
const rows = reader.getRowObjectsJson().slice(0, limit) as Record<string, unknown>[]
|
|
27
|
+
return {
|
|
28
|
+
kind: "data-bridge.table",
|
|
29
|
+
version: 1,
|
|
30
|
+
columns: reader.columnNames().map((name) => ({ name, type: "string" })),
|
|
31
|
+
rows,
|
|
32
|
+
rowCount: rows.length,
|
|
33
|
+
truncated: !reader.done || reader.currentRowCount > limit,
|
|
34
|
+
source: "people-duckdb",
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
close: () => connection.closeSync(),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main(): Promise<number> {
|
|
43
|
+
const dashboard = JSON.parse(readFileSync(DASHBOARD_PATH, "utf8")) as {
|
|
44
|
+
profile?: unknown
|
|
45
|
+
queries?: Record<string, { id: string; source?: string; sql?: string; params?: Record<string, unknown>; model?: string; query?: string; limit?: number }>
|
|
46
|
+
}
|
|
47
|
+
if (dashboard.profile !== "bi-dashboard" || !dashboard.queries) {
|
|
48
|
+
console.error("[bi-dashboard smoke] invalid dashboard fixture")
|
|
49
|
+
return 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { adapter, close } = await createPeopleDuckDbAdapter()
|
|
53
|
+
try {
|
|
54
|
+
const plugin = createDataBridgeServerPlugin({
|
|
55
|
+
workspaceRoot: EXAMPLE_ROOT,
|
|
56
|
+
sqlAdapters: { "people-duckdb": adapter },
|
|
57
|
+
})
|
|
58
|
+
const registry = createWorkspaceBridgeRegistry()
|
|
59
|
+
for (const contribution of plugin.workspaceBridgeHandlers ?? []) {
|
|
60
|
+
registry.registerHandler(contribution.definition, contribution.handler)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const [queryId, query] of Object.entries(dashboard.queries)) {
|
|
64
|
+
const bridgeQuery = query.sql
|
|
65
|
+
? { language: "sql" as const, source: query.source ?? "default", sql: query.sql, params: query.params, limit: query.limit }
|
|
66
|
+
: { language: "bsl" as const, model: query.model ?? "", query: query.query ?? "", limit: query.limit }
|
|
67
|
+
|
|
68
|
+
const res = await registry.call({
|
|
69
|
+
op: "data.v1.query.run",
|
|
70
|
+
input: { query: bridgeQuery },
|
|
71
|
+
}, {
|
|
72
|
+
callerClass: "runtime",
|
|
73
|
+
workspaceId: "bi-dashboard-smoke",
|
|
74
|
+
capabilities: ["data:read", "data:sql-query"],
|
|
75
|
+
actor: { actorKind: "agent", performedBy: { label: "bi-dashboard smoke" } },
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
console.error(`[bi-dashboard smoke] ${queryId} failed: ${res.error.message}`)
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
const output = res.output as DataBridgeTableResult
|
|
83
|
+
if (!Array.isArray(output.rows) || output.rows.length === 0) {
|
|
84
|
+
console.error(`[bi-dashboard smoke] ${queryId} returned no rows`)
|
|
85
|
+
return 1
|
|
86
|
+
}
|
|
87
|
+
console.log(`[bi-dashboard smoke] ${queryId}: ${output.rows.length} row(s)`)
|
|
88
|
+
}
|
|
89
|
+
return 0
|
|
90
|
+
} finally {
|
|
91
|
+
close()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main().then(
|
|
96
|
+
(code) => process.exit(code),
|
|
97
|
+
(err) => {
|
|
98
|
+
console.error("[bi-dashboard smoke] fatal:", err)
|
|
99
|
+
process.exit(2)
|
|
100
|
+
},
|
|
101
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Create or edit BSL BI dashboard JSON specs for the BI Dashboard plugin.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# bi-dashboard-authoring
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to create, edit, or open a BI dashboard for
|
|
8
|
+
`@hachej/boring-bi-dashboard`.
|
|
9
|
+
|
|
10
|
+
## Files
|
|
11
|
+
|
|
12
|
+
- write dashboard specs as JSON files under `dashboards/`
|
|
13
|
+
- use the suffix `.dashboard.json`
|
|
14
|
+
- when the user asks to open or view the dashboard and `exec_ui` is available,
|
|
15
|
+
open the file with:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"kind": "openSurface",
|
|
20
|
+
"params": {
|
|
21
|
+
"kind": "workspace.open.path",
|
|
22
|
+
"target": "dashboards/example.dashboard.json"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Contract
|
|
28
|
+
|
|
29
|
+
Write provider-neutral BSL dashboard specs. Do not write React. Do not write raw
|
|
30
|
+
ECharts or raw Perspective configs.
|
|
31
|
+
|
|
32
|
+
Use this top-level shape:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"kind": "boring.generated-pane",
|
|
37
|
+
"profile": "bi-dashboard",
|
|
38
|
+
"version": 1,
|
|
39
|
+
"title": "Dashboard title",
|
|
40
|
+
"queries": {
|
|
41
|
+
"query_id": {
|
|
42
|
+
"id": "query_id",
|
|
43
|
+
"model": "orders",
|
|
44
|
+
"groupBy": [],
|
|
45
|
+
"measures": []
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"root": "dashboard",
|
|
49
|
+
"elements": {
|
|
50
|
+
"dashboard": {
|
|
51
|
+
"type": "DashboardGrid",
|
|
52
|
+
"props": { "columns": 2 },
|
|
53
|
+
"children": []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Components
|
|
60
|
+
|
|
61
|
+
Use only these component types and prop names:
|
|
62
|
+
|
|
63
|
+
- `DashboardGrid` — layout container with string `children`; optional `props.columns` must be one of `1`, `2`, `3`, `4`, `6`, or `12`
|
|
64
|
+
- `BSLMetric` — KPI card; requires `props.queryId`, `props.label`, and `props.valueField`; optional `props.format` is `number`, `currency`, or `percent`
|
|
65
|
+
- `BSLChart` — chart placeholder; requires `props.queryId` and `props.chartType`; use `props.renderer: "echarts"` for normal charts; allowed chart types are `bar`, `line`, `area`, `scatter`, `heatmap`, `pie`, `treemap`, `sunburst`, `gauge`, and `table`; optional axis props are `props.x`, `props.y`, and `props.color` (not `xField`, `yField`, or `yFields`)
|
|
66
|
+
- `BSLPerspectiveViewer` — exploratory table/pivot; use `props.plugin: "Datagrid"` for detail tables; optional `props.columns`, `props.groupBy`, and `props.splitBy` are string arrays; optional `props.sort` is an array of `[field, "asc" | "desc"]` tuples
|
|
67
|
+
- `BSLFilter` — filter control targeting one or more query IDs; requires `props.id`, `props.field`, `props.controlType`, and `props.targetQueries`; `controlType` is `select`, `multiSelect`, `dateRange`, `numberRange`, or `search`
|
|
68
|
+
- `BSLText` — markdown notes or section text; requires `props.markdown`
|
|
69
|
+
|
|
70
|
+
## Authoring rules
|
|
71
|
+
|
|
72
|
+
- every component ID referenced in `children` must exist in `elements`
|
|
73
|
+
- every `queryId` and filter `targetQueries` entry must exist in `queries`
|
|
74
|
+
- use the exact validated prop names above; avoid invented aliases such as `xField`, `yField`, or `yFields`
|
|
75
|
+
- keep the spec concise and readable
|
|
76
|
+
- choose sensible query IDs and component IDs from the dashboard domain
|
|
77
|
+
- prefer semantic BSL fields such as `revenue`, `order_count`, `month`, `region`,
|
|
78
|
+
`customer_id`, and `cohort_month` over UI-specific names
|