From d6e9a689e3a991b34d206058828ca2522be47d09 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Thu, 31 Jul 2025 13:53:39 +0100 Subject: [PATCH] submit ux improvements --- .../components/editor/problem-workspace.tsx | 685 +++++++++++++++--- 1 file changed, 574 insertions(+), 111 deletions(-) diff --git a/frontend/src/components/editor/problem-workspace.tsx b/frontend/src/components/editor/problem-workspace.tsx index 486b0dd..6a6078b 100644 --- a/frontend/src/components/editor/problem-workspace.tsx +++ b/frontend/src/components/editor/problem-workspace.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { CodeEditor } from "./code-editor"; import { TestResults } from "./test-results"; import { usePyodide } from "@/hooks/use-pyodide"; import { submitSolution } from "@/lib/api"; +import { markQuestionCompleted, getSavedSolution, isQuestionCompleted } from "@/lib/progress"; import type { QuestionDetail, TestResult, HiddenTestOutput } from "@/types"; import { Badge } from "@/components/ui/badge"; import { Markdown } from "@/components/ui/markdown"; @@ -12,6 +13,19 @@ import { CodeBlock } from "@/components/ui/code-block"; import { Callout, ApproachBox } from "@/components/ui/callout"; import { Collapsible } from "@/components/ui/collapsible"; import { getDifficultyVariant, capitalize } from "@/lib/utils"; +import { + detectProblemType, + TREE_NODE_CLASS, + LIST_NODE_CLASS, + BUILD_TREE, + TREE_TO_ARRAY, + BUILD_LIST, + LIST_TO_ARRAY, +} from "@/lib/python-helpers"; +import { + COMPLEXITY_ANALYZER, + type ComplexityResult, +} from "@/lib/complexity-analyzer"; import { FileText, BookOpen, @@ -24,6 +38,13 @@ import { Play, Send, Loader2, + PanelLeftClose, + PanelRightClose, + Columns2, + Gauge, + ChevronRight, + CheckCircle, + XCircle, } from "lucide-react"; import Link from "next/link"; @@ -38,19 +59,60 @@ interface ExecutionResult { } function extractFunctionName(signature: string): string { - const match = signature.match(/def\s+(\w+)\s*\(/); - return match ? match[1] : "solution"; + // Check for class-based signature: "class MinStack" -> "MinStack" + const classMatch = signature.match(/class\s+(\w+)/); + if (classMatch) return classMatch[1]; + + // Function: "def two_sum(" -> "two_sum" + const funcMatch = signature.match(/def\s+(\w+)\s*\(/); + return funcMatch ? funcMatch[1] : "solution"; } function generateStarterCode(signature: string): string { + // Class-based problems need a different starter template + if (signature.startsWith("class ")) { + return `${signature}: + def __init__(self): + # Initialize your data structure here + pass +`; + } + return `${signature} # Write your solution here pass `; } -type LeftTab = "problem" | "explanation"; +/** + * Compare actual and expected outputs with flexible matching. + * Handles unordered array comparison for problems like subsets. + */ +function compareOutputs(actual: unknown, expected: unknown): boolean { + // Direct equality check + if (JSON.stringify(actual) === JSON.stringify(expected)) { + return true; + } + + // Handle unordered array comparison (e.g., subsets, word-break-ii) + if (Array.isArray(actual) && Array.isArray(expected)) { + if (actual.length !== expected.length) { + return false; + } + // Sort both arrays by their JSON string representation for comparison + const sortFn = (a: unknown, b: unknown) => + JSON.stringify(a).localeCompare(JSON.stringify(b)); + const sortedActual = [...actual].sort(sortFn); + const sortedExpected = [...expected].sort(sortFn); + return JSON.stringify(sortedActual) === JSON.stringify(sortedExpected); + } + + return false; +} + +type LeftTab = "problem" | "explanation" | "solutions"; type RightTab = "code" | "results"; +type ViewMode = "split" | "left" | "right"; export function ProblemWorkspace({ question }: ProblemWorkspaceProps) { const starterCode = useMemo( @@ -60,6 +122,7 @@ export function ProblemWorkspace({ question }: ProblemWorkspaceProps) { const [code, setCode] = useState(starterCode); const [leftTab, setLeftTab] = useState("problem"); const [rightTab, setRightTab] = useState("code"); + const [viewMode, setViewMode] = useState("split"); const [hasRunTests, setHasRunTests] = useState(false); // Test execution state @@ -69,6 +132,18 @@ export function ProblemWorkspace({ question }: ProblemWorkspaceProps) { const [visibleResults, setVisibleResults] = useState([]); const [hiddenPassedCount, setHiddenPassedCount] = useState(0); const [hiddenTotalCount, setHiddenTotalCount] = useState(0); + const [complexityResult, setComplexityResult] = useState(null); + const [isComplexityExpanded, setIsComplexityExpanded] = useState(false); + const [isCompleted, setIsCompleted] = useState(false); + + // Load saved solution and completion status on mount + useEffect(() => { + const saved = getSavedSolution(question.slug); + if (saved) { + setCode(saved); + } + setIsCompleted(isQuestionCompleted(question.slug)); + }, [question.slug]); const functionName = useMemo( () => extractFunctionName(question.function_signature!), @@ -88,9 +163,127 @@ export function ProblemWorkspace({ question }: ProblemWorkspaceProps) { setCode(starterCode); }, [starterCode]); + const toggleViewMode = useCallback((panel: "left" | "right") => { + setViewMode((current) => { + if (current === "split") { + return panel; // Maximize the clicked panel + } + return "split"; // Return to split view + }); + }, []); + + const signature = question.function_signature ?? ""; + const generateTestHarness = useCallback( (tests: Array<{ id: number; input: Record }>) => { + // Use first test to detect problem type + const firstInput = tests[0]?.input ?? {}; + const problemType = detectProblemType(signature, firstInput); const testsJson = JSON.stringify(tests); + + if (problemType === "class-based") { + // Class-based harness: operations/args format + return ` +import json + +${code} + +__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}') +__results__ = [] + +for test in __tests__: + try: + operations = test["input"]["operations"] + arguments = test["input"]["args"] + outputs = [] + instance = None + for op, args in zip(operations, arguments): + if instance is None: + # First operation is always the constructor + instance = globals()[op](*args) + outputs.append(None) + else: + method_result = getattr(instance, op)(*args) + outputs.append(method_result) + __results__.append({"test_id": test["id"], "output": outputs}) + except Exception as e: + __results__.append({"test_id": test["id"], "output": None, "error": str(e)}) + +json.dumps(__results__) +`; + } + + if (problemType === "tree") { + // Tree-based harness: convert arrays to TreeNode, convert result back + return ` +import json + +${TREE_NODE_CLASS} + +${BUILD_TREE} + +${TREE_TO_ARRAY} + +${code} + +__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}') +__results__ = [] + +for test in __tests__: + try: + processed = dict(test["input"]) + # Convert tree parameters from array to TreeNode + for key in ["root", "tree", "p", "q"]: + if key in processed and isinstance(processed[key], list): + processed[key] = __build_tree(processed[key]) + result = ${functionName}(**processed) + # Convert TreeNode result back to array + if isinstance(result, TreeNode): + result = __tree_to_array(result) + __results__.append({"test_id": test["id"], "output": result}) + except Exception as e: + __results__.append({"test_id": test["id"], "output": None, "error": str(e)}) + +json.dumps(__results__) +`; + } + + if (problemType === "linkedlist") { + // Linked list harness: convert arrays to ListNode, convert result back + return ` +import json + +${LIST_NODE_CLASS} + +${BUILD_LIST} + +${LIST_TO_ARRAY} + +${code} + +__tests__ = json.loads('${testsJson.replace(/'/g, "\\'")}') +__results__ = [] + +for test in __tests__: + try: + processed = dict(test["input"]) + # Convert list parameters from array to ListNode + for key in ["head", "l1", "l2", "list1", "list2", "node"]: + if key in processed and isinstance(processed[key], list): + processed[key] = __build_list(processed[key]) + result = ${functionName}(**processed) + # Convert ListNode result back to array + if isinstance(result, ListNode): + result = __list_to_array(result) + __results__.append({"test_id": test["id"], "output": result}) + except Exception as e: + __results__.append({"test_id": test["id"], "output": None, "error": str(e)}) + +json.dumps(__results__) +`; + } + + // Simple function harness (default) return ` import json @@ -109,7 +302,7 @@ for test in __tests__: json.dumps(__results__) `; }, - [code, functionName] + [code, functionName, signature] ); const runTests = useCallback( @@ -138,12 +331,38 @@ json.dumps(__results__) [generateTestHarness, runPython] ); + const analyzeComplexity = useCallback( + async (userCode: string) => { + const analyzerCode = ` +${COMPLEXITY_ANALYZER} + +import json +__user_code = '''${userCode.replace(/'/g, "\\'")}''' +__result = analyze_complexity(__user_code) +json.dumps(__result) +`; + const { output, error } = await runPython(analyzerCode); + + if (error) { + return { success: false, error } as ComplexityResult; + } + + try { + return JSON.parse(output as string) as ComplexityResult; + } catch { + return { success: false, error: "Failed to parse analysis" } as ComplexityResult; + } + }, + [runPython] + ); + const handleRunTests = useCallback(async () => { if (!pyodide || isRunning) return; setIsRunning(true); setHiddenPassedCount(0); setHiddenTotalCount(0); + setComplexityResult(null); try { const tests = visibleTestCases.map((tc) => ({ @@ -155,8 +374,7 @@ json.dumps(__results__) const testResults: TestResult[] = results.map((r, idx) => { const testCase = visibleTestCases[idx]; const passed = - !r.error && - JSON.stringify(r.output) === JSON.stringify(testCase.expected); + !r.error && compareOutputs(r.output, testCase.expected); return { test_id: r.test_id, @@ -170,16 +388,20 @@ json.dumps(__results__) setVisibleResults(testResults); setHasRunTests(true); - setRightTab("results"); + + // Run complexity analysis in the background + const complexity = await analyzeComplexity(code); + setComplexityResult(complexity); } finally { setIsRunning(false); } - }, [pyodide, isRunning, visibleTestCases, runTests]); + }, [pyodide, isRunning, visibleTestCases, runTests, analyzeComplexity, code]); const handleSubmit = useCallback(async () => { if (!pyodide || isSubmitting) return; setIsSubmitting(true); + setComplexityResult(null); try { // Run visible tests first @@ -193,8 +415,7 @@ json.dumps(__results__) (r, idx) => { const testCase = visibleTestCases[idx]; const passed = - !r.error && - JSON.stringify(r.output) === JSON.stringify(testCase.expected); + !r.error && compareOutputs(r.output, testCase.expected); return { test_id: r.test_id, @@ -232,6 +453,20 @@ json.dumps(__results__) setHiddenPassedCount(response.total_passed); setHiddenTotalCount(response.total_tests); setHasRunTests(true); + + // Run complexity analysis in the background + const complexity = await analyzeComplexity(code); + setComplexityResult(complexity); + + // Check if all tests passed + const allVisiblePassed = visibleTestResults.every((r) => r.passed); + const allHiddenPassed = response.total_passed === response.total_tests; + if (allVisiblePassed && allHiddenPassed) { + markQuestionCompleted(question.slug, code); + setIsCompleted(true); + } + + // Auto-switch to Results tab after submit setRightTab("results"); } catch (error) { console.error("Submission error:", error); @@ -246,6 +481,7 @@ json.dumps(__results__) runTests, question.slug, code, + analyzeComplexity, ]); const isLoading = isRunning || isSubmitting; @@ -253,33 +489,57 @@ json.dumps(__results__) return (
{/* Left panel - Problem description / Explanation */} -
- {/* Header - compact */} -
-
-
- - ← Back - -

{question.title}

- - {capitalize(question.difficulty)} - + {viewMode !== "right" && ( +
+ {/* Header - compact */} +
+
+
+ + ← Back + +

{question.title}

+ + {capitalize(question.difficulty)} + + {isCompleted && ( + + + Completed + + )} +
+
+ {question.leetcode_url && ( + + LeetCode ↗ + + )} + +
- {question.leetcode_url && ( - - LeetCode ↗ - - )} -
{question.categories.map((cat) => ( @@ -320,12 +580,22 @@ json.dumps(__results__) > Explanation +
{/* Tab content - scrollable */}
- {leftTab === "problem" ? ( + {leftTab === "problem" && (
{/* Problem */}
@@ -381,7 +651,9 @@ json.dumps(__results__)
)}
- ) : ( + )} + + {leftTab === "explanation" && (
{/* Intuition */} {question.explanation?.intuition && ( @@ -474,101 +746,250 @@ json.dumps(__results__)
)} +
+ )} - {/* Solutions */} - {question.solutions && question.solutions.length > 0 && ( -
-

- - Solutions -

-
- {question.solutions.map((solution, i) => { - const content = ( -
-
-

- {solution.approach_name} -

- {solution.is_optimal && ( - Optimal - )} -
- - {solution.explanation && ( -
- {solution.explanation} -
+ {leftTab === "solutions" && ( +
+ {question.solutions && question.solutions.length > 0 ? ( +
+ {question.solutions.map((solution, i) => { + const content = ( +
+
+

+ {solution.approach_name} +

+ {solution.is_optimal && ( + Optimal )}
- ); + + {solution.explanation && ( +
+ {solution.explanation} +
+ )} +
+ ); - return solution.is_optimal ? ( -
{content}
- ) : ( - - {content} - - ); - })} -
+ return solution.is_optimal ? ( +
{content}
+ ) : ( + + {content} + + ); + })}
+ ) : ( +

+ No solutions available for this problem yet. +

)}
)}
+ )} {/* Right panel - Editor and test results */} -
- {/* Tabs header */} -
-
- - {hasRunTests && ( + {viewMode !== "left" && ( +
+ {/* Tabs header */} +
+
- )} +
+ + {hasRunTests && (() => { + const visiblePassed = visibleResults.filter(r => r.passed).length; + const visibleTotal = visibleResults.length; + const allVisiblePassed = visibleTotal > 0 && visiblePassed === visibleTotal; + const allHiddenPassed = hiddenTotalCount === 0 || hiddenPassedCount === hiddenTotalCount; + const allPassed = allVisiblePassed && allHiddenPassed; + + return ( + + ); + })()} +
+
+
- -
{/* Tab content */}
{rightTab === "code" ? ( <> - {/* Editor - full height */} + {/* Editor */}
+ {/* Complexity Analysis - shown after running tests */} + {complexityResult?.success && ( +
+
+ + + Estimated: {complexityResult.complexity} + + {(complexityResult.details?.findings?.length || + complexityResult.details?.recursive_calls?.length) && ( + + )} +
+ + {isComplexityExpanded && complexityResult.details && ( +
+ {/* Loop findings */} + {complexityResult.details.findings + ?.filter((f) => f.type === "loop") + .map((finding, i) => ( +
+
+ {finding.kind === "for" ? ( + <> + Loop over {finding.iterates_over || "iterable"} + {finding.depth && finding.depth > 1 + ? ` (nested, depth ${finding.depth})` + : ""}{" "} + → O(n) + + ) : ( + <> + While loop + {finding.depth && finding.depth > 1 + ? ` (nested, depth ${finding.depth})` + : ""} + + )} +
+
+ + L{finding.line} + + + {finding.snippet} + +
+
+ ))} + + {/* Sort operations */} + {complexityResult.details.findings + ?.filter((f) => f.kind === "sort") + .map((finding, i) => ( +
+
+ Sorting operation → O(n log n) +
+
+ + L{finding.line} + + + {finding.snippet} + +
+
+ ))} + + {/* Linear search warnings */} + {complexityResult.details.findings + ?.filter((f) => f.kind === "linear_search") + .map((finding, i) => ( +
+
+ {finding.note || "Linear search in loop"} +
+
+ + L{finding.line} + + + {finding.snippet} + +
+
+ ))} + + {/* Recursive calls */} + {complexityResult.details.recursive_calls?.map((call, i) => ( +
+
+ Recursive call +
+
+ + L{call.line} + + + {call.snippet} + +
+
+ ))} +
+ )} +
+ )} + {/* Action buttons */}
@@ -611,17 +1032,59 @@ json.dumps(__results__) ) : ( /* Results tab */ -
+
+ + {/* Complexity Analysis in Results tab */} + {complexityResult?.success && ( +
+
+ + + Estimated Complexity: {complexityResult.complexity} + +
+
+ )} + + {/* Success state with navigation */} + {(() => { + const visiblePassed = visibleResults.filter((r) => r.passed).length; + const visibleTotal = visibleResults.length; + const allVisiblePassed = visibleTotal > 0 && visiblePassed === visibleTotal; + const allHiddenPassed = + hiddenTotalCount === 0 || hiddenPassedCount === hiddenTotalCount; + const allPassed = allVisiblePassed && allHiddenPassed && hiddenTotalCount > 0; + + if (!allPassed) return null; + + return ( +
+
+ + All tests passed! +
+
+ + Back to Questions + +
+
+ ); + })()}
)}
-
+
+ )}
); }