From 4bf3fc33cc51b8a9fc60b586d6f487e6ba05fc06 Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 30 Mar 2026 22:43:20 +0530 Subject: [PATCH 1/4] feat(practice): implement template-based practice system with simulation validation --- app/api/templates/[id]/route.ts | 24 +++ app/api/templates/route.ts | 21 +++ app/practice/[id]/page.tsx | 286 +++++++++++++++++++++++++++++ app/practice/page.tsx | 136 ++++++++++++++ components/canvas/DesignCanvas.tsx | 16 +- components/dashboard/Hero.tsx | 6 +- components/dashboard/Sidebar.tsx | 7 +- src/lib/simulation/engine.ts | 4 +- src/lib/templates/curated.ts | 85 +++++++++ src/lib/templates/types.ts | 28 +++ 10 files changed, 606 insertions(+), 7 deletions(-) create mode 100644 app/api/templates/[id]/route.ts create mode 100644 app/api/templates/route.ts create mode 100644 app/practice/[id]/page.tsx create mode 100644 app/practice/page.tsx create mode 100644 src/lib/templates/curated.ts create mode 100644 src/lib/templates/types.ts diff --git a/app/api/templates/[id]/route.ts b/app/api/templates/[id]/route.ts new file mode 100644 index 0000000..3b330dd --- /dev/null +++ b/app/api/templates/[id]/route.ts @@ -0,0 +1,24 @@ +import { NextResponse, NextRequest } from 'next/server'; +import { curatedTemplates } from '@/src/lib/templates/curated'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + // Lookup requested template + // In the future: also look up generated templates from the DB here + const template = curatedTemplates.find(t => t.id === id); + + if (!template) { + return NextResponse.json({ error: 'Template not found' }, { status: 404 }); + } + + return NextResponse.json({ template }); + } catch (error) { + console.error('Error fetching template:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts new file mode 100644 index 0000000..9d10b10 --- /dev/null +++ b/app/api/templates/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { curatedTemplates } from '@/src/lib/templates/curated'; + +export async function GET() { + try { + // Return summary of curated templates for the listing page + const summaries = curatedTemplates.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + category: t.category, + difficulty: t.difficulty, + targetRps: t.targetRps + })); + + return NextResponse.json({ templates: summaries }); + } catch (error) { + console.error('Error fetching templates:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/practice/[id]/page.tsx b/app/practice/[id]/page.tsx new file mode 100644 index 0000000..3be6249 --- /dev/null +++ b/app/practice/[id]/page.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { useEffect, useState, useRef, useCallback } from 'react'; +import { DesignCanvas, CanvasNode, Connection, CanvasStateRef } from '@/components/canvas/DesignCanvas'; +import { ComponentPalette } from '@/components/canvas/ComponentPalette'; +import { CanvasPanelsProvider } from '@/components/canvas/CanvasPanelsContext'; +import { ITemplate } from '@/src/lib/templates/types'; +import { SimulationResult } from '@/src/lib/simulation/engine'; + +// --- LocalStorage helpers --- +function getSavedProgress(templateId: string): { nodes: CanvasNode[]; connections: Connection[] } | null { + try { + const data = localStorage.getItem(`practice_progress_${templateId}`); + return data ? JSON.parse(data) : null; + } catch { return null; } +} + +function saveProgress(templateId: string, nodes: CanvasNode[], connections: Connection[]) { + try { + localStorage.setItem(`practice_progress_${templateId}`, JSON.stringify({ nodes, connections })); + } catch { /* quota exceeded — silently fail */ } +} + +function markSolved(templateId: string) { + try { + const solved = JSON.parse(localStorage.getItem('practice_solved') || '[]') as string[]; + if (!solved.includes(templateId)) { + solved.push(templateId); + localStorage.setItem('practice_solved', JSON.stringify(solved)); + } + } catch { /* silently fail */ } +} + +export function isSolved(templateId: string): boolean { + try { + const solved = JSON.parse(localStorage.getItem('practice_solved') || '[]') as string[]; + return solved.includes(templateId); + } catch { return false; } +} +// --- End helpers --- + +export default function PracticePage() { + const params = useParams(); + const router = useRouter(); + const id = params?.id as string; + + const [template, setTemplate] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [simulationState, setSimulationState] = useState(null); + const [feedback, setFeedback] = useState<{ type: 'success' | 'error', text: string } | null>(null); + const [showSolution, setShowSolution] = useState(false); + const [solved, setSolved] = useState(false); + + // Preserve user's canvas modifications across solution toggles + const [userNodes, setUserNodes] = useState(null); + const [userConnections, setUserConnections] = useState(null); + const canvasStateRef = useRef(null); + + // Auto-save progress periodically + const autoSaveRef = useRef(null); + + useEffect(() => { + async function loadTemplate() { + try { + const res = await fetch(`/api/templates/${id}`); + if (!res.ok) throw new Error('Template not found'); + const data = await res.json(); + setTemplate(data.template); + + // Load saved progress from localStorage + const saved = getSavedProgress(id); + if (saved) { + setUserNodes(saved.nodes); + setUserConnections(saved.connections); + } + + // Check if already solved + setSolved(isSolved(id)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load'); + } finally { + setLoading(false); + } + } + if (id) loadTemplate(); + }, [id]); + + // Auto-save every 3 seconds while actively working + useEffect(() => { + if (!id || showSolution) return; + + autoSaveRef.current = setInterval(() => { + if (canvasStateRef.current) { + saveProgress(id, canvasStateRef.current.nodes, canvasStateRef.current.connections); + } + }, 3000); + + return () => { + if (autoSaveRef.current) clearInterval(autoSaveRef.current); + }; + }, [id, showSolution]); + + const handleToggleSolution = useCallback(() => { + if (!showSolution) { + // Snapshot user's work before switching to solution + if (canvasStateRef.current) { + const nodes = [...canvasStateRef.current.nodes]; + const connections = [...canvasStateRef.current.connections]; + setUserNodes(nodes); + setUserConnections(connections); + saveProgress(id, nodes, connections); + } + } + setShowSolution(prev => !prev); + setFeedback(null); + }, [showSolution, id]); + + const handleSubmit = () => { + if (!template || !simulationState) { + setFeedback({ type: 'error', text: 'Run the simulation first before submitting your fix.' }); + return; + } + + const bottleneckNodeStatus = simulationState.nodeMetrics[template.bottleneckNodeId]?.status; + + if (bottleneckNodeStatus === 'normal' || bottleneckNodeStatus === 'warning') { + if (simulationState.globalStatus === 'critical') { + setFeedback({ type: 'error', text: `Bottleneck resolved on the target node, but another part of the system is now critical. Keep tweaking!` }); + } else { + // SUCCESS — persist solved state + setSolved(true); + markSolved(id); + if (canvasStateRef.current) { + saveProgress(id, canvasStateRef.current.nodes, canvasStateRef.current.connections); + } + setFeedback({ type: 'success', text: '🎉 System stabilized! Bottleneck resolved. Excellent architectural decision.' }); + } + } else { + setFeedback({ type: 'error', text: `The bottleneck node is still overloaded under this load. Try adding a component to absorb some of the traffic.`}); + } + }; + + const handleReset = () => { + if (!template) return; + setUserNodes(null); + setUserConnections(null); + setFeedback(null); + setSolved(false); + try { + localStorage.removeItem(`practice_progress_${id}`); + const solved = JSON.parse(localStorage.getItem('practice_solved') || '[]') as string[]; + localStorage.setItem('practice_solved', JSON.stringify(solved.filter(s => s !== id))); + } catch { /* ignore */ } + }; + + if (loading) { + return ( +
+
+
+

Loading exercise...

+
+
+ ); + } + if (error || !template) { + return ( +
+

Error: {error}

+
+ ); + } + + const activeNodes = showSolution + ? template.modelSolution.nodes + : (userNodes ?? template.initialNodes); + const activeConnections = showSolution + ? template.modelSolution.connections + : (userConnections ?? template.initialConnections); + + return ( + +
+ {/* Header */} +
+
+ router.push('/practice')}>← +
+

+ {template.title} + {solved && ( + + check_circle + Solved + + )} +

+

{template.category} • {template.difficulty.toUpperCase()}

+
+
+
+ + + {!solved ? ( + + ) : ( + + )} +
+
+ + {/* Canvas area */} +
+ {!showSolution && } + + + + {/* Problem Card overlay */} +
+ +
+

Problem Statement

+

+ {template.description} +

+
+ + {showSolution && ( +
+

Model Solution

+

+ {template.solutionExplanation} +

+
+ )} + + {feedback && !showSolution && ( +
+

+ {feedback.text} +

+
+ )} +
+
+
+
+ ); +} diff --git a/app/practice/page.tsx b/app/practice/page.tsx new file mode 100644 index 0000000..e8c0558 --- /dev/null +++ b/app/practice/page.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Sidebar } from '@/components/dashboard/Sidebar'; +import { SidebarProvider } from '@/components/dashboard/SidebarContext'; + +interface TemplateSummary { + id: string; + title: string; + description: string; + category: string; + difficulty: 'easy' | 'medium' | 'hard'; + targetRps: number; +} + +export default function PracticeDirectory() { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [solvedIds, setSolvedIds] = useState([]); + + useEffect(() => { + async function load() { + try { + const res = await fetch('/api/templates'); + const data = await res.json(); + setTemplates(data.templates || []); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + } + load(); + + // Read solved state from localStorage + try { + const solved = JSON.parse(localStorage.getItem('practice_solved') || '[]') as string[]; + setSolvedIds(solved); + } catch { /* ignore */ } + }, []); + + return ( + +
+ +
+
+
+ + {/* Hero banner */} +
+
+
+

Targeted System Design Practice

+

+ Fix broken architectures under load. Focus on specific patterns like caching, + horizontal scaling, and load balancing without the overhead of a full interview. +

+ {solvedIds.length > 0 && ( +

+ ✅ {solvedIds.length} / {templates.length || '...'} exercises completed +

+ )} +
+
+ +

Available Exercises

+ + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ ) : ( +
+ {/* AI Exercise Card */} +
+
+ smart_toy +
+

Generate Custom Exercise

+

Uses AI to generate a brand new unique bottleneck scenario.

+ + Coming Soon + +
+ + {/* Curated Templates */} + {templates.map(template => { + const templateSolved = solvedIds.includes(template.id); + return ( + + {/* Solved checkmark overlay */} + {templateSolved && ( +
+ check_circle +
+ )} +
+
+ + {template.difficulty.toUpperCase()} + + {template.category} +
+

{template.title}

+

+ {template.description} +

+
+
+ {templateSolved ? 'Review Solution' : 'Start Drill'} + arrow_forward +
+ + ); + })} +
+ )} +
+
+
+
+
+ ); +} diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index 1b9ea51..124929a 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -80,12 +80,15 @@ export interface CanvasStateRef { interface DesignCanvasProps { initialNodes?: CanvasNode[]; initialConnections?: Connection[]; + initialTargetRps?: number; onSave?: (nodes: CanvasNode[], connections: Connection[]) => void; readOnly?: boolean; /** Live ref to current canvas state — updated on every change */ stateRef?: MutableRefObject; /** Array of active constraint changes to visually impact the canvas */ activeConstraints?: IConstraintChange[]; + /** Callback fired when the simulation status changes so parent can validate */ + onSimulationChange?: (metrics: any) => void; } const MAX_HISTORY = 50; @@ -142,10 +145,12 @@ function historyReducer(state: HistoryState, action: HistoryAction): HistoryStat export function DesignCanvas({ initialNodes = DEFAULT_NODES, initialConnections = DEFAULT_CONNECTIONS, + initialTargetRps = 10000, onSave, readOnly = false, stateRef, - activeConstraints = [] + activeConstraints = [], + onSimulationChange }: DesignCanvasProps) { const arrowId = useId(); const canvasRef = useRef(null); @@ -185,9 +190,16 @@ export function DesignCanvas({ // Simulation Engine State const [isSimulationRunningRaw, setIsSimulationRunning] = useState(false); const isSimulationRunning = isSimulationRunningRaw && !readOnly; - const [targetRps, setTargetRps] = useState(10000); + const [targetRps, setTargetRps] = useState(initialTargetRps); const simulationMetrics = useSimulationEngine(nodes, connections, targetRps, isSimulationRunning); + // Expose simulation changes to parent (used by Templates system) + useEffect(() => { + if (onSimulationChange) { + onSimulationChange(simulationMetrics); + } + }, [simulationMetrics, onSimulationChange]); + // Selection state const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedConnectionId, setSelectedConnectionId] = useState(null); diff --git a/components/dashboard/Hero.tsx b/components/dashboard/Hero.tsx index 82f77cf..9b3d5fa 100644 --- a/components/dashboard/Hero.tsx +++ b/components/dashboard/Hero.tsx @@ -1,3 +1,5 @@ +import Link from 'next/link'; + interface HeroProps { userName?: string; } @@ -10,9 +12,9 @@ export function Hero({ userName = 'Designer' }: HeroProps) {

Welcome back, {userName}

Ready to architect your next big system? Create a new design or continue working on existing ones.

- +
diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 8a54fac..f41fedc 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -16,12 +16,12 @@ export function Sidebar() { { id: 'my-designs', href: '/dashboard', label: 'My Designs', icon: 'grid_view', filled: true }, { id: 'analytics', href: '/dashboard/analytics', label: 'Analytics', icon: 'bar_chart', filled: true }, { id: 'interview', href: '/interview', label: 'Interview Mode', icon: 'play_circle', filled: false }, - { id: 'templates', href: '#', label: 'Templates', icon: 'library_books', filled: false }, + { id: 'templates', href: '/practice', label: 'Templates', icon: 'library_books', filled: false }, ]; const isActive = (href: string) => { if (href === '#') return false; - if (href === '/dashboard') return pathname === href; + if (href === '/dashboard' || href === '/practice' || href === '/interview') return pathname === href; return pathname === href || pathname.startsWith(href + '/'); }; @@ -137,6 +137,9 @@ export function Sidebar() { {item.icon} {item.label} + {item.id === 'templates' && ( + New + )} ); })} diff --git a/src/lib/simulation/engine.ts b/src/lib/simulation/engine.ts index 4f0743a..5a19e32 100644 --- a/src/lib/simulation/engine.ts +++ b/src/lib/simulation/engine.ts @@ -111,7 +111,9 @@ export function runSimulation(nodes: ICanvasNode[], edges: IConnection[], target // Distribute to children const outgoingEdges = adj[node.id]; if (outgoingEdges && outgoingEdges.length > 0) { - const flowPerEdge = outFlow / outgoingEdges.length; + // Cache nodes absorb most traffic (90% hit rate), only forwarding misses + const effectiveOut = node.type === 'Cache' ? outFlow * 0.1 : outFlow; + const flowPerEdge = effectiveOut / outgoingEdges.length; outgoingEdges.forEach(edge => { edgeMetrics[edge.id].trafficFlow += flowPerEdge; if (nextTrafficIn[edge.to] !== undefined) { diff --git a/src/lib/templates/curated.ts b/src/lib/templates/curated.ts new file mode 100644 index 0000000..589c7ab --- /dev/null +++ b/src/lib/templates/curated.ts @@ -0,0 +1,85 @@ +import { ITemplate } from './types'; + +export const curatedTemplates: ITemplate[] = [ + { + id: 'curated-cache-001', + title: 'Database Under Siege', + description: 'Your SQL database is receiving 8,000 RPS directly from the app servers. It can only handle 3,000. Users are experiencing dropped requests and timeouts. Fix the bottleneck using the canvas.', + category: 'Caching Optimization', + difficulty: 'easy', + targetRps: 8000, + initialNodes: [ + { id: 'client', type: 'Client', icon: 'person', x: 500, y: 350, label: 'Users' }, + { id: 'lb', type: 'LB', icon: 'account_tree', x: 700, y: 350, label: 'Load Balancer' }, + { id: 'srv1', type: 'Server', icon: 'dns', x: 900, y: 250, label: 'App Server 1' }, + { id: 'srv2', type: 'Server', icon: 'dns', x: 900, y: 450, label: 'App Server 2' }, + { id: 'db', type: 'SQL', icon: 'database', x: 1100, y: 350, label: 'Primary DB' } + ], + initialConnections: [ + { id: 'c1', from: 'client', to: 'lb' }, + { id: 'c2', from: 'lb', to: 'srv1' }, + { id: 'c3', from: 'lb', to: 'srv2' }, + { id: 'c4', from: 'srv1', to: 'db' }, + { id: 'c5', from: 'srv2', to: 'db' } + ], + bottleneckNodeId: 'db', + expectedSolution: ['add_cache'], + modelSolution: { + nodes: [ + { id: 'client', type: 'Client', icon: 'person', x: 500, y: 350, label: 'Users' }, + { id: 'lb', type: 'LB', icon: 'account_tree', x: 700, y: 350, label: 'Load Balancer' }, + { id: 'srv1', type: 'Server', icon: 'dns', x: 900, y: 250, label: 'App Server 1' }, + { id: 'srv2', type: 'Server', icon: 'dns', x: 900, y: 450, label: 'App Server 2' }, + { id: 'cache', type: 'Cache', icon: 'memory', x: 1100, y: 350, label: 'Redis Cache' }, + { id: 'db', type: 'SQL', icon: 'database', x: 1300, y: 350, label: 'Primary DB' } + ], + connections: [ + { id: 'c1', from: 'client', to: 'lb' }, + { id: 'c2', from: 'lb', to: 'srv1' }, + { id: 'c3', from: 'lb', to: 'srv2' }, + { id: 'c4', from: 'srv1', to: 'cache' }, + { id: 'c5', from: 'srv2', to: 'cache' }, + { id: 'c6', from: 'cache', to: 'db', label: 'Cache Misses' } + ] + }, + solutionExplanation: "Adding a Redis Cache (50k RPS capacity) in front of the SQL database absorbs 90% of read traffic. Only cache misses (10%) flow to the DB, reducing its load from 8k to ~800 RPS — well within its 3k capacity." + }, + { + id: 'curated-scale-001', + title: 'The Monolith', + description: 'A single App Server is handling all 10,000 RPS coming from the Load Balancer. It can only handle 5,000. Add another server and balance the load.', + category: 'Scaling Systems', + difficulty: 'easy', + targetRps: 10000, + initialNodes: [ + { id: 'client', type: 'Client', icon: 'person', x: 500, y: 350, label: 'Users' }, + { id: 'lb', type: 'LB', icon: 'account_tree', x: 700, y: 350, label: 'Load Balancer' }, + { id: 'srv1', type: 'Server', icon: 'dns', x: 900, y: 350, label: 'App Server 1' }, + { id: 'db', type: 'SQL', icon: 'database', x: 1100, y: 350, label: 'Primary DB' } + ], + initialConnections: [ + { id: 'c1', from: 'client', to: 'lb' }, + { id: 'c2', from: 'lb', to: 'srv1' }, + { id: 'c3', from: 'srv1', to: 'db' } + ], + bottleneckNodeId: 'srv1', + expectedSolution: ['scale_horizontally'], + modelSolution: { + nodes: [ + { id: 'client', type: 'Client', icon: 'person', x: 500, y: 350, label: 'Users' }, + { id: 'lb', type: 'LB', icon: 'account_tree', x: 700, y: 350, label: 'Load Balancer' }, + { id: 'srv1', type: 'Server', icon: 'dns', x: 900, y: 250, label: 'App Server 1' }, + { id: 'srv2', type: 'Server', icon: 'dns', x: 900, y: 450, label: 'App Server 2' }, + { id: 'db', type: 'SQL', icon: 'database', x: 1100, y: 350, label: 'Primary DB' } + ], + connections: [ + { id: 'c1', from: 'client', to: 'lb' }, + { id: 'c2', from: 'lb', to: 'srv1' }, + { id: 'c3', from: 'lb', to: 'srv2' }, + { id: 'c4', from: 'srv1', to: 'db' }, + { id: 'c5', from: 'srv2', to: 'db' } + ] + }, + solutionExplanation: "By adding a second App Server (scaling horizontally) and connecting the Load Balancer to both, the 10,000 RPS is distributed equally. Each server now handles 5,000 RPS, which is exactly within their capacity." + } +]; diff --git a/src/lib/templates/types.ts b/src/lib/templates/types.ts new file mode 100644 index 0000000..008710f --- /dev/null +++ b/src/lib/templates/types.ts @@ -0,0 +1,28 @@ +import { ICanvasNode, IConnection } from "@/src/lib/db/models/Design"; + +export type TemplateDifficulty = "easy" | "medium" | "hard"; +export type TemplateCategory = "Bottleneck Resolution" | "Scaling Systems" | "Caching Optimization" | "Load Balancing" | "Fault Tolerance"; + +export interface ITemplate { + id: string; // Unique identifier (e.g., 'curated-bottleneck-001' or 'ai-gen-123') + title: string; + description: string; + category: TemplateCategory; + difficulty: TemplateDifficulty; + targetRps: number; // For the simulation engine + + // Starting state + initialNodes: ICanvasNode[]; + initialConnections: IConnection[]; + + // For validation + bottleneckNodeId: string; // The specific node ID that is overloaded and needs fixing + + // Model Solution + expectedSolution: string[]; // Descriptive tags or rules (e.g. ['add_cache']) + modelSolution: { + nodes: ICanvasNode[]; + connections: IConnection[]; + }; + solutionExplanation: string; +} From 5b259902b692e97b34cc3ca89cafb8358b0270aa Mon Sep 17 00:00:00 2001 From: Shashank Date: Mon, 30 Mar 2026 23:24:51 +0530 Subject: [PATCH 2/4] fix: Lint and build issues --- components/canvas/DesignCanvas.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index 124929a..948762a 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useId, useCallback, useEffect, useReducer, MutableRefObject } from 'react'; import { IConstraintChange } from '@/src/lib/db/models/InterviewSession'; import { useSimulationEngine } from '@/src/hooks/useSimulationEngine'; +import { SimulationResult } from '@/src/lib/simulation/engine'; import { SimulationControls } from './SimulationControls'; // Color mapping for different component types @@ -88,7 +89,7 @@ interface DesignCanvasProps { /** Array of active constraint changes to visually impact the canvas */ activeConstraints?: IConstraintChange[]; /** Callback fired when the simulation status changes so parent can validate */ - onSimulationChange?: (metrics: any) => void; + onSimulationChange?: (metrics: SimulationResult) => void; } const MAX_HISTORY = 50; From 2d288f7e8290d5d2dcec15004203a2af8731d0e3 Mon Sep 17 00:00:00 2001 From: Shashank Date: Tue, 31 Mar 2026 00:25:26 +0530 Subject: [PATCH 3/4] fix: Lint and build issues --- app/practice/[id]/page.tsx | 71 ++++++++++++------------------ app/practice/page.tsx | 23 ++++++---- components/canvas/DesignCanvas.tsx | 6 +++ components/dashboard/Sidebar.tsx | 3 +- src/lib/practice/storage.ts | 51 +++++++++++++++++++++ src/lib/simulation/engine.ts | 7 ++- 6 files changed, 108 insertions(+), 53 deletions(-) create mode 100644 src/lib/practice/storage.ts diff --git a/app/practice/[id]/page.tsx b/app/practice/[id]/page.tsx index 3be6249..afe519f 100644 --- a/app/practice/[id]/page.tsx +++ b/app/practice/[id]/page.tsx @@ -7,38 +7,10 @@ import { ComponentPalette } from '@/components/canvas/ComponentPalette'; import { CanvasPanelsProvider } from '@/components/canvas/CanvasPanelsContext'; import { ITemplate } from '@/src/lib/templates/types'; import { SimulationResult } from '@/src/lib/simulation/engine'; - -// --- LocalStorage helpers --- -function getSavedProgress(templateId: string): { nodes: CanvasNode[]; connections: Connection[] } | null { - try { - const data = localStorage.getItem(`practice_progress_${templateId}`); - return data ? JSON.parse(data) : null; - } catch { return null; } -} - -function saveProgress(templateId: string, nodes: CanvasNode[], connections: Connection[]) { - try { - localStorage.setItem(`practice_progress_${templateId}`, JSON.stringify({ nodes, connections })); - } catch { /* quota exceeded — silently fail */ } -} - -function markSolved(templateId: string) { - try { - const solved = JSON.parse(localStorage.getItem('practice_solved') || '[]') as string[]; - if (!solved.includes(templateId)) { - solved.push(templateId); - localStorage.setItem('practice_solved', JSON.stringify(solved)); - } - } catch { /* silently fail */ } -} - -export function isSolved(templateId: string): boolean { - try { - const solved = JSON.parse(localStorage.getItem('practice_solved') || '[]') as string[]; - return solved.includes(templateId); - } catch { return false; } -} -// --- End helpers --- +import { + getSavedProgress, saveProgress, clearProgress, + isSolved, markSolved, unmarkSolved, +} from '@/src/lib/practice/storage'; export default function PracticePage() { const params = useParams(); @@ -63,11 +35,16 @@ export default function PracticePage() { const autoSaveRef = useRef(null); useEffect(() => { + const controller = new AbortController(); + async function loadTemplate() { try { - const res = await fetch(`/api/templates/${id}`); - if (!res.ok) throw new Error('Template not found'); + const res = await fetch(`/api/templates/${id}`, { signal: controller.signal }); + if (!res.ok) throw new Error(`Failed to load template: ${res.status} ${res.statusText}`); const data = await res.json(); + + if (controller.signal.aborted) return; + setTemplate(data.template); // Load saved progress from localStorage @@ -80,12 +57,19 @@ export default function PracticePage() { // Check if already solved setSolved(isSolved(id)); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load'); + if (err instanceof Error && err.name === 'AbortError') return; + if (!controller.signal.aborted) { + setError(err instanceof Error ? err.message : 'Failed to load'); + } } finally { - setLoading(false); + if (!controller.signal.aborted) { + setLoading(false); + } } } if (id) loadTemplate(); + + return () => controller.abort(); }, [id]); // Auto-save every 3 seconds while actively working @@ -123,8 +107,14 @@ export default function PracticePage() { setFeedback({ type: 'error', text: 'Run the simulation first before submitting your fix.' }); return; } + + // Guard: ensure the bottleneck node is defined and has metrics + if (!template.bottleneckNodeId || !simulationState.nodeMetrics[template.bottleneckNodeId]) { + setFeedback({ type: 'error', text: 'No bottleneck node defined or metrics unavailable for the target node.' }); + return; + } - const bottleneckNodeStatus = simulationState.nodeMetrics[template.bottleneckNodeId]?.status; + const bottleneckNodeStatus = simulationState.nodeMetrics[template.bottleneckNodeId].status; if (bottleneckNodeStatus === 'normal' || bottleneckNodeStatus === 'warning') { if (simulationState.globalStatus === 'critical') { @@ -149,11 +139,8 @@ export default function PracticePage() { setUserConnections(null); setFeedback(null); setSolved(false); - try { - localStorage.removeItem(`practice_progress_${id}`); - const solved = JSON.parse(localStorage.getItem('practice_solved') || '[]') as string[]; - localStorage.setItem('practice_solved', JSON.stringify(solved.filter(s => s !== id))); - } catch { /* ignore */ } + clearProgress(id); + unmarkSolved(id); }; if (loading) { diff --git a/app/practice/page.tsx b/app/practice/page.tsx index e8c0558..34fbce9 100644 --- a/app/practice/page.tsx +++ b/app/practice/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { Sidebar } from '@/components/dashboard/Sidebar'; import { SidebarProvider } from '@/components/dashboard/SidebarContext'; +import { getSolvedIds } from '@/src/lib/practice/storage'; interface TemplateSummary { id: string; @@ -20,24 +21,30 @@ export default function PracticeDirectory() { const [solvedIds, setSolvedIds] = useState([]); useEffect(() => { + const controller = new AbortController(); + async function load() { try { - const res = await fetch('/api/templates'); + const res = await fetch('/api/templates', { signal: controller.signal }); + if (!res.ok) throw new Error(`Failed to load templates: ${res.status} ${res.statusText}`); const data = await res.json(); - setTemplates(data.templates || []); + if (!controller.signal.aborted) { + setTemplates(data.templates || []); + } } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return; console.error(err); } finally { - setLoading(false); + if (!controller.signal.aborted) { + setLoading(false); + } } } load(); - // Read solved state from localStorage - try { - const solved = JSON.parse(localStorage.getItem('practice_solved') || '[]') as string[]; - setSolvedIds(solved); - } catch { /* ignore */ } + setSolvedIds(getSolvedIds()); + + return () => controller.abort(); }, []); return ( diff --git a/components/canvas/DesignCanvas.tsx b/components/canvas/DesignCanvas.tsx index 948762a..a0b74ab 100644 --- a/components/canvas/DesignCanvas.tsx +++ b/components/canvas/DesignCanvas.tsx @@ -192,6 +192,12 @@ export function DesignCanvas({ const [isSimulationRunningRaw, setIsSimulationRunning] = useState(false); const isSimulationRunning = isSimulationRunningRaw && !readOnly; const [targetRps, setTargetRps] = useState(initialTargetRps); + + // Keep targetRps in sync when the prop changes (async template loads) + useEffect(() => { + setTargetRps(initialTargetRps); + }, [initialTargetRps]); + const simulationMetrics = useSimulationEngine(nodes, connections, targetRps, isSimulationRunning); // Expose simulation changes to parent (used by Templates system) diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index f41fedc..fb1d5e2 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -21,7 +21,8 @@ export function Sidebar() { const isActive = (href: string) => { if (href === '#') return false; - if (href === '/dashboard' || href === '/practice' || href === '/interview') return pathname === href; + if (href === '/dashboard' || href === '/interview') return pathname === href; + if (href === '/practice') return pathname === '/practice' || pathname.startsWith('/practice/'); return pathname === href || pathname.startsWith(href + '/'); }; diff --git a/src/lib/practice/storage.ts b/src/lib/practice/storage.ts new file mode 100644 index 0000000..d796ac9 --- /dev/null +++ b/src/lib/practice/storage.ts @@ -0,0 +1,51 @@ +// Practice persistence helpers +// Shared module so both the listing page and drill page use the same logic. + +import type { CanvasNode, Connection } from '@/components/canvas/DesignCanvas'; + +const SOLVED_KEY = 'practice_solved'; +const progressKey = (id: string) => `practice_progress_${id}`; + +export function getSavedProgress(templateId: string): { nodes: CanvasNode[]; connections: Connection[] } | null { + try { + const data = localStorage.getItem(progressKey(templateId)); + return data ? JSON.parse(data) : null; + } catch { return null; } +} + +export function saveProgress(templateId: string, nodes: CanvasNode[], connections: Connection[]) { + try { + localStorage.setItem(progressKey(templateId), JSON.stringify({ nodes, connections })); + } catch { /* quota exceeded — silently fail */ } +} + +export function clearProgress(templateId: string) { + try { localStorage.removeItem(progressKey(templateId)); } catch { /* ignore */ } +} + +export function getSolvedIds(): string[] { + try { + return JSON.parse(localStorage.getItem(SOLVED_KEY) || '[]') as string[]; + } catch { return []; } +} + +export function isSolved(templateId: string): boolean { + return getSolvedIds().includes(templateId); +} + +export function markSolved(templateId: string) { + try { + const solved = getSolvedIds(); + if (!solved.includes(templateId)) { + solved.push(templateId); + localStorage.setItem(SOLVED_KEY, JSON.stringify(solved)); + } + } catch { /* silently fail */ } +} + +export function unmarkSolved(templateId: string) { + try { + const solved = getSolvedIds().filter(s => s !== templateId); + localStorage.setItem(SOLVED_KEY, JSON.stringify(solved)); + } catch { /* ignore */ } +} diff --git a/src/lib/simulation/engine.ts b/src/lib/simulation/engine.ts index 5a19e32..a913519 100644 --- a/src/lib/simulation/engine.ts +++ b/src/lib/simulation/engine.ts @@ -1,6 +1,9 @@ import { ICanvasNode, IConnection } from '../db/models/Design'; import { NODE_CAPACITIES, NodeMetrics, EdgeMetrics } from './constants'; +/** Fraction of traffic that passes through a Cache node (cache misses). 0.1 = 90% hit rate. */ +const CACHE_MISS_RATIO = 0.1; + export interface SimulationResult { nodeMetrics: Record; edgeMetrics: Record; @@ -111,8 +114,8 @@ export function runSimulation(nodes: ICanvasNode[], edges: IConnection[], target // Distribute to children const outgoingEdges = adj[node.id]; if (outgoingEdges && outgoingEdges.length > 0) { - // Cache nodes absorb most traffic (90% hit rate), only forwarding misses - const effectiveOut = node.type === 'Cache' ? outFlow * 0.1 : outFlow; + // Cache nodes absorb most traffic; only cache misses flow downstream + const effectiveOut = node.type === 'Cache' ? outFlow * CACHE_MISS_RATIO : outFlow; const flowPerEdge = effectiveOut / outgoingEdges.length; outgoingEdges.forEach(edge => { edgeMetrics[edge.id].trafficFlow += flowPerEdge; From 72ad5d89621452007473ebf9d8cfde6cd5b927ee Mon Sep 17 00:00:00 2001 From: Shashank Date: Tue, 31 Mar 2026 01:30:57 +0530 Subject: [PATCH 4/4] fix: Lint and build issues --- app/practice/[id]/page.tsx | 8 ++++++-- src/lib/practice/storage.ts | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/practice/[id]/page.tsx b/app/practice/[id]/page.tsx index afe519f..0401749 100644 --- a/app/practice/[id]/page.tsx +++ b/app/practice/[id]/page.tsx @@ -25,6 +25,7 @@ export default function PracticePage() { const [feedback, setFeedback] = useState<{ type: 'success' | 'error', text: string } | null>(null); const [showSolution, setShowSolution] = useState(false); const [solved, setSolved] = useState(false); + const [canvasVersion, setCanvasVersion] = useState(0); // Preserve user's canvas modifications across solution toggles const [userNodes, setUserNodes] = useState(null); @@ -119,8 +120,10 @@ export default function PracticePage() { if (bottleneckNodeStatus === 'normal' || bottleneckNodeStatus === 'warning') { if (simulationState.globalStatus === 'critical') { setFeedback({ type: 'error', text: `Bottleneck resolved on the target node, but another part of the system is now critical. Keep tweaking!` }); + } else if (simulationState.globalStatus === 'degraded') { + setFeedback({ type: 'error', text: `Target bottleneck resolved, but the system is still degraded. Other nodes are under stress — optimize further.` }); } else { - // SUCCESS — persist solved state + // SUCCESS — globalStatus is 'healthy' setSolved(true); markSolved(id); if (canvasStateRef.current) { @@ -139,6 +142,7 @@ export default function PracticePage() { setUserConnections(null); setFeedback(null); setSolved(false); + setCanvasVersion(v => v + 1); clearProgress(id); unmarkSolved(id); }; @@ -226,7 +230,7 @@ export default function PracticePage() { {!showSolution && } `practice_progress_${id}`; export function getSavedProgress(templateId: string): { nodes: CanvasNode[]; connections: Connection[] } | null { try { - const data = localStorage.getItem(progressKey(templateId)); - return data ? JSON.parse(data) : null; + const raw = localStorage.getItem(progressKey(templateId)); + if (!raw) return null; + const parsed = JSON.parse(raw); + if ( + parsed != null && + typeof parsed === 'object' && + Array.isArray(parsed.nodes) && + Array.isArray(parsed.connections) + ) { + return parsed as { nodes: CanvasNode[]; connections: Connection[] }; + } + return null; } catch { return null; } } @@ -25,7 +35,9 @@ export function clearProgress(templateId: string) { export function getSolvedIds(): string[] { try { - return JSON.parse(localStorage.getItem(SOLVED_KEY) || '[]') as string[]; + const parsed = JSON.parse(localStorage.getItem(SOLVED_KEY) || '[]'); + if (!Array.isArray(parsed)) return []; + return parsed.filter((v): v is string => typeof v === 'string'); } catch { return []; } }