diff --git a/app/e2e/dashboard-portability.spec.ts b/app/e2e/dashboard-portability.spec.ts index 8f20d763..5f72c0ff 100644 --- a/app/e2e/dashboard-portability.spec.ts +++ b/app/e2e/dashboard-portability.spec.ts @@ -160,7 +160,7 @@ test.describe("NeoDash legacy import", () => { timeout: 5_000, }); await expect(dialog.getByText("E2E NeoDash Import Test")).toBeVisible(); - await expect(dialog.getByText("4 widgets")).toBeVisible(); + await expect(dialog.getByText("6 widgets")).toBeVisible(); // No connection mapping should appear (NeoDash skips it) await expect(dialog.getByText("Map each connection")).not.toBeVisible(); @@ -173,9 +173,9 @@ test.describe("NeoDash legacy import", () => { // Should redirect to the imported dashboard await page.waitForURL(/\/[\w-]+$/, { timeout: 15_000 }); - // Verify 4 widget cards rendered (NeoDash report titles are not preserved - // as widget titles — the converter maps settings but not report.title) - await expect(page.locator("[data-testid='widget-card']")).toHaveCount(4, { + // Verify 6 widget cards rendered — includes gantt and graph3d→graph + // Report titles are now preserved as widget settings.title + await expect(page.locator("[data-testid='widget-card']")).toHaveCount(6, { timeout: 15_000, }); diff --git a/app/e2e/fixtures/imports/neodash-sample.json b/app/e2e/fixtures/imports/neodash-sample.json index 37026d5e..54f55bb5 100644 --- a/app/e2e/fixtures/imports/neodash-sample.json +++ b/app/e2e/fixtures/imports/neodash-sample.json @@ -52,6 +52,30 @@ "height": 4, "settings": {}, "parameters": {} + }, + { + "id": "r5", + "title": "Movie Timeline", + "type": "gantt", + "query": "MATCH (m:Movie) RETURN m.title AS task, m.released AS start, m.released + 2 AS end LIMIT 5", + "x": 0, + "y": 8, + "width": 12, + "height": 4, + "settings": {}, + "parameters": {} + }, + { + "id": "r6", + "title": "3D Network", + "type": "graph3d", + "query": "MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) RETURN p, r, m LIMIT 20", + "x": 0, + "y": 12, + "width": 12, + "height": 6, + "settings": {}, + "parameters": {} } ] } diff --git a/app/e2e/global-setup.ts b/app/e2e/global-setup.ts index c120a998..c1bc402c 100644 --- a/app/e2e/global-setup.ts +++ b/app/e2e/global-setup.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import * as crypto from "node:crypto"; import * as net from "node:net"; import * as http from "node:http"; -import { spawn } from "node:child_process"; +import { spawn, execSync } from "node:child_process"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; import { migrate } from "drizzle-orm/postgres-js/migrator"; @@ -249,28 +249,59 @@ export default async function globalSetup() { process.env.TEST_NEO4J_BOLT_URL = `bolt://localhost:${neo4jBoltPort}`; process.env.TEST_PG_PORT = String(pgPort); - // ── Start the Next.js server on a dynamically allocated port ──────────── - // Env vars are passed directly to the process — .env.local is never touched. + // ── Build & start the Next.js server on a dynamically allocated port ─── + // Always use a production build + `next start` for consistent, fast E2E + // runs. `next dev` recompiles pages on demand which adds 10+ minutes of + // webpack overhead locally. A one-time `next build` (~2 min) then instant + // `next start` is what CI already does and is dramatically faster overall. const appDir = path.resolve(__dirname, ".."); - const serverCmd = process.env.CI ? "start" : "dev"; - console.log( - `⏳ Starting Next.js ${serverCmd} server on port ${serverPort}...`, - ); - const args = ["next", serverCmd, "--port", String(serverPort)]; - // Use webpack explicitly — Turbopack (Next.js 16 default) doesn't correctly - // resolve CJS/ESM interop for the @neoboard/connection package at runtime. - if (serverCmd === "dev") args.push("--webpack"); + const serverEnv = { + ...process.env, + DATABASE_URL: databaseUrl, + ENCRYPTION_KEY: TEST_ENCRYPTION_KEY, + API_KEY_HMAC_SECRET: TEST_API_KEY_HMAC_SECRET, + NEXTAUTH_SECRET: TEST_NEXTAUTH_SECRET, + NEXTAUTH_URL: `http://localhost:${serverPort}`, + }; + + // Build once — skip if a previous build exists and E2E_SKIP_BUILD is set, + // OR if .next/BUILD_ID already exists (auto-detect cached build). + const buildIdPath = path.join(appDir, ".next", "BUILD_ID"); + const hasCachedBuild = fs.existsSync(buildIdPath); + + if (process.env.E2E_SKIP_BUILD && !hasCachedBuild) { + throw new Error( + "E2E_SKIP_BUILD is set but no prior build found at .next/BUILD_ID. " + + "Run `npx next build` once or unset E2E_SKIP_BUILD.", + ); + } + + if (process.env.E2E_SKIP_BUILD) { + console.log( + "⏩ Skipping build (E2E_SKIP_BUILD set, reusing existing .next)", + ); + } else { + if (hasCachedBuild) { + console.log( + "⏳ Rebuilding Next.js (production)... (set E2E_SKIP_BUILD=1 to reuse previous build)", + ); + } else { + console.log("⏳ Building Next.js (production)..."); + } + execSync("npx next build", { + cwd: appDir, + stdio: "inherit", + env: serverEnv, + }); + console.log("✅ Next.js build complete"); + } + + console.log(`⏳ Starting Next.js production server on port ${serverPort}...`); + const args = ["next", "start", "--port", String(serverPort)]; const server = spawn("npx", args, { cwd: appDir, stdio: "pipe", - env: { - ...process.env, - DATABASE_URL: databaseUrl, - ENCRYPTION_KEY: TEST_ENCRYPTION_KEY, - API_KEY_HMAC_SECRET: TEST_API_KEY_HMAC_SECRET, - NEXTAUTH_SECRET: TEST_NEXTAUTH_SECRET, - NEXTAUTH_URL: `http://localhost:${serverPort}`, - }, + env: serverEnv, detached: true, }); server.unref(); diff --git a/app/e2e/new-charts.spec.ts b/app/e2e/new-charts.spec.ts index 7a548f02..8bd3a6ca 100644 --- a/app/e2e/new-charts.spec.ts +++ b/app/e2e/new-charts.spec.ts @@ -1,10 +1,16 @@ -import { test, expect, ALICE, createTestDashboard, typeInEditor } from "./fixtures"; +import { + test, + expect, + ALICE, + createTestDashboard, + typeInEditor, +} from "./fixtures"; // --------------------------------------------------------------------------- -// New chart types (v0.8) — creation flow tests +// New chart types — creation flow tests // --------------------------------------------------------------------------- // These tests verify the end-to-end creation flow for each new chart type: -// Gauge, Sankey, Sunburst, Radar, Treemap. +// Gauge, Sankey, Sunburst, Radar, Treemap, Gantt. // // We focus on the creation flow (dialog → query → add widget) rather than // visual rendering details — chart rendering is verified by unit tests. @@ -50,7 +56,9 @@ test.describe("New chart types — creation flow", () => { ); // The Add Widget button should be enabled (no Run required for this flow) - await expect(dialog.getByRole("button", { name: "Add Widget" })).toBeEnabled({ + await expect( + dialog.getByRole("button", { name: "Add Widget" }), + ).toBeEnabled({ timeout: 10_000, }); await dialog.getByRole("button", { name: "Add Widget" }).click(); @@ -78,7 +86,9 @@ test.describe("New chart types — creation flow", () => { "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH m, count(p) AS cast RETURN m.title AS name, cast AS value ORDER BY cast DESC LIMIT 10", ); - await expect(dialog.getByRole("button", { name: "Add Widget" })).toBeEnabled({ + await expect( + dialog.getByRole("button", { name: "Add Widget" }), + ).toBeEnabled({ timeout: 10_000, }); await dialog.getByRole("button", { name: "Add Widget" }).click(); @@ -106,7 +116,9 @@ test.describe("New chart types — creation flow", () => { "MATCH (p:Person)-[r]->(m:Movie) WITH type(r) AS indicator, count(*) AS value RETURN indicator, value, 100 AS max", ); - await expect(dialog.getByRole("button", { name: "Add Widget" })).toBeEnabled({ + await expect( + dialog.getByRole("button", { name: "Add Widget" }), + ).toBeEnabled({ timeout: 10_000, }); await dialog.getByRole("button", { name: "Add Widget" }).click(); @@ -134,7 +146,9 @@ test.describe("New chart types — creation flow", () => { "MATCH (p:Person)-[r:ACTED_IN]->(m:Movie) RETURN p.name AS source, m.title AS target, 1 AS value LIMIT 15", ); - await expect(dialog.getByRole("button", { name: "Add Widget" })).toBeEnabled({ + await expect( + dialog.getByRole("button", { name: "Add Widget" }), + ).toBeEnabled({ timeout: 10_000, }); await dialog.getByRole("button", { name: "Add Widget" }).click(); @@ -162,7 +176,39 @@ test.describe("New chart types — creation flow", () => { "MATCH (p:Person)-[r]->(m:Movie) RETURN type(r) AS parent, m.title AS name, 1 AS value LIMIT 20", ); - await expect(dialog.getByRole("button", { name: "Add Widget" })).toBeEnabled({ + await expect( + dialog.getByRole("button", { name: "Add Widget" }), + ).toBeEnabled({ + timeout: 10_000, + }); + await dialog.getByRole("button", { name: "Add Widget" }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + }); + + test("should create a Gantt widget", async ({ page }) => { + test.setTimeout(60_000); + + await page.getByRole("button", { name: "Add Widget" }).first().click(); + const dialog = page.getByRole("dialog", { name: "Add Widget" }); + + // Select Neo4j connection first + await dialog.getByRole("combobox").nth(0).click(); + await page.getByRole("option").first().click(); + + // Select Gantt chart type + await dialog.getByRole("combobox").nth(1).click(); + await page.getByRole("option", { name: "Gantt" }).click(); + + // Type query — tasks with start/end timestamps + await typeInEditor( + dialog, + page, + "MATCH (p:Person)-[:ACTED_IN]->(m:Movie) WITH m.title AS task, m.released AS start, m.released + 2 AS end RETURN task, start, end LIMIT 8", + ); + + await expect( + dialog.getByRole("button", { name: "Add Widget" }), + ).toBeEnabled({ timeout: 10_000, }); await dialog.getByRole("button", { name: "Add Widget" }).click(); @@ -182,11 +228,15 @@ test.describe("Widget Showcase seed dashboard", () => { await page.waitForURL(/\/[\w-]+$/, { timeout: 10_000 }); }); - test("should render the Widget Showcase dashboard with widget cards", async ({ page }) => { + test("should render the Widget Showcase dashboard with widget cards", async ({ + page, + }) => { test.setTimeout(60_000); // At least one widget card should be visible on the page - await expect(page.locator("[data-testid='widget-card']").first()).toBeVisible({ + await expect( + page.locator("[data-testid='widget-card']").first(), + ).toBeVisible({ timeout: 15_000, }); }); @@ -202,7 +252,9 @@ test.describe("Widget Showcase seed dashboard", () => { test("should show the Rule-Based Styling page tab", async ({ page }) => { test.setTimeout(30_000); - await expect(page.getByRole("tab", { name: "Rule-Based Styling" })).toBeVisible({ + await expect( + page.getByRole("tab", { name: "Rule-Based Styling" }), + ).toBeVisible({ timeout: 10_000, }); }); @@ -214,18 +266,22 @@ test.describe("Widget Showcase seed dashboard", () => { await page.getByRole("tab", { name: "Simple Charts" }).click(); // Multiple widget cards should be present (bar, line, pie, single-value, table, gauge, radar, sankey, treemap, sunburst) - await expect(page.locator("[data-testid='widget-card']").first()).toBeVisible({ + await expect( + page.locator("[data-testid='widget-card']").first(), + ).toBeVisible({ timeout: 15_000, }); // At least 10 widgets should be on this page - const widgetCount = await page.locator("[data-testid='widget-card']").count(); + const widgetCount = await page + .locator("[data-testid='widget-card']") + .count(); expect(widgetCount).toBeGreaterThanOrEqual(10); }); test("should show Color Palettes page tab", async ({ page }) => { - await expect( - page.getByRole("tab", { name: "Color Palettes" }), - ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("tab", { name: "Color Palettes" })).toBeVisible( + { timeout: 10_000 }, + ); }); }); diff --git a/app/playwright.config.ts b/app/playwright.config.ts index e515f523..a7edd2e0 100644 --- a/app/playwright.config.ts +++ b/app/playwright.config.ts @@ -20,12 +20,10 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: 1, - // CI: 2 workers for parallel execution against the production server. - // Locally: 4 workers — Playwright's auto-detect picks based on CPU cores - // but collapses to serial under Docker-testcontainer load, turning a - // ~11-minute run into a ~20-minute run. An explicit number keeps local - // timing deterministic regardless of host contention. - workers: process.env.CI ? 2 : 4, + // CI: 2 workers (constrained runner resources). + // Locally: 6 workers — balances parallelism with server/DB contention. + // Override with --workers=N on the CLI for experimentation. + workers: process.env.CI ? 2 : 6, // CI: github (PR annotations) + list (real-time stream) + blob (for cross-shard merge). // Local: interactive HTML report. reporter: process.env.CI ? [["github"], ["list"], ["blob"]] : "html", diff --git a/app/src/components/widget-editor/__tests__/transform-editor.test.tsx b/app/src/components/widget-editor/__tests__/transform-editor.test.tsx index ac6f781d..ee8a53ba 100644 --- a/app/src/components/widget-editor/__tests__/transform-editor.test.tsx +++ b/app/src/components/widget-editor/__tests__/transform-editor.test.tsx @@ -56,6 +56,19 @@ vi.mock("@neoboard/components", () => ({ ), })); +// Mock @radix-ui/react-select primitives used directly in transform-editor +vi.mock("@radix-ui/react-select", () => ({ + Item: ({ children, value }: { children: React.ReactNode; value: string }) => ( + + ), + ItemText: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + ItemIndicator: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); + // Mock ValueOrParamInput vi.mock("../value-or-param-input", () => ({ ValueOrParamInput: (props: Record) => ( diff --git a/app/src/components/widget-editor/chart-type-selector.tsx b/app/src/components/widget-editor/chart-type-selector.tsx index 53900846..d8ed245a 100644 --- a/app/src/components/widget-editor/chart-type-selector.tsx +++ b/app/src/components/widget-editor/chart-type-selector.tsx @@ -18,6 +18,7 @@ import { Sun, Radar, LayoutGrid, + GanttChart as GanttChartIcon, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Label, Combobox } from "@neoboard/components"; @@ -43,6 +44,7 @@ export const chartTypeIcons: Record = { sunburst: Sun, radar: Radar, treemap: LayoutGrid, + gantt: GanttChartIcon, }; /** Get label + Icon for a chart type. Label from registry, Icon from UI layer. */ diff --git a/app/src/components/widget-editor/transform-editor.tsx b/app/src/components/widget-editor/transform-editor.tsx index 3fc8f640..2169c98e 100644 --- a/app/src/components/widget-editor/transform-editor.tsx +++ b/app/src/components/widget-editor/transform-editor.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { Plus, Trash2, GripVertical } from "lucide-react"; +import { Plus, Trash2, GripVertical, Check } from "lucide-react"; import { Button, Input, Label, Badge } from "@neoboard/components"; import { Select, @@ -10,6 +10,7 @@ import { SelectTrigger, SelectValue, } from "@neoboard/components"; +import * as SelectPrimitive from "@radix-ui/react-select"; import type { Transform } from "@/lib/query/data-transforms"; import { computeColumnsPerStep } from "@/lib/query/data-transforms"; import { ValueOrParamInput } from "./value-or-param-input"; @@ -522,14 +523,21 @@ export function TransformEditor({ {TRANSFORM_TYPES.map((t) => ( - -
- {t.label} - - {t.description} - -
-
+ + + + + + + {t.label} + + {t.description} + + ))}
diff --git a/app/src/lib/__tests__/dashboard/neodash-converter.test.ts b/app/src/lib/__tests__/dashboard/neodash-converter.test.ts index f3276ed6..9df0c6bf 100644 --- a/app/src/lib/__tests__/dashboard/neodash-converter.test.ts +++ b/app/src/lib/__tests__/dashboard/neodash-converter.test.ts @@ -169,6 +169,12 @@ describe("convertNeoDash", () => { { type: "markdown", expected: "markdown" }, { type: "gauge", expected: "gauge" }, { type: "select", expected: "parameter-select" }, + { type: "gantt", expected: "gantt" }, + { type: "graph3d", expected: "graph" }, + { type: "3d-graph", expected: "graph" }, + { type: "circle_packing", expected: "sunburst" }, + { type: "choropleth", expected: "map" }, + { type: "areamap", expected: "map" }, { type: "unknown_type", expected: "json" }, ])("maps $type → $expected", ({ type, expected }) => { const result = convertNeoDash(makeNeoDash({ type })); @@ -302,7 +308,8 @@ describe("convertNeoDash", () => { const widget = result.layout.pages[0].widgets[0]; expect(widget.query).toBe(""); expect(widget.params).toEqual({}); - expect(widget.settings).toEqual({}); + // Title is preserved from report.title even when settings/parameters are missing + expect(widget.settings).toEqual({ title: "W" }); }); it("falls back to 'Imported Dashboard' when title is missing", () => { @@ -348,7 +355,7 @@ describe("convertNeoDash", () => { expect(grid).toMatchObject({ x: 3, y: 5, w: 8, h: 6 }); }); - it("all chart type mappings", () => { + it("all direct chart type mappings", () => { const types = [ "table", "bar", @@ -361,10 +368,84 @@ describe("convertNeoDash", () => { "treemap", "sankey", "radar", + "gantt", ]; for (const type of types) { const result = convertNeoDash(makeNeoDash({ dashTitle: "T", type })); expect(result.layout.pages[0].widgets[0].chartType).toBe(type); } }); + + // --- widget title preservation --- + + it("preserves report.title as widget settings.title", () => { + const result = convertNeoDash(NEODASH_SIMPLE); + const widget = result.layout.pages[0].widgets[0]; + expect((widget.settings as Record).title).toBe( + "Users Table", + ); + }); + + it("preserves report.title for all widgets", () => { + const result = convertNeoDash(NEODASH_SIMPLE); + const titles = result.layout.pages[0].widgets.map( + (w) => (w.settings as Record).title, + ); + expect(titles).toEqual(["Users Table", "Bar Chart"]); + }); + + it("omits title from settings when report.title is empty", () => { + const result = convertNeoDash(makeNeoDash({ title: "" })); + const settings = result.layout.pages[0].widgets[0].settings as Record< + string, + unknown + >; + expect(settings.title).toBeUndefined(); + }); + + // --- degraded type conversions --- + + it("maps graph3d to 2D graph (best-effort)", () => { + const result = convertNeoDash( + makeNeoDash({ dashTitle: "T", type: "graph3d" }), + ); + expect(result.layout.pages[0].widgets[0].chartType).toBe("graph"); + }); + + it("maps circle_packing to sunburst (same hierarchical data)", () => { + const result = convertNeoDash( + makeNeoDash({ dashTitle: "T", type: "circle_packing" }), + ); + expect(result.layout.pages[0].widgets[0].chartType).toBe("sunburst"); + }); + + it("maps choropleth to map (best-effort, point markers only)", () => { + const result = convertNeoDash( + makeNeoDash({ dashTitle: "T", type: "choropleth" }), + ); + expect(result.layout.pages[0].widgets[0].chartType).toBe("map"); + }); + + // --- parameter conversion --- + + it("converts multiple $neodash_ parameters in a single query", () => { + const result = convertNeoDash( + makeNeoDash({ + query: + "MATCH (n) WHERE n.name = $neodash_name AND n.age > $neodash_minAge RETURN n", + }), + ); + expect(result.layout.pages[0].widgets[0].query).toBe( + "MATCH (n) WHERE n.name = $param_name AND n.age > $param_minAge RETURN n", + ); + }); + + it("leaves non-neodash parameters unchanged", () => { + const result = convertNeoDash( + makeNeoDash({ query: "MATCH (n) WHERE n.id = $someParam RETURN n" }), + ); + expect(result.layout.pages[0].widgets[0].query).toBe( + "MATCH (n) WHERE n.id = $someParam RETURN n", + ); + }); }); diff --git a/app/src/lib/__tests__/plugin/chart-helpers.test.ts b/app/src/lib/__tests__/plugin/chart-helpers.test.ts index eaec1e62..9818a856 100644 --- a/app/src/lib/__tests__/plugin/chart-helpers.test.ts +++ b/app/src/lib/__tests__/plugin/chart-helpers.test.ts @@ -207,9 +207,9 @@ describe("supportsColumnMapping", () => { // getAllChartTypes // --------------------------------------------------------------------------- describe("getAllChartTypes", () => { - it("returns all 17 registered types", () => { + it("returns all 18 registered types", () => { const types = getAllChartTypes(); - expect(types.length).toBe(17); + expect(types.length).toBe(18); for (const t of CHART_TYPES) { expect(types).toContain(t); } diff --git a/app/src/lib/dashboard/neodash-converter.ts b/app/src/lib/dashboard/neodash-converter.ts index 31cb0d53..94ed2713 100644 --- a/app/src/lib/dashboard/neodash-converter.ts +++ b/app/src/lib/dashboard/neodash-converter.ts @@ -10,17 +10,23 @@ const CHART_TYPE_MAP: Record = { bar: "bar", line: "line", graph: "graph", + graph3d: "graph", + "3d-graph": "graph", map: "map", + choropleth: "map", + areamap: "map", pie: "pie", value: "single-value", gauge: "gauge", sunburst: "sunburst", + circle_packing: "sunburst", treemap: "treemap", sankey: "sankey", radar: "radar", area: "line", iFrame: "iframe", iframe: "iframe", + gantt: "gantt", select: "parameter-select", markdown: "markdown", form: "form", @@ -91,6 +97,8 @@ export function convertNeoDash(json: unknown): NeoboardExport { params: report.parameters ?? {}, settings: { ...(report.settings ?? {}), + // Preserve report title as widget title + ...(report.title ? { title: report.title } : {}), // Set area mode for NeoDash "area" chart type ...(report.type === "area" ? { chartOptions: { area: true } } : {}), }, diff --git a/app/src/plugins/__tests__/plugin-options.test.ts b/app/src/plugins/__tests__/plugin-options.test.ts index 5703cbe6..23ff8c98 100644 --- a/app/src/plugins/__tests__/plugin-options.test.ts +++ b/app/src/plugins/__tests__/plugin-options.test.ts @@ -111,8 +111,8 @@ describe("plugin options (Phase 5)", () => { } }); - it("all 17 chart types are registered", () => { - expect(CHART_TYPES.length).toBe(17); + it("all 18 chart types are registered", () => { + expect(CHART_TYPES.length).toBe(18); for (const type of CHART_TYPES) { expect(pluginRegistry.has(type), `${type} should be registered`).toBe( true, diff --git a/app/src/plugins/chart-types.ts b/app/src/plugins/chart-types.ts index 7ac01d9d..83076b2d 100644 --- a/app/src/plugins/chart-types.ts +++ b/app/src/plugins/chart-types.ts @@ -24,6 +24,7 @@ export const CHART_TYPES = [ "sunburst", "radar", "treemap", + "gantt", ] as const; export type ChartType = (typeof CHART_TYPES)[number]; diff --git a/app/src/plugins/gantt/component.tsx b/app/src/plugins/gantt/component.tsx new file mode 100644 index 00000000..3b15cc42 --- /dev/null +++ b/app/src/plugins/gantt/component.tsx @@ -0,0 +1,65 @@ +/** + * Gantt chart plugin. + * + * Timeline chart showing tasks as horizontal bars on a time axis. + * Supports click actions, rule-based styling, and progress overlays. + */ + +import dynamic from "next/dynamic"; +import { Skeleton, getChartOptions } from "@neoboard/components"; +import type { GanttDataItem, StylingRule } from "@neoboard/components"; +import { defineChartPlugin } from "../registry"; +import { transformToGanttData } from "./transform"; +import { useEChartsClick, type PluginProps } from "../utils"; +import { ganttSettingsSchema } from "./settings"; + +const GanttChart = dynamic( + () => import("@neoboard/components").then((m) => ({ default: m.GanttChart })), + { ssr: false, loading: () => }, +); + +function GanttPluginComponent({ + data, + settings: raw, + stylingRules, + paramValues, + onChartClick, +}: PluginProps) { + const onClick = useEChartsClick(onChartClick, data); + const settings = ganttSettingsSchema.parse(raw); + return ( + + ); +} + +export const ganttPlugin = defineChartPlugin({ + type: "gantt", + label: "Gantt", + component: GanttPluginComponent, + transform: transformToGanttData, + transformWithMapping: transformToGanttData, + options: getChartOptions("gantt"), + compatibleWith: ["neo4j", "postgresql"], + settingsSchema: ganttSettingsSchema, + stylingTargets: [{ value: "color", label: "Bar Color" }], + capabilities: { + supportsClickAction: true, + supportsStyling: true, + isECharts: true, + requiresQuery: true, + }, + queryHint: + "Return columns: task name, start date, end date. Optional: category/status, progress (0-1).\n" + + "Example: SELECT task_name, start_date, end_date, status FROM projects", +}); diff --git a/app/src/plugins/gantt/index.ts b/app/src/plugins/gantt/index.ts new file mode 100644 index 00000000..e8e991af --- /dev/null +++ b/app/src/plugins/gantt/index.ts @@ -0,0 +1 @@ +export { ganttPlugin } from "./component"; diff --git a/app/src/plugins/gantt/settings.ts b/app/src/plugins/gantt/settings.ts new file mode 100644 index 00000000..214a3136 --- /dev/null +++ b/app/src/plugins/gantt/settings.ts @@ -0,0 +1,17 @@ +/** + * Zod settings schema for the Gantt chart plugin. + */ +import { z } from "zod"; + +export const ganttSettingsSchema = z + .object({ + showTodayLine: z.boolean().default(true), + showProgress: z.boolean().default(false), + showGridLines: z.boolean().default(true), + barBorderRadius: z.coerce.number().default(2), + colorPalette: z.string().optional(), + colorblindMode: z.boolean().default(false), + }) + .passthrough(); + +export type GanttSettings = z.infer; diff --git a/app/src/plugins/gantt/transform.ts b/app/src/plugins/gantt/transform.ts new file mode 100644 index 00000000..02bdbf75 --- /dev/null +++ b/app/src/plugins/gantt/transform.ts @@ -0,0 +1,108 @@ +import { toRecords, normalizeValue } from "../transforms/shared-utils"; + +/** + * Transform raw query results into Gantt chart data. + * + * Heuristically detects columns: + * - task/name/label → task name (required) + * - start/start_date/begin → start time (required) + * - end/end_date/finish/due → end time (required) + * - category/status/group/phase → color grouping (optional) + * - progress/percent/completion → 0-1 completion (optional) + */ +export function transformToGanttData(data: unknown): unknown { + const records = toRecords(data); + if (!records.length) return []; + + const keys = Object.keys(records[0]); + if (keys.length < 3) return []; + + // Detect task column + const taskKey = + keys.find((k) => /^(task|name|label|title)$/i.test(k)) ?? keys[0]; + + // Detect start column + const startKey = + keys.find( + (k) => k !== taskKey && /^(start|start_date|begin|from)$/i.test(k), + ) ?? keys[1]; + + // Detect end column + const endKey = + keys.find( + (k) => + k !== taskKey && + k !== startKey && + /^(end|end_date|finish|due|to|deadline)$/i.test(k), + ) ?? keys[2]; + + // Detect optional category column + const categoryKey = keys.find( + (k) => + k !== taskKey && + k !== startKey && + k !== endKey && + /^(category|status|group|phase|type)$/i.test(k), + ); + + // Detect optional progress column + const progressKey = keys.find( + (k) => + k !== taskKey && + k !== startKey && + k !== endKey && + k !== categoryKey && + /^(progress|percent|completion|pct)$/i.test(k), + ); + + return records + .map((row) => { + const task = String(normalizeValue(row[taskKey]) ?? ""); + const startRaw = row[startKey]; + const endRaw = row[endKey]; + + // Parse dates — accept ISO strings, Unix timestamps (ms or s), Date objects + const start = parseTime(startRaw); + const end = parseTime(endRaw); + + if (start === null || end === null) return null; + + const item: Record = { task, start, end }; + + if (categoryKey && row[categoryKey] != null) { + item.category = String(normalizeValue(row[categoryKey]) ?? ""); + } + + if (progressKey && row[progressKey] != null) { + const p = Number(row[progressKey]); + if (!Number.isNaN(p)) { + // Accept 0-1 or 0-100 range, then clamp to [0, 1] + const scaled = p > 1 ? p / 100 : p; + item.progress = Math.max(0, Math.min(1, scaled)); + } + } + + return item; + }) + .filter(Boolean); +} + +function parseTime(value: unknown): number | null { + if (value == null) return null; + if (value instanceof Date) return value.getTime(); + if (typeof value === "number") { + // Heuristic: values below 1e12 (~Sep 2001 in ms) are treated as seconds. + // This correctly handles Unix timestamps (seconds since epoch) but will + // misclassify pre-2001 millisecond timestamps. In practice, Gantt data + // is almost always recent dates, so this is an acceptable trade-off. + return value < 1e12 ? value * 1000 : value; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) return parsed; + // Try as numeric string + const num = Number(value); + if (!Number.isNaN(num)) return num < 1e12 ? num * 1000 : num; + } + return null; +} diff --git a/app/src/plugins/gauge/component.tsx b/app/src/plugins/gauge/component.tsx index 4f2f245a..adcc0f10 100644 --- a/app/src/plugins/gauge/component.tsx +++ b/app/src/plugins/gauge/component.tsx @@ -33,7 +33,6 @@ function GaugePluginComponent({ min={settings.min} max={settings.max} showProgress={settings.showProgress} - showPointer={settings.showPointer} showDetail={settings.showDetail} startAngle={settings.startAngle} endAngle={settings.endAngle} diff --git a/app/src/plugins/gauge/settings.ts b/app/src/plugins/gauge/settings.ts index 04779d19..635071d3 100644 --- a/app/src/plugins/gauge/settings.ts +++ b/app/src/plugins/gauge/settings.ts @@ -8,7 +8,6 @@ export const gaugeSettingsSchema = z min: z.coerce.number().default(0), max: z.coerce.number().default(100), showProgress: z.boolean().default(true), - showPointer: z.boolean().default(true), showDetail: z.boolean().default(true), startAngle: z.coerce.number().default(225), endAngle: z.coerce.number().default(-45), diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index 25095f62..600533c5 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -34,6 +34,7 @@ import { sankeyPlugin } from "./sankey"; import { sunburstPlugin } from "./sunburst"; import { radarPlugin } from "./radar"; import { treemapPlugin } from "./treemap"; +import { ganttPlugin } from "./gantt"; const BUILT_IN_PLUGINS = [ markdownPlugin, @@ -53,6 +54,7 @@ const BUILT_IN_PLUGINS = [ sunburstPlugin, radarPlugin, treemapPlugin, + ganttPlugin, ]; // Idempotent registration — the first import of this module registers diff --git a/app/src/plugins/settings/__tests__/settings-schemas.test.ts b/app/src/plugins/settings/__tests__/settings-schemas.test.ts index 0cf05729..09ffeefe 100644 --- a/app/src/plugins/settings/__tests__/settings-schemas.test.ts +++ b/app/src/plugins/settings/__tests__/settings-schemas.test.ts @@ -189,7 +189,6 @@ describe("gaugeSettingsSchema", () => { expect(result.min).toBe(0); expect(result.max).toBe(100); expect(result.showProgress).toBe(true); - expect(result.showPointer).toBe(true); expect(result.showDetail).toBe(true); expect(result.startAngle).toBe(225); expect(result.endAngle).toBe(-45); diff --git a/app/src/plugins/sunburst/component.tsx b/app/src/plugins/sunburst/component.tsx index 6b40af18..0bc602ad 100644 --- a/app/src/plugins/sunburst/component.tsx +++ b/app/src/plugins/sunburst/component.tsx @@ -32,6 +32,7 @@ function SunburstPluginComponent({ ({ + useContainerSize: () => ({ + width: 800, + height: 400, + containerRef: vi.fn(), + }), +})); + +vi.mock("echarts/core", () => { + const use = vi.fn(); + const init = vi.fn(() => ({ + setOption: mockSetOption, + resize: vi.fn(), + dispose: vi.fn(), + on: vi.fn(), + off: vi.fn(), + showLoading: vi.fn(), + hideLoading: vi.fn(), + })); + const registerTheme = vi.fn(); + return { use, init, registerTheme, default: { use, init, registerTheme } }; +}); + +const sampleData: Array<{ + task: string; + start: number; + end: number; + category?: string; + progress?: number; +}> = [ + { + task: "Design", + start: 1700000000000, + end: 1700500000000, + category: "Phase 1", + }, + { + task: "Develop", + start: 1700300000000, + end: 1701000000000, + category: "Phase 1", + }, + { + task: "Test", + start: 1700800000000, + end: 1701200000000, + category: "Phase 2", + }, +]; + +describe("GanttChart", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders without errors", () => { + render(); + expect(screen.getByTestId("base-chart")).toBeInTheDocument(); + }); + + it("handles empty data with a No data title", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.title.text).toBe("No data"); + }); + + it("uses custom series type for Gantt bars", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].type).toBe("custom"); + }); + + it("sets time axis on xAxis", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.xAxis.type).toBe("time"); + }); + + it("sets category axis on yAxis with task names", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.yAxis.type).toBe("category"); + expect(optionsCall.yAxis.data).toEqual(["Design", "Develop", "Test"]); + }); + + it("renders a today marker line by default", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + const markLine = optionsCall.series[0].markLine; + expect(markLine).toBeDefined(); + expect(markLine.data[0].xAxis).toBeDefined(); + }); + + it("hides today marker when showTodayLine is false", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeUndefined(); + }); + + it("includes dataZoom for scrolling", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.dataZoom).toBeDefined(); + expect(optionsCall.dataZoom.length).toBeGreaterThan(0); + }); + + it("passes renderItem function to custom series", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(typeof optionsCall.series[0].renderItem).toBe("function"); + }); + + it("applies styling rule color to matching tasks", () => { + const stylingRules = [ + { + id: "r1", + column: "category", + operator: "==" as const, + value: "Phase 2", + color: "#ff0000", + }, + ]; + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + // The series data should contain the styling info for renderItem to use + const seriesData = optionsCall.series[0].data; + expect(seriesData).toHaveLength(3); + }); + + it("shows loading state", () => { + render(); + expect(screen.getByTestId("base-chart")).toBeInTheDocument(); + }); + + it("shows error state", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Fail"); + }); +}); diff --git a/component/src/charts/__tests__/gauge-chart.test.tsx b/component/src/charts/__tests__/gauge-chart.test.tsx index 4d8c9b1d..f2a80736 100644 --- a/component/src/charts/__tests__/gauge-chart.test.tsx +++ b/component/src/charts/__tests__/gauge-chart.test.tsx @@ -66,42 +66,45 @@ describe("GaugeChart", () => { expect(optionsCall.series[0].max).toBe(200); }); - // --- axisTick distance bug fix --- - it("sets axisTick.distance to -20 in non-compact mode", () => { + // --- minimal design: no ticks, no labels --- + it("hides axisTick in minimal design", () => { render(); const optionsCall = mockSetOption.mock.calls[0][0]; - const series = optionsCall.series[0]; - expect(series.axisTick.show).toBe(true); - expect(series.axisTick.distance).toBe(-20); + expect(optionsCall.series[0].axisTick.show).toBe(false); }); - it("sets splitLine.distance to -20 in non-compact mode", () => { + it("hides splitLine in minimal design", () => { render(); const optionsCall = mockSetOption.mock.calls[0][0]; - const series = optionsCall.series[0]; - expect(series.splitLine.show).toBe(true); - expect(series.splitLine.distance).toBe(-20); + expect(optionsCall.series[0].splitLine.show).toBe(false); }); - it("sets axisLabel.distance to 30 in non-compact mode", () => { + it("hides axisLabel in minimal design", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].axisLabel.show).toBe(false); + }); + + it("shows progress arc with roundCap by default", () => { render(); const optionsCall = mockSetOption.mock.calls[0][0]; const series = optionsCall.series[0]; - expect(series.axisLabel.show).toBe(true); - expect(series.axisLabel.distance).toBe(30); + expect(series.progress.show).toBe(true); + expect(series.progress.roundCap).toBe(true); }); - it("sets axisLabel.fontSize to 11", () => { + it("hides pointer and anchor", () => { render(); const optionsCall = mockSetOption.mock.calls[0][0]; - expect(optionsCall.series[0].axisLabel.fontSize).toBe(11); + const series = optionsCall.series[0]; + expect(series.pointer.show).toBe(false); + expect(series.anchor.show).toBe(false); }); - // --- axisTick splitNumber --- - it("sets axisTick.splitNumber to 2 to reduce number of minor ticks", () => { + it("uses roundCap on axisLine track", () => { render(); const optionsCall = mockSetOption.mock.calls[0][0]; - expect(optionsCall.series[0].axisTick.splitNumber).toBe(2); + expect(optionsCall.series[0].axisLine.roundCap).toBe(true); }); it("shows loading state", () => { @@ -177,17 +180,18 @@ describe("GaugeChart", () => { // --- compact mode --- - it("hides axisTick, splitLine, and axisLabel in compact mode (container < 200px)", () => { + it("uses smaller arc width and font in compact mode (container < 200px)", () => { mockSize.width = 150; mockSize.height = 150; render(); const optionsCall = mockSetOption.mock.calls[0][0]; const series = optionsCall.series[0]; - expect(series.axisTick.show).toBe(false); - expect(series.splitLine.show).toBe(false); - expect(series.axisLabel.show).toBe(false); - // Detail stays visible in compact mode (smaller font), title hides + // Thinner arc in compact + expect(series.axisLine.lineStyle.width).toBe(10); + expect(series.progress.width).toBe(10); + // Smaller detail font, title hidden expect(series.detail.show).toBe(true); + expect(series.detail.fontSize).toBe(18); expect(series.title.show).toBe(false); }); }); diff --git a/component/src/charts/gantt-chart.tsx b/component/src/charts/gantt-chart.tsx new file mode 100644 index 00000000..7227bb29 --- /dev/null +++ b/component/src/charts/gantt-chart.tsx @@ -0,0 +1,327 @@ +import { useMemo } from "react"; +import * as echarts from "echarts/core"; +import { CustomChart } from "echarts/charts"; +import { + TitleComponent, + TooltipComponent, + GridComponent, + DataZoomComponent, + MarkLineComponent, +} from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; +import type { EChartsOption } from "echarts"; +import { BaseChart } from "./base-chart"; +import type { BaseChartProps } from "./types"; +import { buildEmptyDataOption } from "./chart-utils"; +import { resolveStylingRuleColor, type StylingRule } from "./styling-rule"; + +echarts.use([ + CustomChart, + TitleComponent, + TooltipComponent, + GridComponent, + DataZoomComponent, + MarkLineComponent, + CanvasRenderer, +]); + +export interface GanttDataItem { + task: string; + start: number; + end: number; + category?: string; + progress?: number; + [key: string]: unknown; +} + +export interface GanttChartProps extends Omit { + /** Array of tasks: [{ task, start, end, category?, progress? }] */ + data: GanttDataItem[]; + /** Show a vertical "today" marker line */ + showTodayLine?: boolean; + /** Show progress overlay inside bars */ + showProgress?: boolean; + /** Bar corner radius */ + barBorderRadius?: number; + /** Show grid lines */ + showGridLines?: boolean; + /** Rule-based styling rules */ + stylingRules?: StylingRule[]; + /** Resolved parameter values for parameterRef comparisons */ + paramValues?: Record; +} + +/** Format ms duration to human-readable string. */ +function formatDuration(ms: number): string { + const hours = ms / 3600000; + if (hours < 24) return `${Math.round(hours)}h`; + const days = hours / 24; + if (days < 30) return `${Math.round(days)}d`; + return `${Math.round(days / 30)}mo`; +} + +function GanttChart({ + data, + showTodayLine = true, + showProgress = false, + barBorderRadius = 2, + showGridLines = true, + stylingRules, + paramValues, + ...rest +}: GanttChartProps) { + const options = useMemo((): EChartsOption => { + if (!data.length) return buildEmptyDataOption(); + + const taskNames = data.map((d) => d.task); + const barHeightRatio = 0.6; + + // Resolve colors per item + const resolvedColors = data.map((item) => { + if (!stylingRules?.length) return undefined; + // Evaluate each rule against the column it targets. + // Rules with a `column` field match that specific data field; + // rules without `column` try category first, then task name. + for (const rule of stylingRules) { + if (rule.column) { + const cellValue = item[rule.column]; + if (cellValue != null) { + const color = resolveStylingRuleColor( + cellValue, + [rule], + paramValues, + ); + if (color) return color; + } + } else { + const catColor = item.category + ? resolveStylingRuleColor(item.category, [rule], paramValues) + : undefined; + if (catColor) return catColor; + const taskColor = resolveStylingRuleColor( + item.task, + [rule], + paramValues, + ); + if (taskColor) return taskColor; + } + } + return undefined; + }); + + // Build series data: [taskIndex, start, end, duration, category, progress, resolvedColor] + const seriesData = data.map((item, i) => ({ + value: [ + i, + item.start, + item.end, + item.end - item.start, + item.category ?? "", + item.progress ?? 0, + ], + itemStyle: resolvedColors[i] ? { color: resolvedColors[i] } : undefined, + })); + + // Custom renderItem for horizontal bars + const renderItem = ( + _params: { + coordSys: { x: number; y: number; width: number; height: number }; + }, + api: { + value: (dim: number) => number; + coord: (val: [number, number]) => [number, number]; + size: (val: [number, number]) => [number, number]; + style: (extra?: Record) => Record; + visual: (key: string) => string; + }, + ) => { + const taskIndex = api.value(0); + const startTime = api.value(1); + const endTime = api.value(2); + const progress = api.value(5); + + const startCoord = api.coord([startTime, taskIndex]); + const endCoord = api.coord([endTime, taskIndex]); + const barHeight = api.size([0, 1])[1] * barHeightRatio; + + const x = startCoord[0]; + const y = startCoord[1] - barHeight / 2; + const width = Math.max(endCoord[0] - startCoord[0], 2); // min 2px width + + const group: { type: string; children: unknown[] } = { + type: "group", + children: [ + // Main bar + { + type: "rect", + shape: { + x, + y, + width, + height: barHeight, + r: barBorderRadius, + }, + style: api.style(), + emphasis: { + style: { + shadowBlur: 6, + shadowColor: "rgba(0, 0, 0, 0.3)", + }, + }, + }, + ], + }; + + // Progress overlay + if (showProgress && progress > 0) { + const progressWidth = width * Math.min(progress, 1); + const full = progress >= 1; + (group.children as unknown[]).push({ + type: "rect", + shape: { + x, + y, + width: progressWidth, + height: barHeight, + // Only round the right side when the overlay spans the whole bar + r: full + ? barBorderRadius + : [barBorderRadius, 0, 0, barBorderRadius], + }, + style: { + fill: "rgba(255, 255, 255, 0.25)", + }, + }); + } + + return group; + }; + + const todayMarkLine = showTodayLine + ? { + markLine: { + silent: true, + symbol: "none", + lineStyle: { + color: "#E74C3C", + type: "dashed" as const, + width: 1.5, + }, + label: { + formatter: "Today", + position: "insideStartTop" as const, + fontSize: 10, + color: "inherit", + }, + data: [{ xAxis: Date.now() }], + }, + } + : {}; + + return { + tooltip: { + trigger: "item", + formatter: (params: unknown) => { + const p = params as { value: number[] }; + const v = p.value; + const name = taskNames[v[0]]; + const start = new Date(v[1]).toLocaleDateString(); + const end = new Date(v[2]).toLocaleDateString(); + const duration = formatDuration(v[3]); + const category = v[4] + ? `
Category: ${echarts.format.encodeHTML(String(v[4]))}` + : ""; + const progress = + v[5] > 0 ? `
Progress: ${Math.round(Number(v[5]) * 100)}%` : ""; + return `${echarts.format.encodeHTML(name)}
${start} → ${end} (${duration})${category}${progress}`; + }, + }, + grid: { + left: "15%", + right: "5%", + top: 30, + bottom: 60, + containLabel: false, + }, + xAxis: { + type: "time", + splitLine: { + show: showGridLines, + lineStyle: { type: "dashed", opacity: 0.3 }, + }, + }, + yAxis: { + type: "category", + data: taskNames, + inverse: true, + axisLabel: { + fontSize: 11, + overflow: "truncate", + ellipsis: "…", + width: 100, + }, + splitLine: { show: false }, + }, + dataZoom: [ + // Horizontal: time axis zoom + { + type: "slider", + xAxisIndex: 0, + height: 20, + bottom: 5, + borderColor: "transparent", + }, + { + type: "inside", + xAxisIndex: 0, + }, + // Vertical: task list scroll (show ~15 tasks at a time) + ...(taskNames.length > 15 + ? [ + { + type: "slider" as const, + yAxisIndex: 0, + width: 12, + right: 0, + startValue: 0, + endValue: 14, + borderColor: "transparent", + fillerColor: "rgba(140, 140, 140, 0.15)", + handleSize: "60%", + }, + { + type: "inside" as const, + yAxisIndex: 0, + }, + ] + : []), + ], + series: [ + { + type: "custom", + renderItem: renderItem as never, + encode: { + x: [1, 2], + y: 0, + }, + data: seriesData, + ...todayMarkLine, + }, + ], + animationDuration: 500, + animationEasingUpdate: "cubicOut", + }; + }, [ + data, + showTodayLine, + showProgress, + barBorderRadius, + showGridLines, + stylingRules, + paramValues, + ]); + + return ; +} + +export { GanttChart }; diff --git a/component/src/charts/gauge-chart.tsx b/component/src/charts/gauge-chart.tsx index 82c8fc0c..67b81c82 100644 --- a/component/src/charts/gauge-chart.tsx +++ b/component/src/charts/gauge-chart.tsx @@ -30,8 +30,6 @@ export interface GaugeChartProps extends Omit { max?: number; /** Show progress arc filling */ showProgress?: boolean; - /** Show the needle pointer */ - showPointer?: boolean; /** Show the numeric value and name detail */ showDetail?: boolean; /** Start angle in degrees (0 = 3 o'clock) */ @@ -58,7 +56,6 @@ function GaugeChart({ min = 0, max = 100, showProgress = true, - showPointer = true, showDetail = true, startAngle = 225, endAngle = -45, @@ -78,6 +75,40 @@ function GaugeChart({ if (!data.length) return buildEmptyDataOption(); const point = data[0]; + const arcWidth = compact ? 10 : 18; + + const thresholdZones = parseGaugeThresholdZones( + thresholdZonesJson, + min, + max, + ) as [number, string][]; + + const hasCustomZones = + thresholdZones.length > 1 || + (thresholdZones.length === 1 && thresholdZones[0][0] !== 1); + + const resolvedColor = resolveItemColor( + point.value, + stylingRules, + paramValues, + ); + + // Determine the progress color: styling rule > threshold zone > default accent + const gaugeSpan = max - min; + const normalizedValue = + gaugeSpan > 0 + ? Math.max(0, Math.min(1, (point.value - min) / gaugeSpan)) + : undefined; + const thresholdColor = + hasCustomZones && normalizedValue !== undefined + ? thresholdZones.find(([stop]) => normalizedValue <= stop)?.[1] + : undefined; + const progressColor = resolvedColor ?? thresholdColor ?? "#5470c6"; + + // Track color — light gray that works in both themes + const trackColor = hasCustomZones + ? (thresholdZones as never) + : ([[1, "rgba(140, 140, 140, 0.15)"]] as never); return { tooltip: { @@ -90,86 +121,64 @@ function GaugeChart({ max, startAngle, endAngle, + radius: "90%", progress: { show: showProgress, - width: compact ? 10 : 16, + width: arcWidth, roundCap: true, + itemStyle: { + color: progressColor, + }, }, pointer: { - show: showPointer, - length: "55%", - width: compact ? 4 : 6, - itemStyle: { color: "auto" }, + show: false, }, axisLine: { roundCap: true, lineStyle: { - width: compact ? 10 : 16, - color: parseGaugeThresholdZones( - thresholdZonesJson, - min, - max, - ) as never, + width: arcWidth, + color: trackColor, }, }, axisTick: { - show: !compact, - distance: compact ? 0 : -20, - splitNumber: 2, - length: 6, - lineStyle: { width: 1.5, color: "inherit" }, + show: false, }, splitLine: { - show: !compact, - distance: compact ? 0 : -20, - length: compact ? 8 : 12, - lineStyle: { width: 2, color: "inherit" }, + show: false, }, axisLabel: { - show: !compact, - distance: compact ? 0 : 30, - fontSize: 11, - color: "inherit", + show: false, }, anchor: { - show: showPointer && !compact, - size: 10, - showAbove: true, - itemStyle: { borderWidth: 2, borderColor: "auto" }, + show: false, }, detail: { show: showDetail, valueAnimation: true, - fontSize: compact ? 14 : 24, - fontWeight: "bold", + fontSize: compact ? 18 : 36, + fontWeight: 600, + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', formatter: "{value}", - offsetCenter: [0, showPointer ? "70%" : "0%"], + offsetCenter: [0, "0%"], color: "inherit", }, title: { show: showDetail && !compact, - offsetCenter: [0, showPointer ? "90%" : "25%"], - fontSize: 12, - color: "inherit", + offsetCenter: [0, "22%"], + fontSize: compact ? 11 : 14, + color: "rgba(140, 140, 140, 0.8)", + fontWeight: 400, }, animationDuration: 1000, animationEasingUpdate: "cubicOut", - data: (() => { - const resolvedColor = resolveItemColor( - point.value, - stylingRules, - paramValues, - ); - return [ - { - value: point.value, - name: point.name ?? "", - ...(resolvedColor - ? { itemStyle: { color: resolvedColor } } - : {}), - }, - ]; - })(), + data: [ + { + value: point.value, + name: point.name ?? "", + ...(resolvedColor ? { itemStyle: { color: resolvedColor } } : {}), + }, + ], }, ], }; @@ -181,7 +190,6 @@ function GaugeChart({ startAngle, endAngle, showProgress, - showPointer, showDetail, thresholdZonesJson, compact, diff --git a/component/src/charts/index.ts b/component/src/charts/index.ts index e3eac62b..20bab41b 100644 --- a/component/src/charts/index.ts +++ b/component/src/charts/index.ts @@ -68,6 +68,9 @@ export type { export { TreemapChart } from "./treemap-chart"; export type { TreemapChartProps, TreemapDataItem } from "./treemap-chart"; +export { GanttChart } from "./gantt-chart"; +export type { GanttChartProps, GanttDataItem } from "./gantt-chart"; + export type { BaseChartProps, ChartSize, diff --git a/component/src/charts/sunburst-chart.tsx b/component/src/charts/sunburst-chart.tsx index 97722e6c..56c2f55d 100644 --- a/component/src/charts/sunburst-chart.tsx +++ b/component/src/charts/sunburst-chart.tsx @@ -23,6 +23,8 @@ export interface SunburstChartProps extends Omit { data: SunburstDataItem[]; /** Show segment labels */ showLabels?: boolean; + /** Maximum depth at which labels are shown (1 = only first ring, 2 = first two, etc.). 0 or undefined = auto (show first 2 levels). */ + maxLabelDepth?: number; /** Sort order for segments */ sort?: "desc" | "asc" | "none"; /** Highlight segments on hover */ @@ -40,6 +42,7 @@ export interface SunburstChartProps extends Omit { function SunburstChart({ data, showLabels = true, + maxLabelDepth, sort = "desc", highlightOnHover = true, stylingRules, @@ -55,6 +58,58 @@ function SunburstChart({ // Sort function for echarts sunburst const sortFn = sort === "none" ? null : sort === "asc" ? "asc" : "desc"; + // Determine how deep labels should display. + // 0 / undefined / null → auto (default 2). Positive integer = exact depth. + const labelDepth = + typeof maxLabelDepth === "number" && + Number.isFinite(maxLabelDepth) && + maxLabelDepth > 0 + ? Math.floor(maxLabelDepth) + : 2; + const canShowLabel = (depth: number) => + showLabels && !compact && depth <= labelDepth; + + // Walk the tree to find max depth and count nodes at each level + const countByLevel: number[] = []; + const walk = (items: SunburstDataItem[], depth: number) => { + countByLevel[depth] = (countByLevel[depth] ?? 0) + items.length; + for (const item of items) { + if (item.children?.length) walk(item.children, depth + 1); + } + }; + walk(data, 1); + const dataDepth = countByLevel.length - 1; + + // Level 0 = root (center), level 1 = first ring, etc. + // Labels beyond maxLabelDepth are rendered but invisible (transparent) + // so that emphasis can reveal them along the ancestor path on hover. + const levels: Record[] = [{}]; // root + for (let i = 1; i <= dataDepth; i++) { + const count = countByLevel[i] ?? 0; + const withinDepth = canShowLabel(i); + // Always use radial rotation when dense — it packs tighter + const rotation = count > 6 ? "radial" : "tangential"; + // Scale font down progressively as rings get more crowded + const fontSize = count > 20 ? 9 : count > 12 ? 10 : i === 1 ? 12 : 11; + // Narrower truncation width for crowded rings + const width = count > 12 ? 50 : rotation === "tangential" ? 100 : 70; + levels.push({ + ...(i === 1 ? { itemStyle: { borderWidth: 2 } } : {}), + label: { + // When showLabels is off, hide everything. + // Otherwise, keep labels present but use transparent color for + // hidden levels so emphasis can reveal them on hover. + show: showLabels && !compact, + rotate: rotation, + overflow: "truncate", + ellipsis: "…", + width, + fontSize: withinDepth ? fontSize : 10, + color: withinDepth ? "inherit" : "transparent", + }, + }); + } + return { tooltip: { trigger: "item", @@ -91,15 +146,20 @@ function SunburstChart({ // eslint-disable-next-line @typescript-eslint/no-explicit-any sort: sortFn as any, label: { - show: showLabels && !compact, + show: !compact, fontSize: 11, }, - // Hide labels for segments with arc angle below 5 degrees + // Hide labels on very thin slivers regardless of level settings minAngle: 5, emphasis: highlightOnHover ? { focus: "ancestor", - label: { show: showLabels && !compact }, + label: { + show: true, + fontSize: 12, + fontWeight: "bold" as const, + color: "inherit", + }, itemStyle: { shadowBlur: 4, shadowOffsetX: 0, @@ -107,39 +167,14 @@ function SunburstChart({ }, } : {}, - levels: [ - {}, - { - itemStyle: { borderWidth: 2 }, - label: { - show: showLabels && !compact, - rotate: "tangential", - overflow: "truncate", - ellipsis: "…", - width: 100, - fontSize: 12, - }, - }, - { - label: { - show: showLabels && !compact, - rotate: "radial", - overflow: "truncate", - ellipsis: "…", - width: 80, - fontSize: 11, - }, - }, - { - label: { show: false }, - }, - ], + levels, }, ], }; }, [ data, showLabels, + maxLabelDepth, sort, highlightOnHover, compact, diff --git a/component/src/components/composed/chart-options/gantt.ts b/component/src/components/composed/chart-options/gantt.ts new file mode 100644 index 00000000..c65ffdf6 --- /dev/null +++ b/component/src/components/composed/chart-options/gantt.ts @@ -0,0 +1,37 @@ +import { type ChartOptionDef } from "./shared"; + +export const ganttOptions: ChartOptionDef[] = [ + { + key: "showTodayLine", + label: "Show Today Line", + type: "boolean", + default: true, + category: "Style", + description: "Display a vertical dashed line marking today's date.", + }, + { + key: "showProgress", + label: "Show Progress", + type: "boolean", + default: false, + category: "Style", + description: + "Overlay a progress indicator inside each bar (requires a progress column returning 0–1).", + }, + { + key: "showGridLines", + label: "Show Grid Lines", + type: "boolean", + default: true, + category: "Style", + description: "Show vertical grid lines on the time axis.", + }, + { + key: "barBorderRadius", + label: "Bar Corner Radius", + type: "number", + default: 2, + category: "Style", + description: "Corner radius for task bars (0 = square, 4+ = rounded).", + }, +]; diff --git a/component/src/components/composed/chart-options/gauge.ts b/component/src/components/composed/chart-options/gauge.ts index fd7d3517..fbc60f14 100644 --- a/component/src/components/composed/chart-options/gauge.ts +++ b/component/src/components/composed/chart-options/gauge.ts @@ -25,14 +25,6 @@ export const gaugeOptions: ChartOptionDef[] = [ category: "Style", description: "Fill the gauge arc to show progress toward the maximum.", }, - { - key: "showPointer", - label: "Show Pointer", - type: "boolean", - default: true, - category: "Style", - description: "Display a needle pointer on the gauge.", - }, { key: "showDetail", label: "Show Value Detail", diff --git a/component/src/components/composed/chart-options/index.ts b/component/src/components/composed/chart-options/index.ts index 042c105b..547373f3 100644 --- a/component/src/components/composed/chart-options/index.ts +++ b/component/src/components/composed/chart-options/index.ts @@ -31,6 +31,7 @@ import { sankeyOptions } from "./sankey"; import { sunburstOptions } from "./sunburst"; import { radarOptions } from "./radar"; import { treemapOptions } from "./treemap"; +import { ganttOptions } from "./gantt"; const chartOptionsRegistry: Record = { bar: [ @@ -70,6 +71,7 @@ const chartOptionsRegistry: Record = { sunburst: [...sunburstOptions, ...behaviorOptions, ...appearanceOptions], radar: [...radarOptions, ...behaviorOptions, ...appearanceOptions], treemap: [...treemapOptions, ...behaviorOptions, ...appearanceOptions], + gantt: [...ganttOptions, ...behaviorOptions, ...appearanceOptions], }; export function getChartOptions(chartType: string): ChartOptionDef[] { diff --git a/component/src/components/composed/chart-options/sunburst.ts b/component/src/components/composed/chart-options/sunburst.ts index fbcada7b..d13133f9 100644 --- a/component/src/components/composed/chart-options/sunburst.ts +++ b/component/src/components/composed/chart-options/sunburst.ts @@ -2,6 +2,15 @@ import { type ChartOptionDef, SHARED_SHOW_LABELS } from "./shared"; export const sunburstOptions: ChartOptionDef[] = [ { ...SHARED_SHOW_LABELS, description: "Show the name of each segment." }, + { + key: "maxLabelDepth", + label: "Label Depth", + type: "number", + default: 2, + category: "Labels", + description: + "Maximum ring depth at which labels are shown (1 = first ring only, 2 = first two, etc.).", + }, { key: "sort", label: "Sort Segments", diff --git a/component/stories/charts/gantt-chart.stories.tsx b/component/stories/charts/gantt-chart.stories.tsx new file mode 100644 index 00000000..c6a0922b --- /dev/null +++ b/component/stories/charts/gantt-chart.stories.tsx @@ -0,0 +1,194 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { GanttChart } from "@/charts/gantt-chart"; + +const meta = { + title: "Charts/GanttChart", + component: GanttChart, + parameters: { layout: "padded" }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Helper: days from a base date +const day = (offset: number) => new Date(2026, 3, 1 + offset).getTime(); + +const projectData = [ + { task: "Requirements", start: day(0), end: day(5), category: "Planning" }, + { task: "Design", start: day(3), end: day(10), category: "Planning" }, + { task: "Frontend", start: day(8), end: day(22), category: "Development" }, + { task: "Backend", start: day(10), end: day(25), category: "Development" }, + { task: "Database", start: day(9), end: day(18), category: "Development" }, + { task: "Integration", start: day(20), end: day(28), category: "Testing" }, + { task: "QA", start: day(25), end: day(32), category: "Testing" }, + { task: "Deployment", start: day(30), end: day(33), category: "Release" }, + { task: "Documentation", start: day(28), end: day(34), category: "Release" }, +]; + +export const Default: Story = { + args: { + data: projectData, + }, +}; + +export const WithCategories: Story = { + args: { + data: projectData, + stylingRules: [ + { + id: "r1", + column: "category", + operator: "==" as const, + value: "Planning", + color: "#5470c6", + }, + { + id: "r2", + column: "category", + operator: "==" as const, + value: "Development", + color: "#91cc75", + }, + { + id: "r3", + column: "category", + operator: "==" as const, + value: "Testing", + color: "#fac858", + }, + { + id: "r4", + column: "category", + operator: "==" as const, + value: "Release", + color: "#ee6666", + }, + ], + }, +}; + +export const WithProgress: Story = { + args: { + data: [ + { + task: "Requirements", + start: day(0), + end: day(5), + category: "Done", + progress: 1.0, + }, + { + task: "Design", + start: day(3), + end: day(10), + category: "Done", + progress: 1.0, + }, + { + task: "Frontend", + start: day(8), + end: day(22), + category: "In Progress", + progress: 0.65, + }, + { + task: "Backend", + start: day(10), + end: day(25), + category: "In Progress", + progress: 0.4, + }, + { + task: "Database", + start: day(9), + end: day(18), + category: "Done", + progress: 1.0, + }, + { + task: "Integration", + start: day(20), + end: day(28), + category: "Not Started", + progress: 0, + }, + { + task: "QA", + start: day(25), + end: day(32), + category: "Not Started", + progress: 0, + }, + { + task: "Deployment", + start: day(30), + end: day(33), + category: "Not Started", + progress: 0, + }, + ], + showProgress: true, + stylingRules: [ + { + id: "r1", + column: "category", + operator: "==" as const, + value: "Done", + color: "#91cc75", + }, + { + id: "r2", + column: "category", + operator: "==" as const, + value: "In Progress", + color: "#5470c6", + }, + { + id: "r3", + column: "category", + operator: "==" as const, + value: "Not Started", + color: "#aaa", + }, + ], + }, +}; + +export const LargeDataset: Story = { + args: { + data: Array.from({ length: 30 }, (_, i) => ({ + task: `Task ${i + 1}`, + start: day(i * 2), + end: day(i * 2 + Math.floor(Math.random() * 8) + 3), + category: ["Backend", "Frontend", "DevOps", "QA"][i % 4], + })), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const NoTodayLine: Story = { + args: { + data: projectData, + showTodayLine: false, + }, +}; + +export const EmptyState: Story = { + args: { + data: [], + }, +}; diff --git a/component/stories/charts/gauge-chart.stories.tsx b/component/stories/charts/gauge-chart.stories.tsx index 7d59f427..b9144ac2 100644 --- a/component/stories/charts/gauge-chart.stories.tsx +++ b/component/stories/charts/gauge-chart.stories.tsx @@ -50,10 +50,14 @@ export const CustomRange: Story = { }, }; -export const NoPointer: Story = { +export const ThresholdZones: Story = { args: { - data: [{ value: 55, name: "Progress" }], - showPointer: false, + data: [{ value: 72, name: "Health" }], + thresholdZones: JSON.stringify([ + { value: 40, color: "#FF6E76" }, + { value: 70, color: "#FDDD60" }, + { value: 100, color: "#58D9A3" }, + ]), }, }; diff --git a/component/stories/charts/sunburst-chart.stories.tsx b/component/stories/charts/sunburst-chart.stories.tsx index f2d4445a..3245441c 100644 --- a/component/stories/charts/sunburst-chart.stories.tsx +++ b/component/stories/charts/sunburst-chart.stories.tsx @@ -109,6 +109,478 @@ export const AscendingSort: Story = { }, }; +export const DeepHierarchy: Story = { + args: { + data: [ + { + name: "World", + children: [ + { + name: "North America", + children: [ + { + name: "United States", + children: [ + { + name: "California", + children: [ + { name: "Los Angeles", value: 3900 }, + { name: "San Francisco", value: 870 }, + { name: "San Diego", value: 1400 }, + { name: "San Jose", value: 1030 }, + ], + }, + { + name: "Texas", + children: [ + { name: "Houston", value: 2300 }, + { name: "Dallas", value: 1340 }, + { name: "Austin", value: 960 }, + { name: "San Antonio", value: 1530 }, + ], + }, + { + name: "New York", + children: [ + { name: "New York City", value: 8300 }, + { name: "Buffalo", value: 255 }, + { name: "Rochester", value: 210 }, + ], + }, + { + name: "Florida", + children: [ + { name: "Miami", value: 450 }, + { name: "Orlando", value: 310 }, + { name: "Tampa", value: 390 }, + { name: "Jacksonville", value: 950 }, + ], + }, + { + name: "Illinois", + children: [ + { name: "Chicago", value: 2700 }, + { name: "Springfield", value: 115 }, + ], + }, + ], + }, + { + name: "Canada", + children: [ + { + name: "Ontario", + children: [ + { name: "Toronto", value: 2930 }, + { name: "Ottawa", value: 1010 }, + ], + }, + { + name: "Quebec", + children: [ + { name: "Montreal", value: 1780 }, + { name: "Quebec City", value: 540 }, + ], + }, + { + name: "British Columbia", + children: [ + { name: "Vancouver", value: 2580 }, + { name: "Victoria", value: 390 }, + ], + }, + ], + }, + { + name: "Mexico", + children: [ + { name: "Mexico City", value: 9200 }, + { name: "Guadalajara", value: 1460 }, + { name: "Monterrey", value: 1130 }, + ], + }, + ], + }, + { + name: "Europe", + children: [ + { + name: "Western Europe", + children: [ + { + name: "United Kingdom", + children: [ + { name: "London", value: 8980 }, + { name: "Manchester", value: 550 }, + { name: "Birmingham", value: 1140 }, + { name: "Edinburgh", value: 525 }, + ], + }, + { + name: "France", + children: [ + { name: "Paris", value: 2160 }, + { name: "Lyon", value: 515 }, + { name: "Marseille", value: 870 }, + ], + }, + { + name: "Germany", + children: [ + { name: "Berlin", value: 3640 }, + { name: "Munich", value: 1470 }, + { name: "Hamburg", value: 1850 }, + { name: "Frankfurt", value: 750 }, + ], + }, + { + name: "Netherlands", + children: [ + { name: "Amsterdam", value: 870 }, + { name: "Rotterdam", value: 650 }, + ], + }, + ], + }, + { + name: "Southern Europe", + children: [ + { + name: "Spain", + children: [ + { name: "Madrid", value: 3220 }, + { name: "Barcelona", value: 1620 }, + { name: "Valencia", value: 790 }, + ], + }, + { + name: "Italy", + children: [ + { name: "Rome", value: 2870 }, + { name: "Milan", value: 1370 }, + { name: "Naples", value: 960 }, + ], + }, + ], + }, + { + name: "Northern Europe", + children: [ + { + name: "Sweden", + children: [ + { name: "Stockholm", value: 970 }, + { name: "Gothenburg", value: 580 }, + ], + }, + { + name: "Norway", + children: [ + { name: "Oslo", value: 690 }, + { name: "Bergen", value: 280 }, + ], + }, + ], + }, + ], + }, + { + name: "Asia", + children: [ + { + name: "East Asia", + children: [ + { + name: "Japan", + children: [ + { name: "Tokyo", value: 13960 }, + { name: "Osaka", value: 2750 }, + { name: "Kyoto", value: 1470 }, + ], + }, + { + name: "South Korea", + children: [ + { name: "Seoul", value: 9770 }, + { name: "Busan", value: 3430 }, + ], + }, + { + name: "China", + children: [ + { name: "Shanghai", value: 24870 }, + { name: "Beijing", value: 21540 }, + { name: "Shenzhen", value: 12590 }, + { name: "Guangzhou", value: 15300 }, + ], + }, + ], + }, + { + name: "Southeast Asia", + children: [ + { + name: "Singapore", + children: [{ name: "Singapore City", value: 5690 }], + }, + { + name: "Thailand", + children: [ + { name: "Bangkok", value: 10540 }, + { name: "Chiang Mai", value: 130 }, + ], + }, + { + name: "Vietnam", + children: [ + { name: "Ho Chi Minh City", value: 8990 }, + { name: "Hanoi", value: 8050 }, + ], + }, + ], + }, + { + name: "South Asia", + children: [ + { + name: "India", + children: [ + { name: "Mumbai", value: 20670 }, + { name: "Delhi", value: 16780 }, + { name: "Bangalore", value: 8440 }, + { name: "Chennai", value: 4680 }, + { name: "Hyderabad", value: 6810 }, + ], + }, + ], + }, + ], + }, + { + name: "South America", + children: [ + { + name: "Brazil", + children: [ + { name: "Sao Paulo", value: 12330 }, + { name: "Rio de Janeiro", value: 6750 }, + { name: "Brasilia", value: 3050 }, + ], + }, + { + name: "Argentina", + children: [ + { name: "Buenos Aires", value: 3060 }, + { name: "Cordoba", value: 1390 }, + ], + }, + { + name: "Colombia", + children: [ + { name: "Bogota", value: 7410 }, + { name: "Medellin", value: 2530 }, + ], + }, + ], + }, + { + name: "Africa", + children: [ + { + name: "Nigeria", + children: [ + { name: "Lagos", value: 15390 }, + { name: "Abuja", value: 3280 }, + ], + }, + { + name: "South Africa", + children: [ + { name: "Johannesburg", value: 5780 }, + { name: "Cape Town", value: 4620 }, + ], + }, + { + name: "Kenya", + children: [ + { name: "Nairobi", value: 4400 }, + { name: "Mombasa", value: 1200 }, + ], + }, + { + name: "Egypt", + children: [ + { name: "Cairo", value: 10230 }, + { name: "Alexandria", value: 5160 }, + ], + }, + ], + }, + { + name: "Oceania", + children: [ + { + name: "Australia", + children: [ + { name: "Sydney", value: 5310 }, + { name: "Melbourne", value: 5080 }, + { name: "Brisbane", value: 2560 }, + { name: "Perth", value: 2080 }, + ], + }, + { + name: "New Zealand", + children: [ + { name: "Auckland", value: 1660 }, + { name: "Wellington", value: 215 }, + ], + }, + ], + }, + ], + }, + ], + maxLabelDepth: 3, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const ManyFirstLevel: Story = { + args: { + data: [ + { + name: "Departments", + children: [ + { + name: "Engineering", + children: [ + { name: "Frontend", value: 12 }, + { name: "Backend", value: 18 }, + { name: "QA", value: 6 }, + ], + }, + { + name: "Product", + children: [ + { name: "PMs", value: 8 }, + { name: "Designers", value: 5 }, + ], + }, + { + name: "Sales", + children: [ + { name: "Enterprise", value: 14 }, + { name: "SMB", value: 10 }, + { name: "Partnerships", value: 4 }, + ], + }, + { + name: "Marketing", + children: [ + { name: "Growth", value: 6 }, + { name: "Content", value: 4 }, + { name: "Brand", value: 3 }, + ], + }, + { + name: "Finance", + children: [ + { name: "Accounting", value: 5 }, + { name: "FP&A", value: 3 }, + ], + }, + { + name: "Legal", + children: [ + { name: "Corporate", value: 3 }, + { name: "IP", value: 2 }, + ], + }, + { + name: "HR", + children: [ + { name: "Recruiting", value: 7 }, + { name: "People Ops", value: 4 }, + ], + }, + { + name: "Operations", + children: [ + { name: "IT", value: 6 }, + { name: "Facilities", value: 3 }, + ], + }, + { + name: "Customer Success", + children: [ + { name: "Onboarding", value: 5 }, + { name: "Support", value: 9 }, + { name: "Renewals", value: 4 }, + ], + }, + { + name: "Data", + children: [ + { name: "Analytics", value: 4 }, + { name: "Data Eng", value: 5 }, + { name: "ML", value: 3 }, + ], + }, + { + name: "Security", + children: [ + { name: "AppSec", value: 3 }, + { name: "Infra", value: 4 }, + ], + }, + { + name: "DevRel", + children: [ + { name: "Advocacy", value: 2 }, + { name: "Community", value: 3 }, + ], + }, + { + name: "Research", + children: [ + { name: "Applied", value: 4 }, + { name: "Fundamental", value: 2 }, + ], + }, + { + name: "Compliance", + children: [ + { name: "Audit", value: 2 }, + { name: "Risk", value: 3 }, + ], + }, + { + name: "Procurement", + children: [ + { name: "Vendors", value: 2 }, + { name: "Contracts", value: 1 }, + ], + }, + { + name: "Localization", + children: [ + { name: "Translation", value: 3 }, + { name: "Regional", value: 2 }, + ], + }, + ], + }, + ], + maxLabelDepth: 2, + }, +}; + export const EmptyState: Story = { args: { data: [], diff --git a/component/vitest.setup.ts b/component/vitest.setup.ts index 48e07097..d0169f79 100644 --- a/component/vitest.setup.ts +++ b/component/vitest.setup.ts @@ -31,6 +31,7 @@ vi.mock("echarts/charts", () => ({ SunburstChart: vi.fn(), RadarChart: vi.fn(), TreemapChart: vi.fn(), + CustomChart: vi.fn(), })); vi.mock("echarts/components", () => ({ diff --git a/docs/APP_IMPLEMENTATION_GUIDE.md b/docs/APP_IMPLEMENTATION_GUIDE.md new file mode 100644 index 00000000..f9f5e94c --- /dev/null +++ b/docs/APP_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,2173 @@ +# NeoBoard `app/` Package — Implementation Guide + +> A comprehensive reference for understanding the architecture, data flows, and implementation patterns of the NeoBoard Next.js application. + +--- + +## Table of Contents + +1. [Package Overview](#1-package-overview) +2. [Architecture Diagrams](#2-architecture-diagrams) +3. [Directory Structure](#3-directory-structure) +4. [App Router & Page Architecture](#4-app-router--page-architecture) +5. [Authentication & Authorization](#5-authentication--authorization) +6. [API Routes](#6-api-routes) +7. [Database Layer (Drizzle ORM)](#7-database-layer-drizzle-orm) +8. [Query Execution Pipeline](#8-query-execution-pipeline) +9. [State Management (Zustand)](#9-state-management-zustand) +10. [Data Fetching (TanStack Query Hooks)](#10-data-fetching-tanstack-query-hooks) +11. [Component Architecture](#11-component-architecture) +12. [Chart Plugin System](#12-chart-plugin-system) +13. [Parameter System](#13-parameter-system) +14. [Multi-Tenancy](#14-multi-tenancy) +15. [Middleware & Instrumentation](#15-middleware--instrumentation) +16. [Dashboard Import/Export & Migration](#16-dashboard-importexport--migration) +17. [Logging & Observability](#17-logging--observability) +18. [Security Model](#18-security-model) +19. [Extension System](#19-extension-system) +20. [Testing Strategy](#20-testing-strategy) +21. [E2E Test Suite](#21-e2e-test-suite) +22. [Configuration Files](#22-configuration-files) +23. [Key Data Flows (End-to-End)](#23-key-data-flows-end-to-end) +24. [Release 2.0 Features](#24-release-20-features) + +--- + +## 1. Package Overview + +The `app/` package is the Next.js 16 application that orchestrates the entire NeoBoard product. It sits at the top of a strict three-package monorepo: + +``` +app/ — Next.js application (this package) +component/ — React UI library (no business logic, no API calls) +connection/ — Database connector library (no UI, no React) +``` + +**Key constraint:** `app/` may import from `component/` and `connection/`. Neither of those packages may import from `app/` or from each other. + +### Tech Stack Summary + +| Concern | Technology | +| ------------ | ---------------------------------------------------- | +| Framework | Next.js 16 (App Router, Turbopack dev, Webpack prod) | +| React | v19 with Server Components | +| Language | TypeScript (strict mode) | +| UI Library | shadcn/ui + Tailwind CSS | +| Charts | ECharts (modular imports) | +| Graph Viz | Neo4j NVL | +| Maps | Leaflet | +| State | Zustand v5 | +| Server State | TanStack Query v5 | +| Auth | Auth.js v5 (NextAuth) | +| ORM | Drizzle ORM | +| Validation | Zod | +| Logging | Pino | +| Testing | Vitest (unit/component) + Playwright (E2E) | + +--- + +## 2. Architecture Diagrams + +### 2.1 High-Level System Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ BROWSER │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ Dashboard │ │ Connection │ │ User │ │ Settings │ │ +│ │ Pages │ │ Manager │ │ Management │ │ Pages │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └─────┬──────┘ │ +│ │ │ │ │ │ +│ ┌──────┴─────────────────┴──────────────────┴────────────────┴──────┐ │ +│ │ REACT COMPONENT LAYER │ │ +│ │ Zustand Stores (dashboard, widget-editor, parameter, ...) │ │ +│ │ TanStack Query Hooks (use-dashboards, use-connections, ...) │ │ +│ └──────────────────────────────┬────────────────────────────────────┘ │ +└─────────────────────────────────┼──────────────────────────────────────┘ + │ HTTP (fetch) + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ NEXT.JS SERVER │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ EDGE MIDDLEWARE (proxy.ts) │ │ +│ │ Auth gate · Request ID · API key validation · JWT decode │ │ +│ └────────────────────────────────┬────────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ API ROUTES (src/app/api/) │ │ +│ │ │ │ +│ │ /connections /dashboards /query /users /keys /templates │ │ +│ │ │ │ +│ │ ┌───────────┐ ┌───────────┐ ┌────────────┐ ┌───────────┐ │ │ +│ │ │requireSes │ │validateBod│ │handleRoute │ │apiSuccess │ │ │ +│ │ │sion() │ │y() │ │Error() │ │/apiError │ │ │ +│ │ └───────────┘ └───────────┘ └────────────┘ └───────────┘ │ │ +│ └────────────────────────────────┬────────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ BUSINESS LOGIC LAYER (src/lib/) │ │ +│ │ │ │ +│ │ ┌────────────┐ ┌────────────────┐ ┌──────────────────────┐ │ │ +│ │ │ Auth │ │ Query │ │ Dashboard │ │ │ +│ │ │ -------- │ │ ---------- │ │ --------- │ │ │ +│ │ │ session │ │ scheduler │ │ import/export │ │ │ +│ │ │ api-key │ │ executor │ │ migration │ │ │ +│ │ │ bootstrap │ │ middleware │ │ neodash converter │ │ │ +│ │ │ signup │ │ audit │ │ optimistic lock │ │ │ +│ │ └────────────┘ └───────┬────────┘ └──────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌────────────┐ ┌───────┴────────┐ ┌──────────────────────┐ │ │ +│ │ │ Crypto │ │ Connector │ │ Extensions │ │ │ +│ │ │ -------- │ │ Adapter │ │ --------- │ │ │ +│ │ │ encrypt │ │ ---------- │ │ feature flags │ │ │ +│ │ │ hash │ │ bridges to │ │ enterprise hooks │ │ │ +│ │ │ rate-limit│ │ connection/ │ │ plugin registry │ │ │ +│ │ └────────────┘ └───────┬────────┘ └──────────────────────┘ │ │ +│ └──────────────────────────┼──────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────┴─────────────────────────────────────┐ │ +│ │ DATA LAYER │ │ +│ │ │ │ +│ │ ┌──────────────────┐ ┌──────────────────────────────────┐ │ │ +│ │ │ Drizzle ORM │ │ connection/ package │ │ │ +│ │ │ (PostgreSQL) │ │ (Neo4j driver + pg client) │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ Users │ │ ┌────────────┐ ┌─────────────┐ │ │ │ +│ │ │ Dashboards │ │ │ Neo4j │ │ PostgreSQL │ │ │ │ +│ │ │ Connections │ │ │ (Cypher) │ │ (SQL) │ │ │ │ +│ │ │ API Keys │ │ └─────┬──────┘ └──────┬──────┘ │ │ │ +│ │ │ Audit Log │ │ │ │ │ │ │ +│ │ └────────┬─────────┘ └────────┼───────────────┼────────┘ │ │ +│ └───────────┼─────────────────────────┼───────────────┼──────────┘ │ +└──────────────┼─────────────────────────┼───────────────┼───────────────┘ + ▼ ▼ ▼ + ┌──────────────┐ ┌────────────┐ ┌────────────┐ + │ PostgreSQL │ │ Neo4j │ │ PostgreSQL │ + │ (app data) │ │ (user DB) │ │ (user DB) │ + └──────────────┘ └────────────┘ └────────────┘ +``` + +### 2.2 Request Lifecycle + +Every browser request flows through this pipeline: + +``` + Browser Request + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ 1. EDGE MIDDLEWARE (proxy.ts) │ + │ ┌──────────────────────────────────────────────────┐ │ + │ │ Generate/propagate x-request-id │ │ + │ │ │ │ │ + │ │ ▼ │ │ + │ │ Is public path? ──yes──▶ PASS THROUGH │ │ + │ │ │ no │ │ + │ │ ▼ │ │ + │ │ Has Bearer nb_* token? ──yes──▶ Validate API │ │ + │ │ │ no key, set headers │ │ + │ │ ▼ │ │ │ + │ │ Has NextAuth JWT? ──yes──▶ Decode JWT │ │ + │ │ │ no │ │ │ + │ │ ▼ ▼ │ │ + │ │ REDIRECT ──▶ /login Force password change? │ │ + │ │ │ yes │ no │ │ + │ │ ▼ ▼ │ │ + │ │ /change-password CONTINUE │ │ + │ └──────────────────────────────────────────────────┘ │ + └─────────────────────────────┬───────────────────────────────┘ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ 2. ROUTE HANDLER │ + │ │ + │ PAGE ROUTES API ROUTES │ + │ ┌────────────────────┐ ┌────────────────────────┐ │ + │ │ Server Component │ │ requireSession() │ │ + │ │ renders page │ │ validateBody(schema) │ │ + │ │ with layout.tsx │ │ permission check │ │ + │ │ hydrates client │ │ business logic │ │ + │ │ components │ │ apiSuccess/apiError │ │ + │ └────────────────────┘ └────────────────────────┘ │ + └─────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────────────────┐ + │ 3. RESPONSE │ + │ │ + │ Pages: HTML with hydration payload │ + │ API: { data, meta } or { error: { code, message } } │ + │ │ + │ Headers: x-request-id propagated for tracing │ + └─────────────────────────────────────────────────────────────┘ +``` + +### 2.3 Dashboard Rendering Pipeline + +How a dashboard goes from database to pixels: + +``` + ┌──────────────────┐ + │ GET /api/dash/ │ + │ boards/[id] │ + └────────┬─────────┘ + │ + ┌──────────▼──────────┐ + │ layoutJson (JSONB) │ + │ from PostgreSQL │ + └──────────┬──────────┘ + │ + ┌──────────────▼──────────────┐ + │ dashboard-store.ts │ + │ loadDashboard(data) │ + │ │ + │ pages[] │ + │ ├── page[0].widgets[] │ + │ ├── page[1].widgets[] │ + │ └── ... │ + └──────────────┬──────────────┘ + │ + ┌──────────────▼──────────────┐ + │ DashboardContainer │ + │ ┌────────────────────┐ │ + │ │ PageTabs │ │ + │ │ [Page 1][Page 2] │ │ + │ └────────────────────┘ │ + │ ┌────────────────────┐ │ + │ │ react-grid-layout │ │ + │ │ │ │ + │ │ ┌──────┐ ┌──────┐ │ │ + │ │ │Card │ │Card │ │ │ + │ │ │ 1 │ │ 2 │ │ │ + │ │ └──────┘ └──────┘ │ │ + │ │ ┌──────┐ ┌──────┐ │ │ + │ │ │Card │ │Card │ │ │ + │ │ │ 3 │ │ 4 │ │ │ + │ │ └──────┘ └──────┘ │ │ + │ └────────────────────┘ │ + └──────────────────────────────┘ + │ + ┌──────────────────┼──────────────────┐ + ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ CardContain │ │ CardContain │ │ CardContain │ + │ er (per │ │ er (per │ │ er (per │ + │ widget) │ │ widget) │ │ widget) │ + └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────────────┐ + │ PER-WIDGET PIPELINE │ + │ │ + │ 1. Read widget config from store │ + │ │ │ + │ ▼ │ + │ 2. useWidgetQuery() │ + │ ├── Resolve $params from parameter-store │ + │ ├── POST /api/query │ + │ ├── Priority: P2 (load) or P3 (refresh) │ + │ └── Cache via TanStack Query │ + │ │ │ + │ ▼ │ + │ 3. Data Transform (plugins/transforms/) │ + │ ├── toRecords() normalize format │ + │ ├── resolveLabelKey() pick x-axis │ + │ ├── resolveValueKeys() pick y-axis │ + │ └── Chart-specific transform │ + │ │ │ + │ ▼ │ + │ 4. Dispatch to renderer by chartType │ + │ ├── "bar","line","pie"... → ChartRenderer │ + │ │ → ECharts │ + │ ├── "table" → TableRenderer │ + │ ├── "form" → FormRenderer │ + │ ├── "graph" → GraphExplorer │ + │ │ (Neo4j NVL) │ + │ ├── "map" → MapRenderer │ + │ │ (Leaflet) │ + │ ├── "parameter-select" → ParamRenderer │ + │ └── "markdown","json", → ContentRenderer │ + │ "iframe" │ + └──────────────────────────────────────────────────┘ +``` + +### 2.4 Query Execution Deep Dive + +The full lifecycle of a query from click to chart: + +``` + User clicks widget User loads page Auto-refresh timer + (interactive) (initial render) (background) + │ │ │ + ▼ ▼ ▼ + Priority: P1 Priority: P2 Priority: P3 + │ │ │ + └──────────────────────┼───────────────────┘ + │ + ▼ + ┌───────────────────────────────┐ + │ POST /api/query │ + │ { │ + │ connectionId, │ + │ query, │ + │ params: { $name: value }, │ + │ priority: 1|2|3, │ + │ cacheConfig │ + │ } │ + └───────────────┬───────────────┘ + │ + ┌───────────────▼───────────────┐ + │ API ROUTE │ + │ requireSession() │ + │ validateBody(Zod schema) │ + │ Lookup connection │ + │ Decrypt credentials (AES-256) │ + └───────────────┬───────────────┘ + │ + ┌──────────────────────▼──────────────────────┐ + │ MIDDLEWARE PIPELINE │ + │ │ + │ ┌────────────────────────────────────────┐ │ + │ │ SCHEDULER (per-connector instance) │ │ + │ │ │ │ + │ │ Queue depth < max? │ │ + │ │ no ──▶ QueueRejectedError (503) │ │ + │ │ yes │ │ + │ │ │ │ │ + │ │ P3 + fill ratio > 0.8? │ │ + │ │ yes ──▶ QueueRejectedError/shed (503)│ │ + │ │ no │ │ + │ │ │ │ │ + │ │ Wait for slot (P1 > P2 > P3) │ │ + │ │ Round-robin within same priority │ │ + │ │ │ │ │ + │ │ Wait > 15s? │ │ + │ │ yes ──▶ QueueTimeoutError (408) │ │ + │ │ no │ │ + │ │ ▼ │ │ + │ │ SLOT ACQUIRED │ │ + │ └────────────────────┬───────────────────┘ │ + │ │ │ + │ ┌────────────────────▼───────────────────┐ │ + │ │ AUDIT (pre-execution) │ │ + │ │ Log: userId, query, connectionId │ │ + │ └────────────────────┬───────────────────┘ │ + │ │ │ + │ ┌────────────────────▼───────────────────┐ │ + │ │ CORE EXECUTOR │ │ + │ │ │ │ + │ │ ┌──────────────────────────────────┐ │ │ + │ │ │ 1. Substitute $params │ │ │ + │ │ │ $country → "USA" │ │ │ + │ │ │ (parameterized, never concat) │ │ │ + │ │ └──────────────┬───────────────────┘ │ │ + │ │ │ │ │ + │ │ ┌──────────────▼───────────────────┐ │ │ + │ │ │ 2. Connection Adapter │ │ │ + │ │ │ app/ → connection/ bridge │ │ │ + │ │ └──────────────┬───────────────────┘ │ │ + │ │ │ │ │ + │ │ ┌──────────────▼───────────────────┐ │ │ + │ │ │ 3. Driver Execution │ │ │ + │ │ │ │ │ │ + │ │ │ PostgreSQL: │ │ │ + │ │ │ BEGIN READ ONLY │ │ │ + │ │ │ AbortSignal timeout (30s) │ │ │ + │ │ │ Cursor: MAX_ROWS+1 pattern │ │ │ + │ │ │ │ │ │ + │ │ │ Neo4j: │ │ │ + │ │ │ Read access mode │ │ │ + │ │ │ Native timeout (30s) │ │ │ + │ │ │ Stream consumption limit │ │ │ + │ │ └──────────────┬───────────────────┘ │ │ + │ └────────────────────┬───────────────────┘ │ + │ │ │ + │ ┌────────────────────▼───────────────────┐ │ + │ │ AUDIT (post-execution) │ │ + │ │ Log: duration, rowCount, success/fail │ │ + │ └────────────────────┬───────────────────┘ │ + └───────────────────────┼──────────────────────┘ + │ + ┌───────────────▼───────────────┐ + │ RESPONSE │ + │ { │ + │ data: { │ + │ columns: ["name","val"], │ + │ rows: [[...], [...]], │ + │ resultId: "abc123" │ + │ }, │ + │ meta: { │ + │ cached: false, │ + │ duration: 142 │ + │ } │ + │ } │ + └───────────────────────────────┘ +``` + +### 2.5 State Management Data Flow + +How data flows between stores, hooks, and components: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ COMPONENT LAYER │ +│ │ +│ DashboardContainer WidgetEditorModal ParameterWidgets │ +│ CardContainer ChartTypeSelector ParamSelect/Date/Text │ +│ ChartRenderer FieldSelector CascadingSelect │ +│ TableRenderer StylingRulesEditor │ +│ FormRenderer ActionRulesEditor │ +└───────┬──────────────────────┬──────────────────────┬───────────────┘ + │ read/write │ read/write │ read/write + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────────┐ ┌───────────────────┐ +│ dashboard- │ │ widget-editor- │ │ parameter- │ +│ store │ │ store │ │ store │ +│ │ │ │ │ │ +│ pages[] │ │ chartType │ │ values{} │ +│ activePage │ │ query │ │ visibility{} │ +│ editMode │ │ connectionId │ │ dependencies{} │ +│ version │ │ chartOptions │ │ │ +│ isDirty │ │ clickActions[] │ │ setValue() │ +│ │ │ stylingRules[] │ │ clearCascading() │ +│ addWidget() │ │ transforms[] │ │ resetAll() │ +│ updateWdgt() │ │ formFields[] │ │ │ +│ setLayout() │ │ │ └─────────┬─────────┘ +└───────┬───────┘ └─────────┬─────────┘ │ + │ │ │ + │ ┌───────────────┘ │ + │ │ (save widget config │ + │ │ back to dashboard) │ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ TANSTACK QUERY HOOKS │ +│ │ +│ useDashboard(id) useConnections() useWidgetQuery() │ +│ useUpdateDashboard() useSchema() useSeedQuery() │ +│ useCreateDashboard() useUsers() useWriteQuery() │ +│ useExportDashboard() useApiKeys() useQueryExec() │ +│ useShareDashboard() useWidgetTemplates() │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ fetch() + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ API ROUTES │ +│ /api/dashboards /api/connections /api/query /api/users │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 2.6 Monorepo Package Dependency + +Strict boundary enforcement — arrows show allowed import directions: + +``` + ┌──────────────────────────────────────────────────────┐ + │ app/ (Next.js) │ + │ │ + │ Pages · API Routes · Stores · Hooks · Plugins │ + │ Orchestrates everything. Owns business logic. │ + │ │ + └───────────┬──────────────────────┬───────────────────┘ + │ imports │ imports + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────┐ + │ component/ │ │ connection/ │ + │ │ │ │ + │ React UI library │ │ DB connector lib │ + │ shadcn/ui │ │ Neo4j driver │ + │ ECharts wrappers │ │ PostgreSQL client │ + │ DataGrid │ │ Query execution │ + │ Form widgets │ │ Schema fetching │ + │ Chart options │ │ Connection pooling │ + │ │ │ │ + │ NO business logic │ │ NO UI / NO React │ + │ NO API calls │ │ NO imports from │ + │ NO stores │ │ app/ or component/ │ + │ NO imports from │ │ │ + │ app/ or connection/│ │ │ + └──────────────────────┘ └──────────────────────┘ + ▲ ▲ + │ │ + ╳ FORBIDDEN ╳ FORBIDDEN + │ │ + └──────────────────────┘ + (cannot import each other) +``` + +### 2.7 Authentication & Tenant Isolation + +``` + ┌──────────────┐ + │ Browser │ + └──────┬───────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ Credentials │ │ NextAuth JWT │ │ API Key │ + │ (login) │ │ (session) │ │ Bearer nb_* │ + └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + ▼ ▼ ▼ + ┌──────────────────────────────────────────────────────┐ + │ proxy.ts │ + │ Extracts: userId, role, tenantId │ + └──────────────────────┬───────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ requireSession() → { userId, role, tenantId } │ + └──────────────────────┬───────────────────────────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ admin │ │ creator │ │ reader │ + │ │ │ │ │ │ + │ All CRUD │ │ Own CRUD │ │ Shared │ + │ All users│ │ Shared R │ │ read-only│ + │ Reassign │ │ Own conn │ │ No conn │ + └──────────┘ └──────────┘ └──────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ EVERY QUERY includes: WHERE tenant_id = $tenantId │ + │ │ + │ ┌──────────────┐ ┌──────────────┐ │ + │ │ Tenant: acme │ │ Tenant: glob │ Completely │ + │ │ │ │ │ isolated. │ + │ │ Users │ │ Users │ No cross- │ + │ │ Dashboards │ │ Dashboards │ tenant access │ + │ │ Connections │ │ Connections │ possible via │ + │ │ API Keys │ │ API Keys │ the API. │ + │ └──────────────┘ └──────────────┘ │ + └──────────────────────────────────────────────────────┘ +``` + +### 2.8 Widget Editor Flow + +How a widget is configured and saved: + +``` + User clicks "Edit Widget" (or "Add Widget") + │ + ▼ + ┌────────────────────────────────────────────────────────────┐ + │ widget-editor-store.open(widgetId, currentConfig) │ + └──────────────────────────┬─────────────────────────────────┘ + │ + ┌──────────────────────────▼─────────────────────────────────┐ + │ WidgetEditorModal │ + │ │ + │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐ │ + │ │ Query │ │ Chart │ │ Styling │ │ Actions │ │ + │ │ Tab │ │ Tab │ │ Tab │ │ Tab │ │ + │ └────┬────┘ └────┬────┘ └────┬────┘ └──────┬───────┘ │ + │ │ │ │ │ │ + │ ▼ ▼ ▼ ▼ │ + │ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │ + │ │SQL/Cyph │ │ChartType │ │Condition │ │Click action │ │ + │ │editor │ │Selector │ │al color │ │rules (nav, │ │ + │ │ │ │ │ │rules │ │set param) │ │ + │ │Connecti │ │Field │ │ │ │ │ │ + │ │on pick │ │Mapping │ │Threshold │ │Target page │ │ + │ │ │ │ │ │editor │ │Target param │ │ + │ │Param │ │Chart │ │ │ │ │ │ + │ │binding │ │Options │ │ │ │ │ │ + │ └────┬────┘ └────┬─────┘ └─────┬────┘ └──────┬──────┘ │ + │ │ │ │ │ │ + │ └───────────┴──────┬──────┴───────────────┘ │ + │ │ │ + │ ┌───────────────────────▼──────────────────────────────┐ │ + │ │ LIVE PREVIEW PANEL │ │ + │ │ │ │ + │ │ Executes query with P1 priority on every change │ │ + │ │ Applies transforms → renders chart preview │ │ + │ │ Shows column mapping overlay │ │ + │ └──────────────────────────────────────────────────────┘ │ + └──────────────────────────┬─────────────────────────────────┘ + │ + User clicks "Save" + │ + ▼ + ┌──────────────────────────────────────────────────────────┐ + │ 1. widget-editor-store → extract final config │ + │ 2. dashboard-store.updateWidget(widgetId, config) │ + │ 3. dashboard-store.isDirty = true │ + └──────────────────────────┬───────────────────────────────┘ + │ + User clicks "Save Dashboard" + │ + ▼ + ┌──────────────────────────────────────────────────────────┐ + │ useUpdateDashboard() │ + │ PUT /api/dashboards/[id] │ + │ body: { layoutJson, expectedVersion } │ + │ │ + │ Server: version match? ──no──▶ 409 CONFLICT │ + │ yes │ + │ │ │ + │ version + 1 │ + │ return updated dashboard │ + │ │ + │ Client: dashboard-store.isDirty = false │ + └──────────────────────────────────────────────────────────┘ +``` + +### 2.9 Parameter Cascading Flow + +``` + Dashboard Parameters: + ┌────────────────────────────────────────────────────────┐ + │ │ + │ [Country ▼] ──depends──▶ [State ▼] ──depends──▶ [City ▼] │ + │ $country $state $city │ + │ │ + └────────────────────────────────────────────────────────┘ + + User selects Country = "USA" + │ + ▼ + parameter-store.setValue("country", "USA") + │ + ├──▶ parameter-store.clearCascading("country") + │ │ + │ ├──▶ clear "state" value + │ └──▶ clear "city" value + │ + ├──▶ State widget: seed query re-executes + │ SELECT DISTINCT state FROM geo WHERE country = $country + │ │ + │ ▼ + │ Dropdown repopulates: [California, Texas, New York, ...] + │ + └──▶ All widgets with $country re-execute their queries + (P1 priority since user-initiated) + + User selects State = "California" + │ + ├──▶ parameter-store.clearCascading("state") + │ └──▶ clear "city" value + │ + ├──▶ City widget: seed query re-executes + │ SELECT DISTINCT city FROM geo WHERE state = $state + │ + └──▶ All widgets with $state or $city re-execute +``` + +--- + +## 3. Directory Structure + +``` +app/ +├── src/ +│ ├── app/ # Next.js App Router (pages, layouts, API routes) +│ │ ├── layout.tsx # Root layout (HTML shell, Providers) +│ │ ├── globals.css # Global styles +│ │ ├── (auth)/ # Public auth pages (login, signup, change-password) +│ │ ├── (dashboard)/ # Protected pages (main app shell) +│ │ │ ├── layout.tsx # Sidebar + AppShell wrapper +│ │ │ ├── page.tsx # Dashboard home +│ │ │ ├── [id]/ # View/edit individual dashboard +│ │ │ ├── dashboards/ # Dashboard listing +│ │ │ ├── connections/ # Connection management +│ │ │ ├── users/ # User management (admin) +│ │ │ ├── widget-lab/ # Widget playground +│ │ │ └── settings/ # Profile, API keys +│ │ └── api/ # API route handlers +│ │ ├── auth/ # NextAuth + bootstrap +│ │ ├── connections/ # CRUD + test + schema +│ │ ├── dashboards/ # CRUD + share + import/export +│ │ ├── query/ # Query execution (read + write) +│ │ ├── users/ # User CRUD + password management +│ │ ├── keys/ # API key management +│ │ ├── widget-templates/ # Saved chart templates +│ │ ├── features/ # Feature flag endpoint +│ │ ├── docs/ # API documentation +│ │ └── openapi*/ # OpenAPI spec (JSON + HTML) +│ ├── components/ # App-level React components +│ │ ├── card-container.tsx # Widget container (query + render) +│ │ ├── dashboard-container.tsx # Dashboard grid layout +│ │ ├── chart-renderer.tsx # Chart plugin dispatch +│ │ ├── widget-editor/ # Widget configuration modal (16 files) +│ │ ├── parameters/ # Parameter input components (8 files) +│ │ └── providers.tsx # React context providers +│ ├── hooks/ # Custom React hooks (TanStack Query wrappers) +│ ├── stores/ # Zustand state stores +│ ├── lib/ # Core business logic & utilities +│ │ ├── api/ # API response helpers +│ │ ├── auth/ # Authentication logic +│ │ ├── connector/ # Connection adapter bridge +│ │ ├── crypto/ # Hashing, rate limiting +│ │ ├── dashboard/ # Import/export, migration, conversion +│ │ ├── db/ # Drizzle schema & client +│ │ ├── extensions/ # Enterprise extension points +│ │ ├── features/ # Feature flags +│ │ ├── parameter/ # Parameter extraction & formatting +│ │ ├── plugin/ # Chart plugin registry types +│ │ ├── query/ # Query executor, scheduler, middleware +│ │ ├── shared/ # Date utils, normalization, parsing +│ │ ├── widget/ # Widget helpers (actions, forms, tables) +│ │ └── logger.ts # Pino structured logger +│ ├── plugins/ # Built-in chart type plugins (17 types) +│ ├── types/ # TypeScript type augmentations +│ ├── instrumentation.ts # Next.js cold-start bootstrap +│ └── proxy.ts # Edge middleware (auth, request ID) +├── e2e/ # Playwright E2E tests (39 spec files) +├── drizzle/ # Database migration files +├── scripts/ # Build/utility scripts +├── next.config.ts +├── vitest.config.ts +├── playwright.config.ts +└── package.json +``` + +--- + +## 4. App Router & Page Architecture + +### Route Groups + +NeoBoard uses two route groups to separate public and protected pages: + +**`(auth)/` — Public pages (no authentication required)** + +- `/login` — Email/password login form +- `/signup` — New user registration (when allowed) +- `/change-password` — Forced password change for new accounts + +**`(dashboard)/` — Protected pages (authentication required)** + +- `/` — Dashboard home (redirects to default or first dashboard) +- `/[id]` — View a specific dashboard +- `/[id]/edit` — Edit a specific dashboard (drag widgets, configure) +- `/dashboards` — List all accessible dashboards +- `/connections` — Manage database connections +- `/users` — User management (admin only) +- `/widget-lab` — Interactive widget testing environment +- `/settings` — User settings hub +- `/settings/profile` — Profile (name, email) +- `/settings/api-keys` — API key management + +### Layout Hierarchy + +``` +layout.tsx (Root) + ├── Providers (TanStack QueryClient, ThemeProvider, Toaster) + ├── (auth)/login/page.tsx — standalone + └── (dashboard)/layout.tsx — AppShell with sidebar + ├── Sidebar (navigation, connection selector) + └── Main content area + ├── page.tsx / [id]/page.tsx + ├── dashboards/page.tsx + └── ... +``` + +The root `layout.tsx` wraps everything in `` which sets up: + +- TanStack Query client (with default stale times) +- Theme context (light/dark/system) +- Toast notification system +- Session provider (Auth.js) + +The `(dashboard)/layout.tsx` adds the persistent sidebar with navigation links, connection selector, and user menu. + +--- + +## 5. Authentication & Authorization + +### Auth Stack + +| Component | File | Purpose | +| --------------- | ----------------------------- | ------------------------------------------------------------- | +| NextAuth config | `lib/auth/config.ts` | Credentials provider, DrizzleAdapter, JWT callbacks | +| Session helper | `lib/auth/session.ts` | `requireSession()` — extracts userId, role, tenantId from JWT | +| API key auth | `lib/auth/api-key.ts` | Generate/validate `nb_*` bearer tokens | +| Password rules | `lib/auth/password-schema.ts` | Zod schema for password validation | +| Bootstrap | `lib/auth/bootstrap.ts` | Create first admin from env vars | +| Signup | `lib/auth/signup.ts` | User registration flow | +| Rate limiter | `lib/crypto/rate-limiter.ts` | Token bucket (20 attempts/min/IP for login) | + +### Authentication Flow + +``` +Browser Request + │ + ▼ +proxy.ts (Edge Middleware) + ├── Public path? → pass through + ├── Has Bearer nb_* token? → validate API key → set x-user-id header + ├── Has NextAuth JWT? → decode → check force_password_change + └── None? → redirect to /login?callbackUrl=... + │ + ▼ +API Route + │ + requireSession() → extracts { userId, role, tenantId } from JWT/headers + │ + ▼ + Permission check (role-based: admin, creator, reader) +``` + +### Roles & Permissions + +| Role | Dashboards | Connections | Users | API Keys | Write Queries | +| ----------- | ---------------------- | -------------------- | --------- | -------- | -------------------------- | +| **admin** | Full CRUD + all users' | Full CRUD + reassign | Full CRUD | Own keys | Yes (if connection allows) | +| **creator** | Own CRUD + shared read | Own CRUD | View self | Own keys | Yes (if connection allows) | +| **reader** | Shared read only | None | View self | Own keys | No | + +### API Key Authentication + +API keys use a `nb_` prefix format. The key is hashed (SHA-256) before storage. Authentication flow: + +1. Client sends `Authorization: Bearer nb_xxx` +2. `proxy.ts` extracts and hashes the key +3. Looks up in `api_keys` table +4. Sets `x-user-id` and `x-tenant-id` headers for downstream routes + +--- + +## 6. API Routes + +All API routes follow consistent patterns: + +### Response Envelope + +Every API response uses a standard envelope: + +```typescript +// Success +{ data: T, meta?: { total, limit, offset } } + +// Error +{ error: { code: string, message: string, details?: unknown } } +``` + +Helpers: `apiSuccess(data)`, `apiList(data, total, limit, offset)`, `apiError(code, message, status)` + +### Route Pattern + +```typescript +// Typical API route structure +export async function GET( + req: NextRequest, + { params }: { params: { id: string } }, +) { + const session = await requireSession(); // Auth check + const { id } = await params; // Extract path params + + const result = await db.query.dashboards.findFirst({ + where: and( + eq(dashboards.id, id), + eq(dashboards.tenantId, session.tenantId), // Tenant isolation + ), + }); + + if (!result) return apiError("NOT_FOUND", "Dashboard not found", 404); + return apiSuccess(result); +} +``` + +### Complete API Surface + +#### Connections (`/api/connections`) + +| Method | Path | Description | +| ------ | -------------------------------- | ----------------------------------------- | +| GET | `/api/connections` | List connections (filtered by role) | +| POST | `/api/connections` | Create connection (credentials encrypted) | +| GET | `/api/connections/[id]` | Get connection details | +| PUT | `/api/connections/[id]` | Update connection | +| DELETE | `/api/connections/[id]` | Delete connection | +| POST | `/api/connections/[id]/test` | Test connectivity | +| GET | `/api/connections/[id]/schema` | Fetch database schema | +| GET | `/api/connections/[id]/usage` | Usage statistics | +| POST | `/api/connections/[id]/reassign` | Transfer ownership (admin) | +| POST | `/api/connections/test-inline` | Test without saving | + +#### Dashboards (`/api/dashboards`) + +| Method | Path | Description | +| ------ | -------------------------------- | --------------------------------------- | +| GET | `/api/dashboards` | List dashboards (paginated) | +| POST | `/api/dashboards` | Create dashboard | +| GET | `/api/dashboards/[id]` | Get dashboard + layout JSON | +| PUT | `/api/dashboards/[id]` | Update (optimistic locking via version) | +| DELETE | `/api/dashboards/[id]` | Delete dashboard | +| POST | `/api/dashboards/[id]/share` | Manage sharing/permissions | +| GET | `/api/dashboards/[id]/export` | Export as JSON | +| POST | `/api/dashboards/import` | Import from JSON | +| POST | `/api/dashboards/[id]/duplicate` | Clone dashboard | + +#### Queries (`/api/query`) + +| Method | Path | Description | +| ------ | ------------------ | ------------------------------------------ | +| POST | `/api/query` | Execute read query (SELECT) | +| POST | `/api/query/write` | Execute write query (INSERT/UPDATE/DELETE) | + +#### Users (`/api/users`) + +| Method | Path | Description | +| -------------- | -------------------------------- | -------------------- | +| GET | `/api/users` | List users (admin) | +| POST | `/api/users` | Create user (admin) | +| GET/PUT/DELETE | `/api/users/[id]` | CRUD individual user | +| POST | `/api/users/[id]/reset-password` | Force password reset | +| GET | `/api/users/me` | Current user info | +| PUT | `/api/users/me/password` | Change own password | + +#### Other + +| Method | Path | Description | +| --------------- | ---------------------------- | --------------------- | +| GET/POST/DELETE | `/api/keys[/id]` | API key management | +| CRUD | `/api/widget-templates[/id]` | Saved chart templates | +| GET | `/api/features` | Feature flags | +| GET | `/api/docs` | API documentation | +| GET | `/api/openapi.json` | OpenAPI spec | + +--- + +## 7. Database Layer (Drizzle ORM) + +### Schema (`lib/db/schema.ts`) + +The database uses PostgreSQL with Drizzle ORM. Core tables: + +| Table | Purpose | Key Columns | +| ------------------ | ------------------------- | ------------------------------------------------------------------------------ | +| `users` | User accounts | id, email, name, role, tenantId, forcePasswordChange | +| `dashboards` | Dashboard metadata | id, title, description, layoutJson, version, ownerId, tenantId | +| `connections` | Database connections | id, name, type (neo4j/pg), host, port, encryptedCredentials, ownerId, tenantId | +| `dashboard_shares` | Sharing permissions | dashboardId, userId, permission (view/edit) | +| `api_keys` | API access tokens | id, hashedKey, userId, tenantId, expiresAt | +| `widget_templates` | Saved chart configs | id, name, chartType, config, ownerId | +| `query_audit_log` | Query execution history | id, userId, connectionId, query, duration, rowCount, timestamp | +| `accounts` | OAuth accounts (NextAuth) | Standard NextAuth adapter table | +| `sessions` | Active sessions | Standard NextAuth adapter table | + +### Key Design Decisions + +- **`tenantId`** on every table — enables multi-tenancy +- **`layoutJson`** (JSONB) stores the entire dashboard layout — widgets, grid positions, pages, parameters — as a single JSON document rather than normalized tables +- **`version`** column on dashboards enables optimistic locking for concurrent editing +- **Credentials** are encrypted at rest using AES-256-GCM envelope encryption (HKDF-SHA256 key derivation from `ENCRYPTION_KEY` env var) +- **Migrations** are forward-only, idempotent, and use advisory locks to prevent concurrent execution + +### Connection Initialization + +```typescript +// lib/db/index.ts +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +const client = postgres(process.env.DATABASE_URL!); +export const db = drizzle(client, { schema }); +``` + +--- + +## 8. Query Execution Pipeline + +The query execution system is the most complex subsystem. It handles user-submitted SQL/Cypher queries with safety, scheduling, caching, and audit logging. + +### Pipeline Architecture + +``` +Frontend (use-widget-query hook) + │ + │ POST /api/query + ▼ +API Route (src/app/api/query/route.ts) + │ + ├── requireSession() → Auth check + ├── validateBody(schema) → Zod validation + ├── resolveConnection() → Decrypt credentials + │ + ▼ +Query Middleware Pipeline (lib/query/pipeline.ts) + │ + ├── Scheduler Middleware → Priority queue, concurrency, fairness + │ ├── P1: Interactive (user click) + │ ├── P2: Page load (initial render) + │ └── P3: Auto-refresh (background) + │ + ├── Audit Middleware → Log start, query text, user + │ + ├── Core Executor → lib/query/query-executor.ts + │ ├── Parameter substitution → Replace $param_name with values + │ ├── Connection adapter → Bridge to connection/ package + │ └── Driver execution → Neo4j driver or PostgreSQL client + │ ├── Read-only mode → BEGIN READ ONLY (pg) / read access mode (neo4j) + │ ├── Timeout → AbortSignal (pg) / native (neo4j), default 30s + │ └── Row limit → MAX_ROWS+1 cursor pattern + │ + ├── Audit Middleware → Log duration, row count, success/failure + │ + ▼ +API Response + │ + ├── { data: { columns, rows, resultId }, meta: { cached, duration } } + │ + ▼ +Frontend (card-container.tsx) + │ + ├── Data Transforms → Group, aggregate, pivot (lib/query/data-transforms.ts) + ├── Chart Plugin → Render via ECharts / custom component + └── Cache → TanStack Query cache with configurable TTL +``` + +### Scheduler (`lib/query/scheduler.ts`) + +The scheduler implements a priority queue with per-connector concurrency control and backpressure. + +**Architecture:** + +- **One scheduler per connector** — `scheduler-registry.ts` lazily creates a scheduler instance per connectionId +- **Priority tiers** — P1 (interactive/user click) > P2 (page load) > P3 (auto-refresh). Higher priority always dequeues first. +- **Per-user fairness** — within the same priority level, round-robin across users prevents one user from starving others +- **Load shedding** — when queue fill ratio exceeds `shedThreshold`, P3 (refresh) queries are rejected immediately to protect interactive queries + +**Configuration (environment variables):** + +| Variable | Default | Purpose | +| ------------------------ | ------- | ------------------------------------ | +| `QUERY_MAX_CONCURRENT` | 10 | Max in-flight queries per scheduler | +| `QUERY_MAX_PER_USER` | 5 | Max in-flight per user per scheduler | +| `QUERY_MAX_QUEUE_DEPTH` | 200 | Queue capacity before rejection | +| `QUERY_QUEUE_TIMEOUT_MS` | 15000 | Max wait time in queue (ms) | +| `QUERY_SHED_THRESHOLD` | 0.8 | Fill ratio that triggers P3 shedding | + +**Error types:** + +| Error | Trigger | HTTP | +| ---------------------------------- | -------------------------------------------------- | ---- | +| `QueueRejectedError("queue_full")` | Queue depth >= `maxQueueDepth` | 503 | +| `QueueRejectedError("shed")` | P3 request when depth >= threshold x maxQueueDepth | 503 | +| `QueueTimeoutError` | Waiter exceeds `queueTimeoutMs` | 408 | + +**Metrics (`scheduler-metrics.ts`):** + +- Emitted every 30 seconds for non-idle schedulers +- Tracks: queue depth (total + per-priority), active queries, active by user, rejection count, shed count, average wait time +- Log level escalates: info → warn (shed > 0 or fill ratio >= threshold) → error (rejections > 0) + +**Stats interface:** + +```typescript +interface SchedulerStats { + queueDepth: number; + queueDepthByPriority: { p1: number; p2: number; p3: number }; + activeQueries: number; + activeByUser: Record; + rejectionsTotal: number; + shedTotal: number; + avgWaitMs: number; +} +``` + +### Query Safety Rules + +These are **inviolable** constraints enforced at multiple levels: + +1. **Never modify user queries** — no LIMIT injection, no query rewriting (except `wrapWithPreviewLimit` for editor preview) +2. **Always parameterized** — user input never interpolated into query strings +3. **Read-only by default** — `BEGIN READ ONLY` (pg) or read access mode (neo4j) +4. **Write requires `can_write`** — enforced server-side, not just UI +5. **Row limits** — cursor-based consumption with MAX_ROWS+1 pattern +6. **Timeouts** — driver-level enforcement (AbortSignal for pg, native for neo4j) +7. **Concurrency** — per-connector p-queue prevents connection exhaustion + +--- + +## 9. State Management (Zustand) + +Six Zustand stores manage client-side state: + +### Dashboard Store (`stores/dashboard-store.ts`) + +The central store for dashboard state: + +```typescript +interface DashboardState { + // Layout + pages: Page[]; // Array of dashboard pages + activePage: number; // Current page index + editMode: boolean; // Whether in edit mode + + // Metadata + dashboardId: string | null; + title: string; + version: number; // Optimistic locking version + + // Dirty tracking + isDirty: boolean; // Unsaved changes exist + + // Actions + addWidget(widget): void; + removeWidget(widgetId): void; + updateWidget(widgetId, changes): void; + duplicateWidget(widgetId): void; + addPage(): void; + removePage(index): void; + renamePage(index, name): void; + reorderPages(from, to): void; + setLayout(page, layouts): void; // Grid position updates + loadDashboard(data): void; // Hydrate from API + reset(): void; +} +``` + +### Widget Editor Store (`stores/widget-editor-store.ts`) + +Manages the state of the widget configuration modal: + +```typescript +interface WidgetEditorState { + isOpen: boolean; + widgetId: string | null; // null = creating new + chartType: string; + query: string; + connectionId: string; + chartOptions: Record; + clickActions: ClickAction[]; + stylingRules: StylingRule[]; + transforms: Transform[]; + formFields: FormField[]; // For form widgets + parameterConfig: ParameterConfig; + // ... actions for each field +} +``` + +### Parameter Store (`stores/parameter-store.ts`) + +Manages dashboard-wide parameter values: + +```typescript +interface ParameterState { + values: Record; // Current values + visibility: Record; // Show/hide toggles + dependencies: Record; // Cascading dependencies + + setValue(name, value): void; + clearValue(name): void; + clearCascading(name): void; // Clear dependent params + resetAll(): void; + loadDefaults(params): void; +} +``` + +### Other Stores + +| Store | File | Purpose | +| ------------------ | ----------------------- | --------------------------------------------------------------- | +| Connection Store | `connection-store.ts` | Selected connection, connection list cache | +| Schema Store | `schema-store.ts` | Database schema cache per connection | +| Graph Widget Store | `graph-widget-store.ts` | Neo4j graph visualization state (node/edge selection, viewport) | + +### Store Design Patterns + +- All stores use Zustand v5 with the vanilla API +- Stores are client-side only (`"use client"` directive) +- Dirty tracking via shallow comparison of initial vs current state +- No persistence middleware — state is transient (loaded from API on each page visit) +- Stores never call APIs directly — that's the hooks' job + +--- + +## 10. Data Fetching (TanStack Query Hooks) + +14 custom hooks wrap TanStack Query for all API communication. This layer sits between components and API routes. + +### Hook Categories + +#### CRUD Hooks (return query + mutation objects) + +| Hook | File | API | +| ----------------------- | ------------------------- | ------------------------------------- | +| `useDashboards` | `use-dashboards.ts` | `/api/dashboards` | +| `useDashboard(id)` | `use-dashboards.ts` | `/api/dashboards/[id]` | +| `useCreateDashboard` | `use-dashboards.ts` | `POST /api/dashboards` | +| `useUpdateDashboard` | `use-dashboards.ts` | `PUT /api/dashboards/[id]` | +| `useDeleteDashboard` | `use-dashboards.ts` | `DELETE /api/dashboards/[id]` | +| `useDuplicateDashboard` | `use-dashboards.ts` | `POST /api/dashboards/[id]/duplicate` | +| `useExportDashboard` | `use-dashboards.ts` | `GET /api/dashboards/[id]/export` | +| `useImportDashboard` | `use-dashboards.ts` | `POST /api/dashboards/import` | +| `useShareDashboard` | `use-dashboards.ts` | `POST /api/dashboards/[id]/share` | +| `useConnections` | `use-connections.ts` | `/api/connections` | +| `useUsers` | `use-users.ts` | `/api/users` | +| `useApiKeys` | `use-api-keys.ts` | `/api/keys` | +| `useWidgetTemplates` | `use-widget-templates.ts` | `/api/widget-templates` | + +#### Query Execution Hooks + +| Hook | File | Purpose | +| ------------------------ | ------------------------------ | ----------------------------------------------------------------- | +| `useWidgetQuery` | `use-widget-query.ts` | Execute widget query with parameter resolution, caching, priority | +| `useQueryExecution` | `use-query-execution.ts` | Low-level query executor (calls `/api/query`) | +| `useWriteQueryExecution` | `use-write-query-execution.ts` | Execute write queries (`/api/query/write`) | + +#### UI Hooks + +| Hook | File | Purpose | +| -------------------------- | -------------------------------- | ------------------------------------------- | +| `useTheme` | `use-theme.ts` | Light/dark/system theme preference | +| `useClickAction` | `use-click-action.ts` | Widget click → navigate or set parameter | +| `useCountdown` | `use-countdown.ts` | Auto-refresh countdown timer | +| `useUnsavedChangesWarning` | `use-unsaved-changes-warning.ts` | Browser warning on unsaved edits | +| `useSchema` | `use-schema.ts` | Fetch database schema for connection | +| `useSeedQuery` | `use-seed-query.ts` | Execute query to populate parameter options | + +### Hook Pattern + +```typescript +// Typical CRUD hook pattern +export function useDashboards(limit = 20, offset = 0) { + return useQuery({ + queryKey: ["dashboards", limit, offset], + queryFn: () => + fetch(`/api/dashboards?limit=${limit}&offset=${offset}`) + .then((r) => r.json()) + .then((envelope) => envelope.data), + }); +} + +export function useCreateDashboard() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body) => + fetch("/api/dashboards", { method: "POST", body: JSON.stringify(body) }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ["dashboards"] }), + }); +} +``` + +--- + +## 11. Component Architecture + +### Core Rendering Chain + +The widget rendering pipeline flows through three key components: + +``` +DashboardContainer + │ + ├── PageTabs (multi-page navigation) + │ + └── Grid (react-grid-layout) + │ + └── CardContainer (per widget) + │ + ├── useWidgetQuery() → Execute query + ├── Data Transforms → Reshape results + │ + ├── ChartRenderer → ECharts-based charts + │ └── Plugin.component → bar, line, pie, etc. + │ + ├── TableRenderer → Data grid + ├── FormWidgetRenderer → Form inputs + ├── GraphExplorationWrapper → Neo4j graph (NVL) + └── ParameterWidgetRenderer → Parameter inputs +``` + +### CardContainer (`components/card-container.tsx`) + +The most important component — it's the container for every widget on a dashboard: + +- Manages query execution lifecycle (loading, error, data states) +- Applies data transforms (group, aggregate, pivot) +- Handles caching configuration +- Dispatches to the correct renderer based on chart type +- Shows error states, empty states, "incompatible data" warnings +- Manages auto-refresh countdown +- Handles click actions (navigate, set parameter) + +### Widget Editor (`components/widget-editor/`) + +A complex modal with 16 sub-components for configuring widgets: + +| File | Purpose | +| ------------------------------ | ----------------------------------------------------------- | +| `widget-editor-modal.tsx` | Main modal shell with tabs (Query, Chart, Styling, Actions) | +| `query-editor-panel.tsx` | SQL/Cypher editor with syntax highlighting | +| `chart-type-selector.tsx` | Visual chart type picker grid | +| `field-selector-input.tsx` | Map query columns to chart axes | +| `widget-preview-panel.tsx` | Live preview of chart with current config | +| `action-rules-editor.tsx` | Configure click actions (navigate, set param) | +| `styling-rules-editor.tsx` | Conditional color/format rules | +| `transform-editor.tsx` | Data transform pipeline (group by, aggregate) | +| `form-fields-editor.tsx` | Form widget field definitions | +| `parameter-config-section.tsx` | Parameter binding configuration | +| `parameter-preview.tsx` | Preview parameter values | +| `template-browser.tsx` | Browse and apply saved templates | +| `value-or-param-input.tsx` | Toggle between literal value and parameter reference | +| `column-mapping-overlay.tsx` | Visual column-to-axis mapping | +| `use-accordion-crud.ts` | Reusable hook for accordion-based CRUD lists | + +### Parameter Components (`components/parameters/`) + +Eight parameter input types, each with its own component: + +| Component | Type | Description | +| ---------------------------- | ---------------- | ------------------------------ | +| `param-text.tsx` | text | Free-text input | +| `param-number-range.tsx` | number_range | Slider with min/max | +| `param-date.tsx` | date | Date picker | +| `param-date-range.tsx` | date_range | Start + end date | +| `param-date-relative.tsx` | date_relative | Relative ("last 7 days") | +| `param-select.tsx` | select | Single-select dropdown | +| `param-multi-select.tsx` | multi_select | Multi-select dropdown | +| `param-cascading-select.tsx` | cascading_select | Parent-child dependent selects | + +Supporting hooks: + +- `use-param-actions.ts` — parameter mutation actions +- `use-cascading-clear.ts` — clear dependent parameters when parent changes +- `use-seed-query-options.ts` — load options from a database query + +--- + +## 12. Chart Plugin System + +### Plugin Registry (`plugins/registry.ts`) + +Chart types are registered as plugins at application startup. Each plugin implements a standard interface: + +```typescript +interface ChartPluginConfig { + type: string; // Unique identifier (e.g., "bar") + label: string; // Display name in chart picker + component: React.ComponentType; // React component (dynamic import) + transform: (data: unknown) => unknown; // Raw rows → chart-ready shape + transformWithMapping?: (data, mapping) => unknown; // With user column overrides + validate?: (data: unknown) => string | null; // Error string or null + options?: ChartOptionDef[]; // Chart Options panel fields + queryHint?: string; // Example + column expectations + compatibleWith?: ConnectorType[]; // Allowed connector types + stylingTargets?: { value; label }[]; // Rule-based styling columns + settingsSchema?: z.ZodType; // Typed settings validation (Zod) + enrichClickEvent?: (event, row) => event; // Attach chart-specific click fields + capabilities?: Partial<{ + supportsClickAction: boolean; // Default: true + supportsStyling: boolean; // Default: false (true if stylingTargets provided) + isECharts: boolean; // Default: false (for screenshot capture) + requiresQuery: boolean; // Default: true (false for markdown, form) + }>; +} +``` + +### Built-in Plugins (17 chart types) + +The `plugins/` directory contains 17 chart type plugins plus 2 shared utility directories: + +| Plugin | Directory | Renderer | +| ---------------- | --------------------------- | --------------------- | +| Bar | `plugins/bar/` | ECharts | +| Line | `plugins/line/` | ECharts | +| Pie | `plugins/pie/` | ECharts | +| Single Value | `plugins/single-value/` | Custom | +| Table | `plugins/table/` | DataGrid (component/) | +| Graph | `plugins/graph/` | Neo4j NVL | +| Map | `plugins/map/` | Leaflet | +| Form | `plugins/form/` | Custom form inputs | +| Markdown | `plugins/markdown/` | Markdown renderer | +| JSON | `plugins/json/` | JSON tree viewer | +| Gauge | `plugins/gauge/` | ECharts | +| Sankey | `plugins/sankey/` | ECharts | +| Sunburst | `plugins/sunburst/` | ECharts | +| Radar | `plugins/radar/` | ECharts | +| Treemap | `plugins/treemap/` | ECharts | +| Parameter Select | `plugins/parameter-select/` | Custom select | +| iFrame | `plugins/iframe/` | HTML iframe | + +**Shared utility directories (not chart types):** + +- `plugins/transforms/` — Data transform functions per chart type (e.g., `transformToBarData`, `transformToPieData`, `transformToHierarchicalData`). Also includes `shared-utils.ts` (record normalization, column auto-detection) and `hierarchical-utils.ts` (tree-building for sunburst/treemap). +- `plugins/settings/` — Per-plugin Zod settings schemas (e.g., `barSettingsSchema` with orientation, stacked, showValues, colorPalette, etc.) + +### Per-Plugin Settings Schema + +Each chart type defines a Zod schema for its configurable options: + +```typescript +// Example: plugins/bar/settings.ts +export const barSettingsSchema = z + .object({ + orientation: z.enum(["vertical", "horizontal"]).default("vertical"), + stacked: z.boolean().default(false), + showValues: z.boolean().default(false), + showLegend: z.boolean().default(true), + barWidth: z.coerce.number().default(0), + barGap: z.string().default("30%"), + xAxisLabel: z.string().optional(), + yAxisLabel: z.string().optional(), + showGridLines: z.boolean().default(true), + axisLabelRotation: z.coerce.number().default(-1), + referenceLines: z.string().optional(), + enableDataZoom: z.boolean().default(false), + colorPalette: z.string().optional(), + colorblindMode: z.boolean().default(false), + }) + .passthrough(); +``` + +### External Plugin Loading + +NeoBoard supports loading third-party chart plugins at build time via a manifest file: + +1. A `neoboard-plugins.json` manifest declares external plugins +2. `node scripts/generate-plugin-imports.mjs` generates `external-plugins.generated.ts` +3. At startup, `plugins/index.ts` registers built-in plugins first, then external plugins + +```json +// neoboard-plugins.json +{ + "plugins": [ + { + "type": "custom-chart", + "label": "Custom Chart", + "module": "@myorg/neoboard-custom-plugin", + "overrides": false + } + ] +} +``` + +**Conflict resolution:** + +- External plugins without `overrides: true` throw an error if they duplicate a built-in type +- External plugins with `overrides: true` replace the built-in plugin entirely +- After registration, a validation pass ensures every declared chart type has a registered plugin + +### ECharts Import Pattern + +All ECharts-based plugins use modular imports to minimize bundle size: + +```typescript +// Correct: modular imports +import { BarChart } from "echarts/charts"; +import { GridComponent, TooltipComponent } from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; +import { use } from "echarts/core"; + +use([BarChart, GridComponent, TooltipComponent, CanvasRenderer]); + +// NEVER: full import +// import * as echarts from "echarts"; ← banned +``` + +### Dynamic Loading + +All chart components use `next/dynamic` with `ssr: false` to prevent server-side rendering: + +```typescript +const BarChart = dynamic(() => import("./bar/component"), { ssr: false }); +``` + +Heavy dependencies (NVL for graphs, Leaflet for maps) are only loaded when a widget of that type appears on the current dashboard. + +--- + +## 13. Parameter System + +Parameters allow dashboard users to filter data dynamically. They flow through the system as follows: + +### Parameter Lifecycle + +``` +1. Definition (widget editor) + └── Parameter widget configured with name, type, default value, seed query + +2. Storage (dashboard layoutJson) + └── Parameters stored as part of dashboard layout JSON + +3. Runtime (parameter-store.ts) + └── Current values managed in Zustand store + +4. Substitution (query execution) + └── $param_name in SQL replaced with current value before execution + +5. Cascading (dependencies) + └── Changing parent param clears dependent child params +``` + +### Parameter Types + +| Type | Input | Value Format | +| ------------------ | ------------------- | ---------------------------------------- | +| `text` | Free text | `string` | +| `number_range` | Slider | `{ min: number, max: number }` | +| `date` | Date picker | `YYYY-MM-DD` | +| `date_range` | Two date pickers | `{ start: string, end: string }` | +| `date_relative` | Dropdown | Resolved to absolute dates at query time | +| `select` | Dropdown (single) | `string` | +| `multi_select` | Dropdown (multi) | `string[]` | +| `cascading_select` | Dependent dropdowns | `string` (each level) | + +### Seed Queries + +Select and multi-select parameters can load their options from a database query: + +```sql +-- Seed query for a "department" parameter +SELECT DISTINCT department FROM employees ORDER BY department +``` + +The seed query is executed once when the dashboard loads, and results populate the dropdown options. + +### Parameter Extraction (`lib/parameter/collect-parameter-names.ts`) + +Scans query text for `$param_name` patterns and returns a list of required parameters. Used by the widget editor to show which parameters a query depends on. + +--- + +## 14. Multi-Tenancy + +NeoBoard supports multi-tenant deployment where a single instance serves multiple organizations: + +### Implementation + +- Every major table has a `tenant_id` column +- `requireSession()` extracts `tenantId` from the JWT +- Every database query includes a tenant filter: `WHERE tenant_id = $tenantId` +- JWT tokens include a `tenantId` claim, validated on every request +- Default tenant: `"default"` (for single-tenant deployments) + +### Isolation Guarantees + +- Users can only see dashboards, connections, and data within their tenant +- Admin role is scoped to the tenant (a tenant admin cannot see other tenants) +- API keys are scoped to the tenant +- Cross-tenant access is impossible through the API + +### SaaS vs On-Prem + +The distinction is handled purely through environment variables, never through code branches: + +``` +MULTI_TENANT=true → Enable tenant isolation +MULTI_TENANT=false → Single tenant mode (default) +``` + +--- + +## 15. Middleware & Instrumentation + +### Edge Middleware (`src/proxy.ts`) + +Runs on every request at the edge (before hitting the Node.js server): + +1. **Request ID** — Generates or propagates `x-request-id` header +2. **Public paths** — Allows `/login`, `/signup`, `/api/auth/*`, `/api/openapi` without auth +3. **API key auth** — Validates `Bearer nb_*` tokens for API routes +4. **JWT auth** — Validates NextAuth JWT for page routes +5. **Force password change** — Redirects new users to `/change-password` +6. **Unauthenticated** — Redirects to `/login?callbackUrl=...` + +### Instrumentation (`src/instrumentation.ts`) + +Next.js cold-start hook that runs once per server startup: + +1. **Register query middleware** — Audit and scheduler middleware +2. **Start scheduler metrics** — Periodic queue depth reporting +3. **Bootstrap admin** — Create first admin user from `BOOTSTRAP_ADMIN_EMAIL` + `BOOTSTRAP_ADMIN_PASSWORD` env vars if no users exist + +--- + +## 16. Dashboard Import/Export & Migration + +### Export (`lib/dashboard/dashboard-export.ts`) + +Exports a dashboard as a self-contained JSON file: + +```json +{ + "version": 2, + "exportedAt": "2026-04-23T...", + "dashboard": { + "title": "Sales Dashboard", + "description": "...", + "layout": { + /* pages, widgets, grid positions */ + }, + "settings": { + /* auto-refresh, theme */ + } + } +} +``` + +### Import (`lib/dashboard/dashboard-import.ts`) + +Validates and imports a JSON dashboard: + +- Schema validation (Zod) +- Version migration if needed +- Connection remapping (imported dashboards reference connections by name, not ID) +- Conflict resolution (skip, overwrite, rename) + +### NeoDash Converter (`lib/dashboard/neodash-converter.ts`) + +Converts NeoDash v2 dashboard format to NeoBoard format, enabling migration from the legacy tool. + +### Layout Migration (`lib/dashboard/migrate-layout.ts`) + +Handles forward migration of layout JSON when the schema evolves (v1 → v2, etc.). + +--- + +## 17. Logging & Observability + +### Structured Logging (`lib/logger.ts`) + +Pino-based structured JSON logging with four dedicated logger instances: + +```typescript +import { logger, queryLogger, authLogger, apiLogger } from "@/lib/logger"; + +logger.info({ dashboardId, userId }, "Dashboard loaded"); +queryLogger.info({ connectionId, duration, rowCount }, "Query executed"); +authLogger.warn({ email, ip }, "Login failed"); +apiLogger.info({ method, path, status, duration }, "API request"); +``` + +### Log Categories + +| Logger | Purpose | Key Fields | +| ------------- | ------------------------------ | --------------------------------------- | +| `logger` | General application logs | requestId, userId | +| `queryLogger` | Query execution audit trail | connectionId, query, duration, rowCount | +| `authLogger` | Authentication events | email, ip, action | +| `apiLogger` | API request/response lifecycle | method, path, status, duration | + +### Configuration (environment variables) + +| Variable | Options | Default | Purpose | +| ---------------------- | ------------------------ | ---------------------------- | ---------------------------------- | +| `LOG_LEVEL` | error, warn, info, debug | info | Pino log level | +| `LOG_FORMAT` | json, pretty | json | Output format | +| `LOG_OUTPUT` | stdout, file, both | stdout | Destination | +| `LOG_FILE_PATH` | path | `./logs/neoboard.log` | File location | +| `LOG_MAX_SIZE` | size string | 50M | Rotation threshold | +| `LOG_MAX_FILES` | number | 7 | Retained rotated files | +| `LOG_ANONYMIZE` | true, false | false | Enable PII anonymization | +| `LOG_ANONYMIZE_SECRET` | string | `neoboard-log-anonymizer-v1` | HMAC key for deterministic hashing | + +### Transport Options (`lib/logger-transports.ts`) + +- **Stdout JSON** (default) — synchronous write to fd 1, no transport overhead +- **Stdout pretty** — `pino-pretty` with colorize, HH:MM:ss.l timestamps, strips pid/hostname +- **File output** — `pino-roll` with rotation by size and file count +- **Stdout + File** — multi-target using Pino transport API + +### Built-in Field Redaction + +Sensitive fields are automatically censored before logging: + +```typescript +redact: { + paths: [ + "password", "passwordHash", "*.password", "*.passwordHash", + "credentials", "*.credentials", + "token", "*.token", + "authorization", "*.authorization" + ], + censor: "[REDACTED]" +} +``` + +### PII Anonymization (`lib/log-anonymizer.ts`) + +When `LOG_ANONYMIZE=true`, a Pino `hooks.logMethod` intercept runs on every log call: + +| Field Pattern | Action | Example | +| ------------------------- | ------------------------------------------ | ----------------------------- | +| userId, user_id, email | HMAC-SHA256 hash → `sha256:<16 hex chars>` | `sha256:a1b2c3d4e5f67890` | +| params, password, token | Full redaction → `[REDACTED]` | — | +| uri, connectionUri, dbUri | Credential-stripped URL | `postgres://***@host:5432/db` | + +Hashing is deterministic (keyed HMAC) so the same userId always produces the same hash across the deployment, enabling correlation in anonymized logs without exposing PII. + +### Query Audit Trail (`lib/query/middleware/audit.ts`) + +Every query execution is logged with: + +- Who (userId, tenantId) +- What (query text, parameters) +- Where (connectionId, connection type) +- When (timestamp) +- How long (duration in ms) +- How much (row count) +- Whether it succeeded or failed + +--- + +## 18. Security Model + +### Credential Encryption + +``` +User provides credentials (password, connection string) + │ + ▼ +AES-256-GCM encryption with envelope scheme + ├── Master key: ENCRYPTION_KEY env var + ├── Key derivation: HKDF-SHA256 per credential + ├── Unique IV per encryption + └── Auth tag for integrity + │ + ▼ +Stored encrypted in PostgreSQL (encryptedCredentials column) +``` + +**Critical:** Lost `ENCRYPTION_KEY` = all credentials unrecoverable. + +### Query Safety (enforced at multiple layers) + +| Layer | Mechanism | +| ------------------ | ---------------------------------------------------------------------- | +| SQL injection | Parameterized queries only. Never interpolate user input. | +| Read-only | `BEGIN READ ONLY` (pg) / read access mode (neo4j) for non-Form widgets | +| Write permission | `can_write` flag checked server-side in API route | +| Row limits | Cursor/stream with MAX_ROWS+1 pattern | +| Timeouts | Driver-level (AbortSignal for pg, native for neo4j). Default 30s. | +| Concurrency | Per-connector p-queue. Prevents connection exhaustion. | +| Query modification | NEVER modify or wrap user queries (safety enforced at driver level) | + +### Rate Limiting + +Token bucket algorithm (`lib/crypto/rate-limiter.ts`): + +- Login: 20 attempts per minute per IP +- API: configurable per-key limits + +### Password Security + +- Hashed with bcryptjs (cost factor 12) +- Password validation via Zod schema (minimum length, complexity) +- Force password change flag for admin-created accounts + +--- + +## 19. Extension System + +### Architecture (`lib/extensions/`) + +NeoBoard supports runtime extensions for enterprise features: + +```typescript +interface Extension { + name: string; + version: string; + hooks: { + onQueryExecute?: (ctx) => void; // Before query runs + onQueryComplete?: (ctx) => void; // After query completes + onDashboardSave?: (ctx) => void; // Before dashboard save + onUserLogin?: (ctx) => void; // After login + // ... more hooks + }; +} +``` + +### Feature Flags (`lib/features/`) + +Enterprise features gated by environment variables: + +```typescript +// lib/features/registry.ts +const features = { + SSO: env("FEATURE_SSO"), + CUSTOM_ROLES: env("FEATURE_CUSTOM_ROLES"), + CONNECTOR_LABELS: env("FEATURE_CONNECTOR_LABELS"), + BULK_IMPORT: env("FEATURE_BULK_IMPORT"), + DASHBOARD_SHARING_LINKS: env("FEATURE_SHARING_LINKS"), + QUERY_RESULT_CACHING: env("FEATURE_QUERY_CACHE"), + // ... +}; +``` + +Route protection: + +```typescript +// In an API route +requireFeature("CUSTOM_ROLES"); // 403 if not enabled +``` + +--- + +## 20. Testing Strategy + +### Test Pyramid + +``` + ┌─────────┐ + │ E2E │ Playwright (39 spec files) + │ (slow) │ Real browser, real DB, full flows + ┌┴─────────┴┐ + │ Component │ Vitest + jsdom (.test.tsx) + │ (medium) │ React Testing Library + ┌┴───────────┴┐ + │ Unit │ Vitest + Node (.test.ts) + │ (fast) │ Pure functions, stores, API routes + └─────────────┘ +``` + +### Vitest Configuration (`vitest.config.ts`) + +Two project environments: + +| Project | Environment | File Pattern | Use Case | +| ----------- | ----------- | ------------ | ------------------------------------------ | +| `unit` | Node | `.test.ts` | Pure logic, API routes, stores, hooks | +| `component` | jsdom | `.test.tsx` | React component rendering, branch coverage | + +### Test Conventions + +- Tests live in `__tests__/` next to the file under test +- Every behavior, bug fix, and edge case gets a test +- TDD workflow: Red → Green → Refactor (mandatory) +- Coverage target: 80% per package + +### What Gets Tested Where + +| Layer | Tool | Examples | +| ---------------- | ----------------------- | ------------------------------------------------------- | +| Pure functions | Vitest (Node) | chart-registry, normalize-value, date-utils, query-hash | +| API routes | Vitest (mocked DB/auth) | Validation, permissions, error codes | +| Zustand stores | Vitest (Node) | State transitions, cascading logic | +| React components | Vitest (jsdom) | Render, branches, error states | +| Full user flows | Playwright E2E | Login → create dashboard → add widget → verify data | + +--- + +## 21. E2E Test Suite + +### Spec Files (39 tests) + +| Spec File | Coverage Area | +| ------------------------------- | --------------------------------------- | +| `auth.spec.ts` | Login, signup, logout flows | +| `auth-states.spec.ts` | Force password change, session expiry | +| `dashboards.spec.ts` | Dashboard CRUD operations | +| `dashboard-metadata.spec.ts` | Title, description, settings | +| `dashboard-states.spec.ts` | Empty, loading, error states | +| `dashboard-portability.spec.ts` | Import/export JSON | +| `dashboard-visibility.spec.ts` | Sharing, permissions | +| `connections.spec.ts` | Connection CRUD | +| `connection-advanced.spec.ts` | Schema fetch, test connection | +| `widgets.spec.ts` | Widget CRUD within dashboard | +| `widget-states.spec.ts` | Widget loading, error, empty states | +| `widget-lab.spec.ts` | Widget playground | +| `charts.spec.ts` | Chart rendering (bar, line, pie, etc.) | +| `new-charts.spec.ts` | Newer chart types (gauge, sankey, etc.) | +| `parameters.spec.ts` | Parameter widgets, binding | +| `parameter-types.spec.ts` | All parameter input types | +| `form-widget.spec.ts` | Form widget with write queries | +| `grid.spec.ts` | Dashboard grid layout | +| `navigation.spec.ts` | Page navigation, routing | +| `sidebar-states.spec.ts` | Sidebar collapse, expand | +| `theme.spec.ts` | Light/dark mode | +| `responsive.spec.ts` | Mobile/tablet breakpoints | +| `users.spec.ts` | User management | +| `settings-profile.spec.ts` | Profile settings | +| `api-keys.spec.ts` | API key management | +| `api-docs.spec.ts` | API documentation page | +| `query-safety.spec.ts` | SQL injection prevention | +| `write-permissions.spec.ts` | Write query permission enforcement | +| `sharing-permissions.spec.ts` | Dashboard sharing | +| `transforms.spec.ts` | Data transforms | +| `styling-rules.spec.ts` | Conditional formatting | +| `auto-refresh.spec.ts` | Auto-refresh widget data | +| `code-completion.spec.ts` | Query editor autocomplete | +| `content-widgets.spec.ts` | Markdown, JSON, iframe widgets | +| `heavy-widgets.spec.ts` | Graph, map widgets | +| `empty-states.spec.ts` | Empty state illustrations | +| `design-system.spec.ts` | Design system compliance | +| `performance.spec.ts` | Load time benchmarks | +| `import-validation.spec.ts` | Import validation edge cases | + +### Test Infrastructure + +- **Global setup** (`e2e/global-setup.ts`): Starts Docker containers (PostgreSQL, Neo4j), seeds test data +- **Global teardown** (`e2e/global-teardown.ts`): Stops containers +- **Fixtures** (`e2e/fixtures.ts`): Authenticated page, test user, test connection +- **Pages** (`e2e/pages/`): Page Object Model for reusable interactions + +--- + +## 22. Configuration Files + +### `next.config.ts` + +Key settings: + +- **Output:** `standalone` (Docker-optimized) +- **Transpilation:** `@neoboard/components`, `@neoboard/connection` +- **Server externals:** `postgres`, `pg`, `neo4j-driver` (not bundled into serverless) +- **MobX alias:** Single instance for Neo4j NVL compatibility +- **Source maps:** Enabled in production when `E2E_COVERAGE=1` + +### `playwright.config.ts` + +- **Workers:** 2 (CI) / 4 (local) +- **Timeout:** 30s per test, 5s per assertion +- **Viewport:** Fixed 1280x1024 +- **Reporter:** GitHub (CI) / HTML (local) +- **Server coverage:** Collected via `nextcov` + +### `vitest.config.ts` + +- **Two projects:** `unit` (Node) + `component` (jsdom) +- **Coverage:** v8 provider, text + lcov + json reporters +- **Setup:** `vitest.setup.tsx` for React Testing Library + +--- + +## 23. Key Data Flows (End-to-End) + +### Flow 1: User Views a Dashboard + +``` +1. Browser navigates to /[id] +2. proxy.ts validates JWT → allows request +3. (dashboard)/[id]/page.tsx renders +4. useDashboard(id) fetches GET /api/dashboards/[id] +5. API route: requireSession() → tenant filter → return dashboard + layoutJson +6. dashboard-store.loadDashboard(data) hydrates layout +7. DashboardContainer renders grid with widgets +8. Each CardContainer: + a. Reads widget config from layout + b. useWidgetQuery() resolves parameters from parameter-store + c. POST /api/query with { connectionId, query, params, priority: P2 } + d. Scheduler queues query → executor runs it → audit logs it + e. Response arrives → data transforms applied → chart rendered +``` + +### Flow 2: User Edits a Widget + +``` +1. User clicks edit icon on widget card +2. widget-editor-store.open(widgetId, currentConfig) +3. WidgetEditorModal renders with tabs: + - Query: SQL editor + connection selector + - Chart: type picker + options panel + - Styling: conditional format rules + - Actions: click action configuration +4. User modifies query → preview panel re-executes with P1 priority +5. User clicks Save: + a. widget-editor-store → extract config + b. dashboard-store.updateWidget(widgetId, newConfig) + c. dashboard-store.isDirty = true +6. User clicks Save Dashboard: + a. useUpdateDashboard mutation + b. PUT /api/dashboards/[id] with layoutJson + version (optimistic lock) + c. Server checks version matches → updates → increments version + d. dashboard-store.isDirty = false +``` + +### Flow 3: Parameter Cascading + +``` +1. Dashboard has parameters: Country → State → City (cascading) +2. User selects Country = "USA" + a. parameter-store.setValue("country", "USA") + b. parameter-store.clearCascading("country") → clears state, city + c. State param's seed query re-executes: SELECT state FROM geo WHERE country = $country + d. State dropdown repopulates with US states +3. User selects State = "California" + a. Same cascade: city param refreshes +4. All widgets with $country, $state, $city in their queries re-execute +``` + +### Flow 4: Query Execution Pipeline (detailed) + +``` +Frontend: POST /api/query + body: { connectionId, query, params, priority, cacheConfig } + +API Route: + 1. requireSession() → { userId, tenantId, role } + 2. validateBody(querySchema) → Zod validation + 3. Lookup connection → decrypt credentials + 4. Check can_write if write query + 5. Enter middleware pipeline: + +Pipeline: + ┌─ Scheduler Middleware ─────────────────────┐ + │ - Get/create per-connector queue │ + │ - Enqueue with priority (P1/P2/P3) │ + │ - Wait for available slot │ + │ - Timeout → QueueTimeoutError │ + │ - Full → QueueFullError │ + └─────────────────────────────────────────────┘ + ┌─ Audit Middleware (pre) ────────────────────┐ + │ - Log: userId, query, connectionId, start │ + └─────────────────────────────────────────────┘ + ┌─ Core Executor ────────────────────────────┐ + │ 1. Substitute $params with values │ + │ 2. Create connection adapter │ + │ 3. Open read-only transaction │ + │ 4. Execute with timeout (AbortSignal) │ + │ 5. Stream rows up to MAX_ROWS+1 │ + │ 6. Return { columns, rows, truncated } │ + └─────────────────────────────────────────────┘ + ┌─ Audit Middleware (post) ───────────────────┐ + │ - Log: duration, rowCount, success/failure │ + └─────────────────────────────────────────────┘ + +Response → Frontend: + { data: { columns, rows, resultId }, meta: { cached, duration } } +``` + +--- + +## 24. Release 2.0 Features + +This section documents major architectural features introduced in the release/2.0 branch. + +### Optimistic Locking for Concurrent Dashboard Editing + +**Problem:** Two users editing the same dashboard simultaneously could silently overwrite each other's changes. + +**Solution:** Version-based optimistic locking on the `dashboards` table. + +**How it works:** + +1. Dashboard has a `version` integer column (starts at 1) +2. When the frontend saves, it sends `expectedVersion` in the PUT body +3. The server includes `version = expectedVersion` in the UPDATE WHERE clause +4. If another user saved in between, the WHERE matches 0 rows → returns `CONFLICT` error + +```typescript +// Server-side (PUT /api/dashboards/[id]) +if (expectedVersion !== undefined) { + conditions.push(eq(dashboards.version, expectedVersion)); +} +// On update: version: sql`${dashboards.version} + 1` + +// If 0 rows updated: +apiError( + "CONFLICT", + "This dashboard was modified by someone else. Reload to see their changes.", +); +``` + +**Version increment rules:** + +- Increments for meaningful edits: name, description, layout, isPublic +- Does NOT increment for thumbnail-only or settings-only saves +- Frontend displays a conflict toast and triggers a dashboard reload + +### Widget Reassignment + +Allows moving all widgets from one database connection to another, useful when migrating data sources. + +**API endpoint:** + +``` +POST /api/connections/{id}/reassign +Body: { targetConnectionId: string } +Response: { dashboardsUpdated: number, widgetsReassigned: number } +``` + +**Guards:** + +- Source and target must be the same connector type (cannot reassign Neo4j queries to PostgreSQL) +- Non-admin users can only reassign widgets in dashboards they own or have edit access to +- Admin users can reassign across all dashboards in the tenant +- Query compatibility is NOT validated — broken queries fail at runtime + +**Implementation:** A single SQL UPDATE walks `layoutJson.pages[].widgets[].connectionId` using `jsonb_set`, swapping matching connection IDs in place. + +### Demo Showcases + +Four portable example dashboards for demonstration and testing: + +| Showcase | Description | +| -------------------- | -------------------------------------------------------------- | +| `chart-gallery` | 17 pages, one per registered chart type | +| `click-actions` | Interactive examples, one per click-action type | +| `transformations` | Before/after side-by-side per data transform | +| `rule-based-styling` | One page per stylable chart with 2-3 realistic threshold rules | + +**Storage:** JSON files in `scripts/demo/` with a manifest in `scripts/demo/showcases.mjs`. + +**Validation:** Each showcase is validated against `neoboardExportSchema` (dashboard export format), enforcing `formatVersion: 1`, `layout.version: 2`, and `conn_*` portable connection keys. + +**Usage:** Consumed by CLI (`cli/src/commands/demo.ts`) for `list`, `seed`, and `reset` subcommands, with `--only` filtering by comma-separated keys. + +### Data Transform Pipeline (`plugins/transforms/`) + +Each chart type has a dedicated transform function that converts raw query results into chart-ready data shapes: + +| Transform | Input | Output | +| ----------------------------- | ----------------------------- | ---------------------------------- | +| `transformToBarData` | Flat rows | `{ categories[], series[] }` | +| `transformToLineData` | Flat rows | `{ xAxis[], series[] }` | +| `transformToPieData` | Flat rows | `{ name, value }[]` | +| `transformToValueData` | Single row | `{ value, label }` | +| `transformToGaugeData` | Single row | `{ value, min, max }` | +| `transformToSankeyData` | Rows with source/target/value | `{ nodes[], links[] }` | +| `transformToRadarData` | Flat rows | `{ indicators[], series[] }` | +| `transformToHierarchicalData` | Flat or nested rows | `{ name, value, children[] }` tree | +| `transformToGraphData` | Neo4j paths/nodes/rels | `{ nodes[], edges[] }` | +| `transformToMapData` | Rows with lat/lng | `{ points[] }` | +| `transformToSelectData` | Flat rows | `{ label, value }[]` | + +**Shared utilities (`transforms/shared-utils.ts`):** + +- `toRecords(data)` — normalizes Neo4j (array) and PostgreSQL (`{ records }`) formats to flat arrays +- `resolveLabelKey(keys, mapping?)` — auto-detects or uses user-overridden x-axis/label column +- `resolveValueKeys(keys, labelKey, mapping?)` — auto-detects y-axis/series columns +- `normalizeValue(value)` — handles null, undefined, NaN coercion + +**Hierarchical transform (`transforms/hierarchical-utils.ts`):** +Handles three input shapes: + +1. Pre-hierarchical data (already has `children` array) — pass through +2. Flat with parent column — builds tree using parent pointers +3. Flat name/value pairs — returns as-is with normalized values + +--- + +## Appendix: File Quick Reference + +| Purpose | Key File | +| --------------------- | ------------------------------------------------------ | +| Root layout | `src/app/layout.tsx` | +| Dashboard shell | `src/app/(dashboard)/layout.tsx` | +| Edge middleware | `src/proxy.ts` | +| Cold-start bootstrap | `src/instrumentation.ts` | +| Auth config | `src/lib/auth/config.ts` | +| Session helper | `src/lib/auth/session.ts` | +| DB schema | `src/lib/db/schema.ts` | +| Query executor | `src/lib/query/query-executor.ts` | +| Query scheduler | `src/lib/query/scheduler.ts` | +| Dashboard store | `src/stores/dashboard-store.ts` | +| Parameter store | `src/stores/parameter-store.ts` | +| Widget editor store | `src/stores/widget-editor-store.ts` | +| Widget container | `src/components/card-container.tsx` | +| Widget editor modal | `src/components/widget-editor/widget-editor-modal.tsx` | +| Dashboard grid | `src/components/dashboard-container.tsx` | +| Chart plugin registry | `src/plugins/registry.ts` | +| API response helpers | `src/lib/api/api-response.ts` | +| Logger | `src/lib/logger.ts` | +| Log transports | `src/lib/logger-transports.ts` | +| Log anonymizer | `src/lib/log-anonymizer.ts` | +| Feature flags | `src/lib/features/registry.ts` | +| Scheduler config | `src/lib/query/scheduler-config.ts` | +| Scheduler registry | `src/lib/query/scheduler-registry.ts` | +| Scheduler metrics | `src/lib/query/scheduler-metrics.ts` | +| Audit middleware | `src/lib/query/middleware/audit.ts` | +| External plugins | `src/plugins/external-plugins.generated.ts` | +| Data transforms | `src/plugins/transforms/index.ts` | +| Connection reassign | `src/lib/db/connection-reassign.ts` | +| Demo showcases | `scripts/demo/showcases.mjs` | diff --git a/scripts/demo/chart-gallery.json b/scripts/demo/chart-gallery.json index 7abb122a..68df333e 100644 --- a/scripts/demo/chart-gallery.json +++ b/scripts/demo/chart-gallery.json @@ -247,7 +247,7 @@ "settings": { "title": "", "chartOptions": { - "content": "## Gauge\n\nSingle-value visualized against a scale with optional threshold zones. Use for **% SLA**, **% conversion**, any bounded 0–100 metric.\n\n**Options shown:** `min: 0`, `max: 100`, `showProgress: true`, `showPointer: true`." + "content": "## Gauge\n\nSingle-value visualized against a scale with optional threshold zones. Use for **% SLA**, **% conversion**, any bounded 0–100 metric.\n\n**Options shown:** `min: 0`, `max: 100`, `showProgress: true`." } } }, @@ -262,7 +262,6 @@ "min": 0, "max": 100, "showProgress": true, - "showPointer": true, "showDetail": true } } @@ -708,6 +707,43 @@ { "i": "radar-md", "x": 0, "y": 0, "w": 12, "h": 3 }, { "i": "radar-chart", "x": 2, "y": 3, "w": 8, "h": 6 } ] + }, + { + "id": "page-gantt", + "title": "18. Gantt", + "widgets": [ + { + "id": "gantt-md", + "chartType": "markdown", + "connectionId": "conn_postgres_read", + "query": "", + "settings": { + "title": "", + "chartOptions": { + "content": "## Gantt chart\n\nTimeline visualization showing tasks as horizontal bars on a time axis. Perfect for **project plans**, **ETL pipelines**, **incident timelines**, or any data with start/end dates.\n\n**Data shape:** query returns `task` (name), `start` (date), `end` (date) columns. Optional: `category`/`status` for color grouping, `progress` (0–1) for completion overlay.\n\n**Options shown:** `showTodayLine: true`, `showProgress: false`, `showGridLines: true`." + } + } + }, + { + "id": "gantt-chart", + "chartType": "gantt", + "connectionId": "conn_postgres_read", + "query": "SELECT p.name AS task, o.created_at AS start, o.created_at + INTERVAL '7 days' * (ROW_NUMBER() OVER (ORDER BY p.name)) AS end, CASE WHEN p.price > 50 THEN 'Premium' ELSE 'Standard' END AS category FROM neoboard_demo_public.products p JOIN neoboard_demo_public.order_items oi ON oi.product_id = p.id JOIN neoboard_demo_public.orders o ON o.id = oi.order_id GROUP BY p.name, p.price, o.created_at ORDER BY o.created_at LIMIT 12", + "settings": { + "title": "Product order timeline", + "chartOptions": { + "showTodayLine": true, + "showProgress": false, + "showGridLines": true, + "barBorderRadius": 2 + } + } + } + ], + "gridLayout": [ + { "i": "gantt-md", "x": 0, "y": 0, "w": 12, "h": 3 }, + { "i": "gantt-chart", "x": 1, "y": 3, "w": 10, "h": 7 } + ] } ] }