diff --git a/sensibledb-explorer/src/frontend/src/components/editor/SensibleQLEditor.tsx b/sensibledb-explorer/src/frontend/src/components/editor/SensibleQLEditor.tsx index 5a344d7f..131a5e66 100644 --- a/sensibledb-explorer/src/frontend/src/components/editor/SensibleQLEditor.tsx +++ b/sensibledb-explorer/src/frontend/src/components/editor/SensibleQLEditor.tsx @@ -1,8 +1,7 @@ -import { Component, createSignal, onMount, Show, For } from "solid-js"; -import { EditorView, basicSetup } from "codemirror"; -import { EditorState } from "@codemirror/state"; +import { Component, Show, For, createSignal } from "solid-js"; import { sensibleqlExecute } from "../../lib/api"; import { activeDb } from "../../stores/app"; +import { useCodeMirror } from "../../lib/useCodeMirror"; import type { SensibleqlResult } from "../../types"; import "./SensibleQLEditor.css"; @@ -27,40 +26,21 @@ const sampleQueries: SampleQuery[] = [ ]; const SensibleQLEditor: Component = () => { - let editorRef: HTMLDivElement | undefined; const [query, setQuery] = createSignal(""); const [result, setResult] = createSignal(null); const [isRunning, setIsRunning] = createSignal(false); - let editor: EditorView | undefined; - onMount(() => { - if (!editorRef) return; - editor = new EditorView({ - state: EditorState.create({ - doc: "// Select a sample query below or write your own\n// Supported: MATCH, GET, FIND, COUNT\n", - extensions: [ - basicSetup, - EditorView.theme({ - "&": { fontSize: "14px" }, - ".cm-editor": { background: "#f8fafc" }, - ".cm-gutters": { background: "#f1f5f9", border: "none" }, - }), - EditorView.updateListener.of((update) => { - if (update.docChanged) { - setQuery(update.state.doc.toString()); - } - }), - ], - }), - parent: editorRef, - }); + const { ref: editorRef, setDoc: setEditorDoc, getDoc } = useCodeMirror({ + initialDoc: "// Select a sample query below or write your own\n// Supported: MATCH, GET, FIND, COUNT\n", + onChange: (doc) => setQuery(doc), }); const handleRun = async () => { - if (!activeDb() || !query()) return; + const currentQuery = getDoc(); + if (!activeDb() || !currentQuery) return; setIsRunning(true); try { - const res = await sensibleqlExecute(activeDb()!, query()); + const res = await sensibleqlExecute(activeDb()!, currentQuery); setResult(res); } catch (e: any) { setResult({ success: false, message: String(e), data: null }); @@ -70,11 +50,7 @@ const SensibleQLEditor: Component = () => { }; const loadSample = (sample: SampleQuery) => { - if (editor) { - editor.dispatch({ - changes: { from: 0, to: editor.state.doc.length, insert: sample.query }, - }); - } + setEditorDoc(sample.query); setQuery(sample.query); setResult(null); }; @@ -105,7 +81,7 @@ const SensibleQLEditor: Component = () => { -
+
{(r) => (
@@ -122,4 +98,4 @@ const SensibleQLEditor: Component = () => { ); }; -export default SensibleQLEditor; +export default SensibleQLEditor; \ No newline at end of file diff --git a/sensibledb-explorer/src/frontend/src/components/home/HomeView.test.tsx b/sensibledb-explorer/src/frontend/src/components/home/HomeView.test.tsx new file mode 100644 index 00000000..a2034e6e --- /dev/null +++ b/sensibledb-explorer/src/frontend/src/components/home/HomeView.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import HomeView from "../home/HomeView"; +import { setActiveView, setActiveDb, databases, setDatabases, setNodes, setEdges, setSchema } from "../../stores/app"; + +vi.mock("../../lib/api", () => ({ + nodeList: vi.fn().mockResolvedValue([]), + edgeList: vi.fn().mockResolvedValue([]), + schemaGet: vi.fn().mockResolvedValue({ node_labels: [], edge_types: [], indexes: [], vector_indexes: [] }), +})); + +vi.mock("../onboarding/GuidedTour", () => ({ + showTour: vi.fn(), +})); + +vi.mock("../onboarding/ConnectionWizard", () => ({ + sourceOptions: [ + { value: "demo", label: "Load Demo Data", icon: "๐Ÿงช" }, + ], + default: () =>
ConnectionWizard
, +})); + +describe("HomeView", () => { + beforeEach(() => { + vi.clearAllMocks(); + setActiveDb(null); + setDatabases([]); + setNodes([]); + setEdges([]); + setSchema(null); + }); + + it("renders welcome section", () => { + const { container } = render(() => ); + expect(container.querySelector(".welcome-section")).toBeInTheDocument(); + }); + + it("displays welcome title", () => { + render(() => ); + expect(screen.getByText(/welcome to sensibledb/i)).toBeInTheDocument(); + }); + + it("shows connect your data section", () => { + render(() => ); + expect(screen.getAllByText(/connect your data/i).length).toBeGreaterThan(0); + }); + + it("shows import your data card", () => { + render(() => ); + expect(screen.getAllByText(/import your data/i).length).toBeGreaterThan(0); + }); + + it("has tour button", () => { + render(() => ); + expect(screen.getByRole("button", { name: /take a tour/i })).toBeInTheDocument(); + }); + + it("has connect data button", () => { + render(() => ); + expect(screen.getByRole("button", { name: /connect your data/i })).toBeInTheDocument(); + }); + + it("shows demo section", () => { + render(() => ); + expect(screen.getByText(/try a demo database/i)).toBeInTheDocument(); + }); + + it("displays demo card titles", () => { + render(() => ); + expect(screen.getByText(/health patterns/i)).toBeInTheDocument(); + expect(screen.getByText(/project management/i)).toBeInTheDocument(); + }); + + it("displays demo card descriptions", () => { + render(() => ); + expect(screen.getByText(/track symptoms, triggers/i)).toBeInTheDocument(); + expect(screen.getByText(/see how team members/i)).toBeInTheDocument(); + }); + + it("shows demo item counts", () => { + render(() => ); + expect(screen.getAllByText(/items/i).length).toBeGreaterThan(0); + }); + + it("shows demo connection counts", () => { + render(() => ); + expect(screen.getAllByText(/connections/i).length).toBeGreaterThan(0); + }); + + it("has explore button on demo cards", () => { + render(() => ); + expect(screen.getAllByRole("button", { name: /explore/i }).length).toBeGreaterThan(0); + }); + + it("shows question suggestions on demo cards", () => { + render(() => ); + expect(screen.getByText(/what triggers fatigue?/i)).toBeInTheDocument(); + expect(screen.getByText(/show me all symptoms/i)).toBeInTheDocument(); + }); +}); diff --git a/sensibledb-explorer/src/frontend/src/lib/useAppInit.test.ts b/sensibledb-explorer/src/frontend/src/lib/useAppInit.test.ts new file mode 100644 index 00000000..a9802192 --- /dev/null +++ b/sensibledb-explorer/src/frontend/src/lib/useAppInit.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { useAppInitialization, useKeyboardShortcuts } from "./useAppInit"; + +vi.mock("../lib/api", () => ({ + dbList: vi.fn().mockResolvedValue([]), + nodeList: vi.fn().mockResolvedValue([]), + edgeList: vi.fn().mockResolvedValue([]), + schemaGet: vi.fn().mockResolvedValue({ node_labels: [], edge_types: [], indexes: [], vector_indexes: [] }), + logError: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../components/onboarding/GuidedTour", () => ({ + isTourCompleted: vi.fn().mockReturnValue(true), +})); + +describe("useAppInitialization", () => { + it("exports useAppInitialization function", () => { + expect(typeof useAppInitialization).toBe("function"); + }); + + it("returns loadDbData function", () => { + const setDatabases = vi.fn(); + const setActiveDb = vi.fn(); + const setNodes = vi.fn(); + const setEdges = vi.fn(); + const setSchema = vi.fn(); + + const result = useAppInitialization(setDatabases, setActiveDb, setNodes, setEdges, setSchema); + expect(result).toHaveProperty("loadDbData"); + expect(typeof result.loadDbData).toBe("function"); + }); +}); + +describe("useKeyboardShortcuts", () => { + it("exports useKeyboardShortcuts function", () => { + expect(typeof useKeyboardShortcuts).toBe("function"); + }); +}); \ No newline at end of file diff --git a/sensibledb-explorer/src/frontend/src/lib/useAppInit.ts b/sensibledb-explorer/src/frontend/src/lib/useAppInit.ts new file mode 100644 index 00000000..73ea04a5 --- /dev/null +++ b/sensibledb-explorer/src/frontend/src/lib/useAppInit.ts @@ -0,0 +1,88 @@ +import { onMount, onCleanup } from "solid-js"; +import { setActiveView, setSelectedNode, activeDb } from "../stores/app"; +import { logError, dbList as apiDbList, nodeList, edgeList, schemaGet } from "./api"; +import { isTourCompleted } from "../components/onboarding/GuidedTour"; + +export function useAppInitialization( + setDatabases: (dbs: string[]) => void, + setActiveDb: (db: string | null) => void, + setNodes: (nodes: any[]) => void, + setEdges: (edges: any[]) => void, + setSchema: (schema: any) => void +) { + const loadDbData = async (dbName: string) => { + try { + const n = await nodeList(dbName); + setNodes(n); + const e = await edgeList(dbName); + setEdges(e); + const s = await schemaGet(dbName); + setSchema(s); + } catch (err) { + const errMsg = "[loadDbData] ERROR: " + String(err); + logError(errMsg).catch(() => {}); + } + }; + + onMount(async () => { + try { + const dbs = await apiDbList(); + setDatabases(dbs); + if (dbs.length > 0) { + const firstDb = dbs[0]; + setActiveDb(firstDb); + await loadDbData(firstDb); + } + } catch (e) { + } + + if (!isTourCompleted()) { + setTimeout(() => { + const tourEvent = new CustomEvent("show-tour"); + window.dispatchEvent(tourEvent); + }, 1500); + } + }); + + return { loadDbData }; +} + +export function useKeyboardShortcuts( + activeDb: () => string | null, + loadDbData: (dbName: string) => Promise +) { + onMount(() => { + const handler = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + if (e.key === "1") setActiveView("home"); + else if (e.key === "2") setActiveView("graph"); + else if (e.key === "3") setActiveView("chat"); + else if (e.key === "4") setActiveView("report"); + else if (e.key === "5") setActiveView("nodes"); + else if (e.key === "6") setActiveView("edges"); + else if (e.key === "7") setActiveView("schema"); + else if (e.key === "8") setActiveView("sensibleql"); + else if (e.key === "9") setActiveView("models"); + else if (e.key === "Escape") { + setSelectedNode(null); + setActiveView("home"); + } + else if (e.key === "/" || (e.ctrlKey && e.key === "k")) { + e.preventDefault(); + setActiveView("chat"); + } + else if (e.ctrlKey && e.key === "g") { + e.preventDefault(); + setActiveView("graph"); + } + else if (e.ctrlKey && e.key === "r") { + e.preventDefault(); + if (activeDb()) loadDbData(activeDb()!); + } + }; + + window.addEventListener("keydown", handler); + onCleanup(() => window.removeEventListener("keydown", handler)); + }); +} \ No newline at end of file diff --git a/sensibledb-explorer/src/frontend/src/lib/useCodeMirror.test.ts b/sensibledb-explorer/src/frontend/src/lib/useCodeMirror.test.ts new file mode 100644 index 00000000..c823a44c --- /dev/null +++ b/sensibledb-explorer/src/frontend/src/lib/useCodeMirror.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from "vitest"; +import { useCodeMirror } from "./useCodeMirror"; + +describe("useCodeMirror", () => { + it("creates ref function", () => { + const { ref } = useCodeMirror(); + expect(typeof ref).toBe("function"); + }); + + it("creates getDoc function", () => { + const { getDoc } = useCodeMirror(); + expect(typeof getDoc).toBe("function"); + }); + + it("creates setDoc function", () => { + const { setDoc } = useCodeMirror(); + expect(typeof setDoc).toBe("function"); + }); + + it("accepts initial doc option", () => { + const hook = useCodeMirror({ initialDoc: "initial text" }); + expect(hook.getDoc).toBeDefined(); + expect(hook.setDoc).toBeDefined(); + }); + + it("accepts onChange callback", () => { + const onChange = vi.fn(); + const hook = useCodeMirror({ onChange }); + expect(typeof hook.ref).toBe("function"); + }); +}); \ No newline at end of file diff --git a/sensibledb-explorer/src/frontend/src/lib/useCodeMirror.ts b/sensibledb-explorer/src/frontend/src/lib/useCodeMirror.ts new file mode 100644 index 00000000..9d16f5c2 --- /dev/null +++ b/sensibledb-explorer/src/frontend/src/lib/useCodeMirror.ts @@ -0,0 +1,74 @@ +import { onMount, onCleanup, createSignal } from "solid-js"; +import { EditorView, basicSetup } from "codemirror"; +import { EditorState } from "@codemirror/state"; + +export interface UseCodeMirrorOptions { + initialDoc?: string; + onChange?: (doc: string) => void; + theme?: Record>; +} + +export interface UseCodeMirrorReturn { + ref: (el: HTMLDivElement) => void; + editor: EditorView | undefined; + getDoc: () => string; + setDoc: (doc: string) => void; +} + +export function useCodeMirror(options: UseCodeMirrorOptions = {}): UseCodeMirrorReturn { + const { initialDoc = "", onChange } = options; + + const [query, setQuery] = createSignal(initialDoc); + let editorRef: HTMLDivElement | undefined; + let editor: EditorView | undefined; + + const ref = (el: HTMLDivElement) => { + editorRef = el; + if (!el || editor) return; + + editor = new EditorView({ + state: EditorState.create({ + doc: initialDoc, + extensions: [ + basicSetup, + EditorView.theme({ + "&": { fontSize: "14px" }, + ".cm-editor": { background: "#f8fafc" }, + ".cm-gutters": { background: "#f1f5f9", border: "none" }, + }), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + const doc = update.state.doc.toString(); + setQuery(doc); + onChange?.(doc); + } + }), + ], + }), + parent: el, + }); + }; + + const getDoc = () => editor?.state.doc.toString() ?? ""; + + const setDoc = (doc: string) => { + if (editor) { + editor.dispatch({ + changes: { from: 0, to: editor.state.doc.length, insert: doc }, + }); + } + }; + + onCleanup(() => { + editor?.destroy(); + }); + + return { + ref, + getDoc, + setDoc, + get editor() { + return editor; + }, + }; +} \ No newline at end of file diff --git a/sensibledb-explorer/src/frontend/src/lib/useGraph.test.ts b/sensibledb-explorer/src/frontend/src/lib/useGraph.test.ts new file mode 100644 index 00000000..6fd7acf9 --- /dev/null +++ b/sensibledb-explorer/src/frontend/src/lib/useGraph.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { useGraphData, useForceSimulation } from "./useGraph"; + +describe("useGraphData", () => { + it("exports useGraphData function", () => { + expect(typeof useGraphData).toBe("function"); + }); + + it("accepts nodes, edges, and schema callbacks", () => { + const nodes = () => []; + const edges = () => []; + const schema = () => null; + + const result = useGraphData(nodes, edges, schema); + expect(result).toHaveProperty("graphNodes"); + expect(result).toHaveProperty("graphEdges"); + }); + + it("returns graphNodes as function", () => { + const nodes = () => [{ id: 1, label: "Test", node_type: "Test", properties: {} }]; + const edges = () => []; + const schema = () => ({ node_labels: ["Test"] }); + + const result = useGraphData(nodes, edges, schema); + expect(typeof result.graphNodes).toBe("function"); + }); +}); + +describe("useForceSimulation", () => { + it("exports useForceSimulation function", () => { + expect(typeof useForceSimulation).toBe("function"); + }); + + it("returns transform signal", () => { + const { transform, setTransform } = useForceSimulation(); + expect(transform).toBeDefined(); + expect(typeof setTransform).toBe("function"); + }); + + it("returns dragging signal", () => { + const { dragging, setDragging } = useForceSimulation(); + expect(dragging).toBeDefined(); + expect(typeof setDragging).toBe("function"); + }); + + it("returns panning signal", () => { + const { panning, setPanning } = useForceSimulation(); + expect(panning).toBeDefined(); + expect(typeof setPanning).toBe("function"); + }); + + it("returns runAsyncSimulation function", () => { + const { runAsyncSimulation } = useForceSimulation(); + expect(typeof runAsyncSimulation).toBe("function"); + }); +}); \ No newline at end of file diff --git a/sensibledb-explorer/src/frontend/src/lib/useGraph.ts b/sensibledb-explorer/src/frontend/src/lib/useGraph.ts new file mode 100644 index 00000000..283f9579 --- /dev/null +++ b/sensibledb-explorer/src/frontend/src/lib/useGraph.ts @@ -0,0 +1,208 @@ +import { createMemo, createSignal } from "solid-js"; +import type { NodeDto, EdgeDto } from "../../types"; + +export interface GraphNode { + id: number; + label: string; + type: string; + x: number; + y: number; + color: string; + icon: string; + connectionCount: number; +} + +const colors = [ + "#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6", + "#ec4899", "#06b6d4", "#f97316", "#14b8a6", "#6366f1" +]; + +const typeIcons: Record = { + Person: "๐Ÿง‘", + Event: "๐Ÿ“…", + Symptom: "๐Ÿ˜ฐ", + Medication: "๐Ÿ’Š", + Office: "๐Ÿข", + Home: "๐Ÿ ", + Travel: "โœˆ๏ธ", + Task: "โœ…", + Project: "๐Ÿ“‹", + Tool: "๐Ÿ”ง", + default: "๐Ÿ“ฆ", +}; + +export function useGraphData( + nodes: () => NodeDto[], + edges: () => EdgeDto[], + schema: () => { node_labels: string[] } | null +) { + const getIconForType = (type: string): string => { + return typeIcons[type] || typeIcons.default; + }; + + const getColorForIndex = (i: number): string => { + return colors[i % colors.length]; + }; + + const extractTypeFromLabel = (label: string): string => { + const s = schema(); + if (s) { + for (const nodeLabel of s.node_labels) { + if (label.toLowerCase().includes(nodeLabel.toLowerCase())) { + return nodeLabel; + } + } + } + const words = label.split(/[\s_-]+/); + if (words.length > 1) { + return words[0]; + } + return "Item"; + }; + + const getConnectionCount = (nodeId: number, edgeList: EdgeDto[]): number => { + return edgeList.filter(e => e.from === nodeId || e.to === nodeId).length; + }; + + const graphNodes = createMemo(() => { + const nodeList = nodes(); + const edgeList = edges(); + return nodeList.map((n, i) => ({ + id: n.id, + label: n.label, + type: extractTypeFromLabel(n.label), + x: Math.random() * 800, + y: Math.random() * 600, + color: getColorForIndex(i), + icon: getIconForType(extractTypeFromLabel(n.label)), + connectionCount: getConnectionCount(n.id, edgeList), + })); + }); + + const graphEdges = createMemo(() => edges()); + + return { + graphNodes, + graphEdges, + }; +} + +export interface Transform { + x: number; + y: number; + k: number; +} + +export interface UseForceSimulationOptions { + width?: number; + height?: number; + onTick?: (nodes: GraphNode[], edges: EdgeDto[]) => void; +} + +export function useForceSimulation(options: UseForceSimulationOptions = {}) { + const { width = 800, height = 600, onTick } = options; + + const [transform, setTransform] = createSignal({ x: 0, y: 0, k: 1 }); + const [dragging, setDragging] = createSignal(null); + const [panning, setPanning] = createSignal(false); + const [panStart, setPanStart] = createSignal({ x: 0, y: 0 }); + const [hoveredEdge, setHoveredEdge] = createSignal(null); + const [hoveredNode, setHoveredNode] = createSignal(null); + + const runAsyncSimulation = ( + nodeMap: Map, + edgeList: EdgeDto[], + cx: number, + cy: number + ) => { + let simulationRunning = true; + + let alpha = 1.0; + const minAlpha = 0.001; + const decay = 0.965; + + const tick = () => { + if (alpha < minAlpha) { + simulationRunning = false; + return; + } + + const arr = Array.from(nodeMap.values()); + const dragNode = dragging(); + + for (let i = 0; i < arr.length; i++) { + const node = arr[i]; + + if (dragNode && node.id === dragNode.id) continue; + + let fx = 0, fy = 0; + + for (let j = 0; j < arr.length; j++) { + if (i === j) continue; + const other = arr[j]; + const dx = node.x - other.x; + const dy = node.y - other.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = 1000 / (dist * dist); + fx += (dx / dist) * force; + fy += (dy / dist) * force; + } + + for (const edge of edgeList) { + const isSource = edge.from === node.id; + const isTarget = edge.to === node.id; + if (!isSource && !isTarget) continue; + + const otherId = isSource ? edge.to : edge.from; + const other = nodeMap.get(otherId); + if (!other) continue; + + const dx = node.x - other.x; + const dy = node.y - other.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = (dist - 200) * 0.01; + fx -= (dx / dist) * force; + fy -= (dy / dist) * force; + } + + fx += (cx - node.x) * 0.001; + fy += (cy - node.y) * 0.001; + + node.x += fx * alpha; + node.y += fy * alpha; + + node.x = Math.max(50, Math.min(width - 50, node.x)); + node.y = Math.max(50, Math.min(height - 50, node.y)); + } + + alpha *= decay; + + if (simulationRunning) { + onTick?.(Array.from(nodeMap.values()), edgeList); + requestAnimationFrame(tick); + } + }; + + requestAnimationFrame(tick); + + return () => { + simulationRunning = false; + }; + }; + + return { + transform, + setTransform, + dragging, + setDragging, + panning, + setPanning, + panStart, + setPanStart, + hoveredEdge, + setHoveredEdge, + hoveredNode, + setHoveredNode, + runAsyncSimulation, + }; +} \ No newline at end of file diff --git a/sensibledb-explorer/src/frontend/src/lib/useReportGenerator.test.ts b/sensibledb-explorer/src/frontend/src/lib/useReportGenerator.test.ts new file mode 100644 index 00000000..cbf43c5f --- /dev/null +++ b/sensibledb-explorer/src/frontend/src/lib/useReportGenerator.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { useReportGenerator } from "./useReportGenerator"; + +describe("useReportGenerator", () => { + it("exports useReportGenerator function", () => { + expect(typeof useReportGenerator).toBe("function"); + }); + + it("accepts nodes, edges, and activeDb callbacks", () => { + const nodes = () => []; + const edges = () => []; + const activeDb = () => null; + + const result = useReportGenerator(nodes, edges, activeDb); + expect(result).toHaveProperty("nodeCount"); + expect(result).toHaveProperty("edgeCount"); + expect(result).toHaveProperty("generateReport"); + }); + + it("returns nodeCount as function", () => { + const nodes = () => [{ id: 1, label: "Test", node_type: "Test", properties: {} }]; + const edges = () => []; + const activeDb = () => "test.db"; + + const result = useReportGenerator(nodes, edges, activeDb); + expect(typeof result.nodeCount).toBe("function"); + }); + + it("returns generateReport function", () => { + const nodes = () => []; + const edges = () => []; + const activeDb = () => "test.db"; + + const result = useReportGenerator(nodes, edges, activeDb); + expect(typeof result.generateReport).toBe("function"); + }); + + it("generateReport returns string", () => { + const nodes = () => [{ id: 1, label: "Test", node_type: "Test", properties: {} }]; + const edges = () => []; + const activeDb = () => "test.db"; + + const result = useReportGenerator(nodes, edges, activeDb); + expect(typeof result.generateReport()).toBe("string"); + }); +}); \ No newline at end of file diff --git a/sensibledb-explorer/src/frontend/src/lib/useReportGenerator.ts b/sensibledb-explorer/src/frontend/src/lib/useReportGenerator.ts new file mode 100644 index 00000000..31c8f17b --- /dev/null +++ b/sensibledb-explorer/src/frontend/src/lib/useReportGenerator.ts @@ -0,0 +1,114 @@ +import { createMemo } from "solid-js"; +import type { NodeDto, EdgeDto } from "../../types"; + +export interface ReportData { + nodeCount: number; + edgeCount: number; + nodeTypes: string[]; + edgeTypes: string[]; + mostConnected: Array<[number, number]>; + typeBreakdown: Array<{ type: string; count: number; pct: number }>; +} + +export function useReportGenerator( + nodes: () => NodeDto[], + edges: () => EdgeDto[], + activeDb: () => string | null +) { + const nodeCount = createMemo(() => nodes().length); + const edgeCount = createMemo(() => edges().length); + + const nodeTypes = createMemo(() => + Array.from(new Set(nodes().map(n => n.label.split(":")[0]))) + ); + + const edgeTypes = createMemo(() => + Array.from(new Set(edges().map(e => e.label || e.edge_type))) + ); + + const mostConnected = createMemo(() => { + const connectionCount = new Map(); + edges().forEach(e => { + connectionCount.set(e.from, (connectionCount.get(e.from) || 0) + 1); + connectionCount.set(e.to, (connectionCount.get(e.to) || 0) + 1); + }); + return [...connectionCount.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + }); + + const typeBreakdown = createMemo(() => { + const types = nodeTypes(); + const count = nodeCount(); + return types.map(type => ({ + type, + count: nodes().filter(n => n.label.startsWith(type)).length, + pct: count > 0 ? Math.round((nodes().filter(n => n.label.startsWith(type)).length / count) * 100) : 0, + })); + }); + + const getNodeLabel = (id: number): string => { + return nodes().find(n => n.id === id)?.label || `ID: ${id}`; + }; + + const periodLabel = (period: string): string => { + switch (period) { + case "week": return "Last 7 Days"; + case "month": return "Last 30 Days"; + default: return "All Time"; + } + }; + + const generateReport = (period: string = "all"): string => { + const now = new Date().toLocaleString(); + const db = activeDb() || "Not connected"; + + const lines: string[] = []; + lines.push("SensibleDB Summary Report"); + lines.push(`Generated: ${now}`); + lines.push(`Period: ${periodLabel(period)}`); + lines.push(`Database: ${db}`); + lines.push(""); + lines.push("โ”€โ”€ Overview โ”€โ”€"); + lines.push(`Total Items: ${nodeCount()}`); + lines.push(`Total Connections: ${edgeCount()}`); + lines.push(`Item Types: ${nodeTypes().size} (${nodeTypes().join(", ")})`); + lines.push(`Relationship Types: ${edgeTypes().size} (${edgeTypes().join(", ")})`); + lines.push(""); + lines.push("โ”€โ”€ Key Findings โ”€โ”€"); + + if (mostConnected().length > 0) { + lines.push(`โ€ข ${getNodeLabel(mostConnected()[0][0])} is the most connected with ${mostConnected()[0][1]} connections`); + } + lines.push(`โ€ข Your data contains ${nodeTypes().size} different types: ${nodeTypes().join(", ")}`); + lines.push(`โ€ข ${edgeTypes().size} types of relationships connect your items: ${edgeTypes().join(", ")}`); + lines.push(""); + + if (mostConnected().length > 0) { + lines.push("โ”€โ”€ Most Connected Items โ”€โ”€"); + mostConnected().forEach(([id, count], i) => { + lines.push(`${i + 1}. ${getNodeLabel(id)} โ€” ${count} connections`); + }); + lines.push(""); + } + + lines.push("โ”€โ”€ Item Breakdown by Type โ”€โ”€"); + typeBreakdown().forEach(({ type, count, pct }) => { + lines.push(`${type}: ${count} items (${pct}%)`); + }); + + return lines.join("\n"); + }; + + return { + nodeCount, + edgeCount, + nodeTypes, + edgeTypes, + mostConnected, + typeBreakdown, + getNodeLabel, + periodLabel, + generateReport, + }; +} \ No newline at end of file