@illuma-ai/code-sandbox 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,14 @@
9
9
 
10
10
  import { Nodepod } from "@illuma-ai/nodepod";
11
11
  import type { NodepodProcess } from "@illuma-ai/nodepod";
12
- import type { BootProgress, BootStage, FileMap, RuntimeConfig } from "../types";
12
+ import type {
13
+ BootProgress,
14
+ BootStage,
15
+ FileMap,
16
+ RuntimeConfig,
17
+ SandboxError,
18
+ SandboxErrorCategory,
19
+ } from "../types";
13
20
 
14
21
  /** Callback type for progress updates */
15
22
  type ProgressCallback = (progress: BootProgress) => void;
@@ -17,6 +24,8 @@ type ProgressCallback = (progress: BootProgress) => void;
17
24
  type OutputCallback = (line: string) => void;
18
25
  /** Callback type for server ready */
19
26
  type ServerReadyCallback = (port: number, url: string) => void;
27
+ /** Callback type for structured errors */
28
+ type ErrorCallback = (error: SandboxError) => void;
20
29
 
21
30
  /** Debug log prefix for easy filtering in DevTools console */
22
31
  const DBG = "[CodeSandbox:Runtime]";
@@ -42,11 +51,14 @@ export class NodepodRuntime {
42
51
  private status: BootStage = "initializing";
43
52
  private error: string | null = null;
44
53
  private previewUrl: string | null = null;
54
+ private errors: SandboxError[] = [];
55
+ private errorIdCounter = 0;
45
56
 
46
57
  // Callbacks
47
58
  private onProgress: ProgressCallback | null = null;
48
59
  private onOutput: OutputCallback | null = null;
49
60
  private onServerReady: ServerReadyCallback | null = null;
61
+ private onSandboxError: ErrorCallback | null = null;
50
62
 
51
63
  constructor(config: RuntimeConfig) {
52
64
  this.config = {
@@ -78,6 +90,11 @@ export class NodepodRuntime {
78
90
  this.onServerReady = cb;
79
91
  }
80
92
 
93
+ /** Register a structured error callback */
94
+ setErrorCallback(cb: ErrorCallback): void {
95
+ this.onSandboxError = cb;
96
+ }
97
+
81
98
  /** Get the current preview URL (null if server not ready) */
82
99
  getPreviewUrl(): string | null {
83
100
  return this.previewUrl;
@@ -119,6 +136,31 @@ export class NodepodRuntime {
119
136
  return this.error;
120
137
  }
121
138
 
139
+ /** Get all structured errors collected during this session */
140
+ getErrors(): SandboxError[] {
141
+ return [...this.errors];
142
+ }
143
+
144
+ /**
145
+ * Report an external error (e.g., from the preview iframe).
146
+ *
147
+ * This is the public entry point for errors that originate outside
148
+ * the runtime — browser console errors, unhandled rejections, etc.
149
+ * The runtime enriches them with source context from the virtual FS
150
+ * and emits them via the onSandboxError callback.
151
+ */
152
+ async reportError(error: SandboxError): Promise<void> {
153
+ // Enrich with source context if we have a file path and line number
154
+ if (error.filePath && error.line && !error.sourceContext) {
155
+ error.sourceContext = await this.getSourceContext(
156
+ error.filePath,
157
+ error.line,
158
+ );
159
+ }
160
+ this.errors.push(error);
161
+ this.onSandboxError?.(error);
162
+ }
163
+
122
164
  // -------------------------------------------------------------------------
123
165
  // Lifecycle
124
166
  // -------------------------------------------------------------------------
@@ -287,9 +329,11 @@ export class NodepodRuntime {
287
329
  }
288
330
  } catch (err) {
289
331
  const message = err instanceof Error ? err.message : String(err);
332
+ const stack = err instanceof Error ? err.stack : undefined;
290
333
  console.error(DBG, "boot: FATAL ERROR:", err);
291
334
  this.error = message;
292
335
  this.emitProgress("error", `Error: ${message}`, 0);
336
+ this.emitError("boot", message, { stack });
293
337
  throw err;
294
338
  }
295
339
  }
@@ -506,6 +550,12 @@ export class NodepodRuntime {
506
550
 
507
551
  this.serverProcess.on("error", (chunk: string) => {
508
552
  this.appendOutput(`[stderr] ${chunk}`);
553
+ // Emit structured error for stderr output.
554
+ // Filter out noise: skip empty lines and common non-error warnings.
555
+ const trimmed = chunk.trim();
556
+ if (trimmed && !this.isStderrNoise(trimmed)) {
557
+ this.emitError("process-stderr", trimmed);
558
+ }
509
559
  });
510
560
 
511
561
  this.serverProcess.on("exit", (code: number) => {
@@ -513,6 +563,11 @@ export class NodepodRuntime {
513
563
  if (code !== 0 && this.status !== "ready") {
514
564
  this.error = `Process exited with code ${code}`;
515
565
  this.emitProgress("error", `Process exited with code ${code}`, 0);
566
+ // Emit structured error for non-zero exit
567
+ this.emitError("process-exit", `Process exited with code ${code}`, {
568
+ // Include last 20 lines of terminal output for context
569
+ stack: this.terminalOutput.slice(-20).join("\n"),
570
+ });
516
571
  }
517
572
  });
518
573
  }
@@ -533,4 +588,188 @@ export class NodepodRuntime {
533
588
  this.terminalOutput.push(line);
534
589
  this.onOutput?.(line);
535
590
  }
591
+
592
+ // -------------------------------------------------------------------------
593
+ // Error helpers
594
+ // -------------------------------------------------------------------------
595
+
596
+ /** Generate a unique error ID */
597
+ private nextErrorId(): string {
598
+ return `err_${++this.errorIdCounter}_${Date.now()}`;
599
+ }
600
+
601
+ /**
602
+ * Create and record a structured error from a process or boot event.
603
+ *
604
+ * Parses the error message to extract file path and line number when
605
+ * possible, then enriches with surrounding source context.
606
+ */
607
+ private async emitError(
608
+ category: SandboxErrorCategory,
609
+ message: string,
610
+ extra?: Partial<SandboxError>,
611
+ ): Promise<void> {
612
+ // Parse file/line from common Node.js error formats:
613
+ // - "/app/server.js:42"
614
+ // - "at Object.<anonymous> (/app/routes/api.js:15:8)"
615
+ // - "SyntaxError: /app/server.js: Unexpected token (12:5)"
616
+ let filePath = extra?.filePath;
617
+ let line = extra?.line;
618
+ let column = extra?.column;
619
+
620
+ if (!filePath) {
621
+ const parsed = this.parseErrorLocation(message + (extra?.stack ?? ""));
622
+ filePath = parsed.filePath;
623
+ line = parsed.line ?? line;
624
+ column = parsed.column ?? column;
625
+ }
626
+
627
+ let sourceContext = extra?.sourceContext;
628
+ if (filePath && line && !sourceContext) {
629
+ sourceContext = await this.getSourceContext(filePath, line);
630
+ }
631
+
632
+ const error: SandboxError = {
633
+ id: this.nextErrorId(),
634
+ category,
635
+ message,
636
+ timestamp: new Date().toISOString(),
637
+ ...extra,
638
+ filePath,
639
+ line,
640
+ column,
641
+ sourceContext,
642
+ };
643
+
644
+ this.errors.push(error);
645
+ this.onSandboxError?.(error);
646
+ }
647
+
648
+ /**
649
+ * Filter out common non-error stderr output.
650
+ *
651
+ * Many Node.js tools write informational messages to stderr
652
+ * (deprecation warnings, experimental feature notices, etc.).
653
+ * We don't want to flood the agent with these.
654
+ */
655
+ private isStderrNoise(text: string): boolean {
656
+ const noisePatterns = [
657
+ /^DeprecationWarning:/,
658
+ /^ExperimentalWarning:/,
659
+ /^\(node:\d+\) /,
660
+ /^npm WARN /,
661
+ /^warn /i,
662
+ /^Debugger listening/,
663
+ /^Debugger attached/,
664
+ /^Waiting for the debugger/,
665
+ ];
666
+ return noisePatterns.some((p) => p.test(text));
667
+ }
668
+
669
+ /**
670
+ * Parse file path and line/column from common error message formats.
671
+ *
672
+ * Handles:
673
+ * - Node.js stack frames: "at func (/app/file.js:12:5)"
674
+ * - Direct references: "/app/file.js:12:5"
675
+ * - SyntaxError: "/app/file.js: Unexpected token (12:5)"
676
+ */
677
+ private parseErrorLocation(text: string): {
678
+ filePath?: string;
679
+ line?: number;
680
+ column?: number;
681
+ } {
682
+ const workdir = this.config.workdir ?? "/app";
683
+
684
+ // Pattern 1: "at ... (/app/path/file.js:LINE:COL)"
685
+ const stackMatch = text.match(/\(?(\/[^:)]+):(\d+):(\d+)\)?/);
686
+ if (stackMatch) {
687
+ const absPath = stackMatch[1];
688
+ const relativePath = absPath.startsWith(workdir + "/")
689
+ ? absPath.slice(workdir.length + 1)
690
+ : absPath;
691
+ return {
692
+ filePath: relativePath,
693
+ line: parseInt(stackMatch[2], 10),
694
+ column: parseInt(stackMatch[3], 10),
695
+ };
696
+ }
697
+
698
+ // Pattern 2: "file.js:LINE" (no column)
699
+ const simpleMatch = text.match(/\(?(\/[^:)]+):(\d+)\)?/);
700
+ if (simpleMatch) {
701
+ const absPath = simpleMatch[1];
702
+ const relativePath = absPath.startsWith(workdir + "/")
703
+ ? absPath.slice(workdir.length + 1)
704
+ : absPath;
705
+ return {
706
+ filePath: relativePath,
707
+ line: parseInt(simpleMatch[2], 10),
708
+ };
709
+ }
710
+
711
+ // Pattern 3: "Unexpected token (LINE:COL)" with a file path earlier
712
+ const syntaxMatch = text.match(/(\/[^\s:]+)\s*:\s*[^(]*\((\d+):(\d+)\)/);
713
+ if (syntaxMatch) {
714
+ const absPath = syntaxMatch[1];
715
+ const relativePath = absPath.startsWith(workdir + "/")
716
+ ? absPath.slice(workdir.length + 1)
717
+ : absPath;
718
+ return {
719
+ filePath: relativePath,
720
+ line: parseInt(syntaxMatch[2], 10),
721
+ column: parseInt(syntaxMatch[3], 10),
722
+ };
723
+ }
724
+
725
+ return {};
726
+ }
727
+
728
+ /**
729
+ * Get surrounding source code lines from the virtual filesystem.
730
+ *
731
+ * Returns ~10 lines centered on the target line, formatted as
732
+ * "lineNum: content" for each line. This gives the AI agent enough
733
+ * context to construct a surgical fix.
734
+ */
735
+ private async getSourceContext(
736
+ filePath: string,
737
+ targetLine: number,
738
+ windowSize = 5,
739
+ ): Promise<string | undefined> {
740
+ try {
741
+ const content = this.currentFiles[filePath];
742
+ if (!content) {
743
+ // Try reading from the FS directly (file might have been written
744
+ // outside of our tracking, e.g., by npm install)
745
+ if (!this.nodepod) return undefined;
746
+ const fullPath = this.resolvePath(filePath);
747
+ const exists = await this.nodepod.fs.exists(fullPath);
748
+ if (!exists) return undefined;
749
+ const fsContent = await this.nodepod.fs.readFile(fullPath, "utf-8");
750
+ return this.formatSourceContext(fsContent, targetLine, windowSize);
751
+ }
752
+ return this.formatSourceContext(content, targetLine, windowSize);
753
+ } catch {
754
+ return undefined;
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Format source code lines centered on a target line.
760
+ * Output: "lineNum: content\n" for each line in the window.
761
+ */
762
+ private formatSourceContext(
763
+ content: string,
764
+ targetLine: number,
765
+ windowSize: number,
766
+ ): string {
767
+ const lines = content.split("\n");
768
+ const start = Math.max(0, targetLine - windowSize - 1);
769
+ const end = Math.min(lines.length, targetLine + windowSize);
770
+ return lines
771
+ .slice(start, end)
772
+ .map((l, i) => `${start + i + 1}: ${l}`)
773
+ .join("\n");
774
+ }
536
775
  }
package/src/styles.css CHANGED
@@ -22,3 +22,99 @@
22
22
  --sb-warning: #cca700;
23
23
  --sb-error: #f44747;
24
24
  }
25
+
26
+ /*
27
+ * Ranger Theme Variables — bridged from ranger/client/src/style.css.
28
+ *
29
+ * These are injected into the sandbox shell so consumers can apply Ranger's
30
+ * design tokens to both the sandbox chrome and the preview iframe's content.
31
+ * HSL values are stored WITHOUT the hsl() wrapper (e.g. "12 76% 61%")
32
+ * and consumed as: color: hsl(var(--chart-1));
33
+ *
34
+ * Light mode (default) and dark mode (.dark) variants are provided.
35
+ */
36
+
37
+ /* --- Light mode --------------------------------------------------------- */
38
+ :root {
39
+ /* Ranger text */
40
+ --text-primary: #1a1a1a;
41
+ --text-secondary: #565869;
42
+ --text-tertiary: #8e8ea0;
43
+
44
+ /* Ranger surfaces */
45
+ --surface-primary: #ffffff;
46
+ --surface-secondary: #f9fafb;
47
+ --surface-tertiary: #f3f4f6;
48
+
49
+ /* Ranger borders */
50
+ --border-light: #e5e7eb;
51
+ --border-medium: #d1d5db;
52
+ --border-heavy: #9ca3af;
53
+
54
+ /* shadcn / chart HSL tokens (light) */
55
+ --background: 0 0% 100%;
56
+ --foreground: 0 0% 3.9%;
57
+ --card: 0 0% 100%;
58
+ --card-foreground: 0 0% 3.9%;
59
+ --primary: 0 0% 9%;
60
+ --primary-foreground: 0 0% 98%;
61
+ --secondary: 0 0% 96.1%;
62
+ --secondary-foreground: 0 0% 9%;
63
+ --muted: 0 0% 96.1%;
64
+ --muted-foreground: 0 0% 45.1%;
65
+ --accent: 0 0% 96.1%;
66
+ --accent-foreground: 0 0% 9%;
67
+ --destructive: 0 84.2% 60.2%;
68
+ --destructive-foreground: 0 0% 98%;
69
+ --border: 0 0% 89.8%;
70
+ --input: 0 0% 89.8%;
71
+ --ring: 0 0% 3.9%;
72
+ --radius: 0.5rem;
73
+ --chart-1: 12 76% 61%;
74
+ --chart-2: 173 58% 39%;
75
+ --chart-3: 197 37% 24%;
76
+ --chart-4: 43 74% 66%;
77
+ --chart-5: 27 87% 67%;
78
+ }
79
+
80
+ /* --- Dark mode ---------------------------------------------------------- */
81
+ .dark {
82
+ /* Ranger text */
83
+ --text-primary: #f3f4f6;
84
+ --text-secondary: #d1d5db;
85
+ --text-tertiary: #6b7280;
86
+
87
+ /* Ranger surfaces */
88
+ --surface-primary: #111827;
89
+ --surface-secondary: #1f2937;
90
+ --surface-tertiary: #374151;
91
+
92
+ /* Ranger borders */
93
+ --border-light: #3a3a3b;
94
+ --border-medium: #4b5563;
95
+ --border-heavy: #6b7280;
96
+
97
+ /* shadcn / chart HSL tokens (dark) */
98
+ --background: 0 0% 7%;
99
+ --foreground: 0 0% 98%;
100
+ --card: 0 0% 3.9%;
101
+ --card-foreground: 0 0% 98%;
102
+ --primary: 0 0% 98%;
103
+ --primary-foreground: 0 0% 9%;
104
+ --secondary: 0 0% 14.9%;
105
+ --secondary-foreground: 0 0% 98%;
106
+ --muted: 0 0% 14.9%;
107
+ --muted-foreground: 0 0% 63.9%;
108
+ --accent: 0 0% 14.9%;
109
+ --accent-foreground: 0 0% 98%;
110
+ --destructive: 0 62.8% 40.6%;
111
+ --destructive-foreground: 0 0% 98%;
112
+ --border: 0 0% 14.9%;
113
+ --input: 0 0% 14.9%;
114
+ --ring: 0 0% 83.1%;
115
+ --chart-1: 220 70% 50%;
116
+ --chart-2: 160 60% 45%;
117
+ --chart-3: 30 80% 55%;
118
+ --chart-4: 280 65% 60%;
119
+ --chart-5: 340 75% 55%;
120
+ }
@@ -1400,6 +1400,7 @@ const INDEX_HTML = `<!DOCTYPE html>
1400
1400
  <script src="https://unpkg.com/@remix-run/router@1.21.0/dist/router.umd.min.js"><\/script>
1401
1401
  <script src="https://unpkg.com/react-router@6.28.0/dist/umd/react-router.production.min.js"><\/script>
1402
1402
  <script src="https://unpkg.com/react-router-dom@6.28.0/dist/umd/react-router-dom.production.min.js"><\/script>
1403
+ <script src="https://unpkg.com/chart.js@4.4.0/dist/chart.umd.js"><\/script>
1403
1404
  <link rel="stylesheet" href="/styles.css">
1404
1405
  </head>
1405
1406
  <body class="bg-gray-50 min-h-screen antialiased">
@@ -1417,6 +1418,7 @@ const INDEX_HTML = `<!DOCTYPE html>
1417
1418
  <script src="/components/Navbar.js"><\/script>
1418
1419
  <script src="/components/ItemCard.js"><\/script>
1419
1420
  <script src="/components/ItemForm.js"><\/script>
1421
+ <script src="/components/StatsChart.js"><\/script>
1420
1422
 
1421
1423
  <!-- Pages -->
1422
1424
  <script src="/pages/WelcomePage.js"><\/script>
@@ -1433,7 +1435,84 @@ const INDEX_HTML = `<!DOCTYPE html>
1433
1435
  // public/styles.css
1434
1436
  // ---------------------------------------------------------------------------
1435
1437
 
1436
- const STYLES_CSS = `.fade-in {
1438
+ const STYLES_CSS = `/*
1439
+ * Ranger Theme Variables — chart colors and design tokens.
1440
+ * HSL values stored WITHOUT hsl() wrapper; consume as: color: hsl(var(--chart-1));
1441
+ */
1442
+
1443
+ /* --- Light mode (default) ----------------------------------------------- */
1444
+ :root {
1445
+ --text-primary: #1a1a1a;
1446
+ --text-secondary: #565869;
1447
+ --text-tertiary: #8e8ea0;
1448
+ --surface-primary: #ffffff;
1449
+ --surface-secondary: #f9fafb;
1450
+ --surface-tertiary: #f3f4f6;
1451
+ --border-light: #e5e7eb;
1452
+ --border-medium: #d1d5db;
1453
+ --border-heavy: #9ca3af;
1454
+ --background: 0 0% 100%;
1455
+ --foreground: 0 0% 3.9%;
1456
+ --card: 0 0% 100%;
1457
+ --card-foreground: 0 0% 3.9%;
1458
+ --primary: 0 0% 9%;
1459
+ --primary-foreground: 0 0% 98%;
1460
+ --secondary: 0 0% 96.1%;
1461
+ --secondary-foreground: 0 0% 9%;
1462
+ --muted: 0 0% 96.1%;
1463
+ --muted-foreground: 0 0% 45.1%;
1464
+ --accent: 0 0% 96.1%;
1465
+ --accent-foreground: 0 0% 9%;
1466
+ --destructive: 0 84.2% 60.2%;
1467
+ --destructive-foreground: 0 0% 98%;
1468
+ --border: 0 0% 89.8%;
1469
+ --input: 0 0% 89.8%;
1470
+ --ring: 0 0% 3.9%;
1471
+ --radius: 0.5rem;
1472
+ --chart-1: 12 76% 61%;
1473
+ --chart-2: 173 58% 39%;
1474
+ --chart-3: 197 37% 24%;
1475
+ --chart-4: 43 74% 66%;
1476
+ --chart-5: 27 87% 67%;
1477
+ }
1478
+
1479
+ /* --- Dark mode ---------------------------------------------------------- */
1480
+ .dark {
1481
+ --text-primary: #f3f4f6;
1482
+ --text-secondary: #d1d5db;
1483
+ --text-tertiary: #6b7280;
1484
+ --surface-primary: #111827;
1485
+ --surface-secondary: #1f2937;
1486
+ --surface-tertiary: #374151;
1487
+ --border-light: #3a3a3b;
1488
+ --border-medium: #4b5563;
1489
+ --border-heavy: #6b7280;
1490
+ --background: 0 0% 7%;
1491
+ --foreground: 0 0% 98%;
1492
+ --card: 0 0% 3.9%;
1493
+ --card-foreground: 0 0% 98%;
1494
+ --primary: 0 0% 98%;
1495
+ --primary-foreground: 0 0% 9%;
1496
+ --secondary: 0 0% 14.9%;
1497
+ --secondary-foreground: 0 0% 98%;
1498
+ --muted: 0 0% 14.9%;
1499
+ --muted-foreground: 0 0% 63.9%;
1500
+ --accent: 0 0% 14.9%;
1501
+ --accent-foreground: 0 0% 98%;
1502
+ --destructive: 0 62.8% 40.6%;
1503
+ --destructive-foreground: 0 0% 98%;
1504
+ --border: 0 0% 14.9%;
1505
+ --input: 0 0% 14.9%;
1506
+ --ring: 0 0% 83.1%;
1507
+ --chart-1: 220 70% 50%;
1508
+ --chart-2: 160 60% 45%;
1509
+ --chart-3: 30 80% 55%;
1510
+ --chart-4: 280 65% 60%;
1511
+ --chart-5: 340 75% 55%;
1512
+ }
1513
+
1514
+ /* --- Animations --------------------------------------------------------- */
1515
+ .fade-in {
1437
1516
  animation: fadeIn 0.3s ease-out;
1438
1517
  }
1439
1518
  @keyframes fadeIn {
@@ -2431,6 +2510,131 @@ window.RegisterPage = function RegisterPage() {
2431
2510
  };
2432
2511
  `;
2433
2512
 
2513
+ // ---------------------------------------------------------------------------
2514
+ // public/components/StatsChart.js
2515
+ // ---------------------------------------------------------------------------
2516
+
2517
+ const STATS_CHART_JS = `var h = React.createElement;
2518
+
2519
+ /**
2520
+ * StatsChart — Renders a doughnut chart of item status distribution
2521
+ * using Chart.js with Ranger theme CSS variables (--chart-1 through --chart-5).
2522
+ *
2523
+ * Props:
2524
+ * stats — { total, byStatus: { todo, in_progress, done }, byPriority: { low, medium, high } }
2525
+ *
2526
+ * Uses the global Chart class from chart.js UMD CDN.
2527
+ */
2528
+ window.StatsChart = function StatsChart(props) {
2529
+ var stats = props.stats || { total: 0, byStatus: {}, byPriority: {} };
2530
+ var canvasRef = React.useRef(null);
2531
+ var chartRef = React.useRef(null);
2532
+
2533
+ /**
2534
+ * Read a CSS custom property value from :root and convert to usable color.
2535
+ * Ranger stores HSL values without the hsl() wrapper (e.g. "12 76% 61%"),
2536
+ * so we wrap them: hsl(12 76% 61%).
2537
+ */
2538
+ function getChartColor(varName, fallback) {
2539
+ var raw = getComputedStyle(document.documentElement)
2540
+ .getPropertyValue(varName)
2541
+ .trim();
2542
+ if (!raw) return fallback;
2543
+ if (raw.startsWith('#') || raw.startsWith('rgb') || raw.startsWith('hsl(')) {
2544
+ return raw;
2545
+ }
2546
+ return 'hsl(' + raw + ')';
2547
+ }
2548
+
2549
+ React.useEffect(function () {
2550
+ if (!canvasRef.current) return;
2551
+
2552
+ if (chartRef.current) {
2553
+ chartRef.current.destroy();
2554
+ chartRef.current = null;
2555
+ }
2556
+
2557
+ var todo = (stats.byStatus && stats.byStatus.todo) || 0;
2558
+ var inProgress = (stats.byStatus && stats.byStatus.in_progress) || 0;
2559
+ var done = (stats.byStatus && stats.byStatus.done) || 0;
2560
+
2561
+ if (todo + inProgress + done === 0) return;
2562
+
2563
+ var colors = [
2564
+ getChartColor('--chart-1', 'hsl(12 76% 61%)'),
2565
+ getChartColor('--chart-2', 'hsl(173 58% 39%)'),
2566
+ getChartColor('--chart-3', 'hsl(197 37% 24%)'),
2567
+ ];
2568
+
2569
+ chartRef.current = new Chart(canvasRef.current, {
2570
+ type: 'doughnut',
2571
+ data: {
2572
+ labels: ['To Do', 'In Progress', 'Done'],
2573
+ datasets: [
2574
+ {
2575
+ data: [todo, inProgress, done],
2576
+ backgroundColor: colors,
2577
+ borderWidth: 0,
2578
+ hoverOffset: 6,
2579
+ },
2580
+ ],
2581
+ },
2582
+ options: {
2583
+ responsive: true,
2584
+ maintainAspectRatio: false,
2585
+ cutout: '65%',
2586
+ plugins: {
2587
+ legend: {
2588
+ position: 'bottom',
2589
+ labels: {
2590
+ padding: 16,
2591
+ usePointStyle: true,
2592
+ pointStyleWidth: 8,
2593
+ font: { size: 12 },
2594
+ color: getChartColor('--text-secondary', '#565869'),
2595
+ },
2596
+ },
2597
+ tooltip: {
2598
+ backgroundColor: 'rgba(0,0,0,0.8)',
2599
+ titleFont: { size: 13 },
2600
+ bodyFont: { size: 12 },
2601
+ padding: 10,
2602
+ cornerRadius: 8,
2603
+ },
2604
+ },
2605
+ },
2606
+ });
2607
+
2608
+ return function () {
2609
+ if (chartRef.current) {
2610
+ chartRef.current.destroy();
2611
+ chartRef.current = null;
2612
+ }
2613
+ };
2614
+ }, [stats.byStatus && stats.byStatus.todo,
2615
+ stats.byStatus && stats.byStatus.in_progress,
2616
+ stats.byStatus && stats.byStatus.done]);
2617
+
2618
+ var hasData =
2619
+ ((stats.byStatus && stats.byStatus.todo) || 0) +
2620
+ ((stats.byStatus && stats.byStatus.in_progress) || 0) +
2621
+ ((stats.byStatus && stats.byStatus.done) || 0) > 0;
2622
+
2623
+ return h(
2624
+ 'div',
2625
+ { className: 'bg-white rounded-lg border border-gray-200 p-4 card-hover' },
2626
+ h('h3', { className: 'text-sm font-semibold text-gray-700 mb-3' }, 'Status Distribution'),
2627
+ hasData
2628
+ ? h('div', { style: { height: '200px', position: 'relative' } },
2629
+ h('canvas', { ref: canvasRef })
2630
+ )
2631
+ : h('div', { className: 'flex items-center justify-center h-48 text-gray-400 text-sm' },
2632
+ 'No data to display'
2633
+ )
2634
+ );
2635
+ };
2636
+ `;
2637
+
2434
2638
  // ---------------------------------------------------------------------------
2435
2639
  // public/pages/DashboardPage.js
2436
2640
  // ---------------------------------------------------------------------------
@@ -2527,6 +2731,11 @@ window.DashboardPage = function DashboardPage() {
2527
2731
  _statCard('Done', stats.byStatus.done || 0, 'bg-emerald-50')
2528
2732
  ),
2529
2733
 
2734
+ // Chart — doughnut showing status distribution
2735
+ h('div', { className: 'mb-6' },
2736
+ h(window.StatsChart, { stats: stats })
2737
+ ),
2738
+
2530
2739
  // Filters
2531
2740
  h('div', { className: 'flex gap-2 mb-4' },
2532
2741
  _filterSelect('Status', app.filters.status, ['todo', 'in_progress', 'done'],
@@ -3288,6 +3497,7 @@ export const fullstackStarterTemplate = {
3288
3497
  "public/components/Navbar.js": NAVBAR_JS,
3289
3498
  "public/components/ItemCard.js": ITEM_CARD_JS,
3290
3499
  "public/components/ItemForm.js": ITEM_FORM_JS,
3500
+ "public/components/StatsChart.js": STATS_CHART_JS,
3291
3501
  "public/pages/WelcomePage.js": WELCOME_PAGE_JS,
3292
3502
  "public/pages/LoginPage.js": LOGIN_PAGE_JS,
3293
3503
  "public/pages/RegisterPage.js": REGISTER_PAGE_JS,