diff --git a/frontend/components/best-practices/AnalysisHeader.tsx b/frontend/components/best-practices/AnalysisHeader.tsx deleted file mode 100644 index e5cdfcd..0000000 --- a/frontend/components/best-practices/AnalysisHeader.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import { CheckCircle2, Copy, Check, Sparkles } from "lucide-react"; -import { useState } from "react"; - -export function AnalysisHeader({ - cached, - onCopy, -}: { - cached: boolean; - onCopy: () => void; -}) { - const [copied, setCopied] = useState(false); - - const copy = async () => { - await onCopy(); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; - - return ( -
-
- - AI Analysis - - NIM - -
- {cached && ( - - - cached - - )} - -
- ); -} diff --git a/frontend/components/best-practices/AnalysisHydration.tsx b/frontend/components/best-practices/AnalysisHydration.tsx index 84b961e..004df2e 100644 --- a/frontend/components/best-practices/AnalysisHydration.tsx +++ b/frontend/components/best-practices/AnalysisHydration.tsx @@ -3,9 +3,9 @@ import { Loader2 } from "lucide-react"; import type { SolutionExplanation } from "@/lib/types"; import { AnalysisSummary } from "./AnalysisSummary"; -import { AnalysisApproach } from "./AnalysisApproach"; -import { AnalysisScorecard } from "./AnalysisScorecard"; import { AnalysisComplexity } from "./AnalysisComplexity"; +import { QualityGauge, QualityGaugeSkeleton } from "./QualityGauge"; +import { ScoreRadar } from "./ScoreRadar"; function Shimmer({ className }: { className?: string }) { return
; @@ -16,8 +16,15 @@ export function AnalysisHydration({ }: { partial: Partial | null; }) { + const hasRadar = + partial && + partial.efficiency_score != null && + partial.readability_score != null && + partial.correctness_score != null && + partial.best_practices_score != null; + return ( -
+
Generating analysis @@ -29,40 +36,48 @@ export function AnalysisHydration({
- {partial?.summary ? ( - - ) : ( -
- - - -
- )} - - {partial?.quality_score != null ? ( - - ) : ( - - )} + {/* Telemetry shimmer grid — mirrors the rendered layout */} +
+ {partial?.quality_score != null ? ( + + ) : ( + + )} + {hasRadar ? ( + + ) : ( +
+ + +
+ )} +
- {partial?.approach ? ( - - ) : ( -
- - - -
- )} +
+ {partial?.summary ? ( + + ) : ( +
+ + + +
+ )} - {partial?.time_complexity && partial?.space_complexity ? ( - - ) : ( - - )} + {partial?.time_complexity && partial?.space_complexity ? ( + + ) : ( + + )} +
); } \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisModal.tsx b/frontend/components/best-practices/AnalysisModal.tsx new file mode 100644 index 0000000..bafa945 --- /dev/null +++ b/frontend/components/best-practices/AnalysisModal.tsx @@ -0,0 +1,399 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Check, + CheckCircle2, + Copy, + MessageSquare, + Sparkles, +} from "lucide-react"; +import type { CommunitySolution, SolutionExplanation } from "@/lib/types"; +import { + explainSolutionChatStream, + explainSolutionStream, +} from "@/lib/api"; +import { toast } from "@/lib/toast"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { CodeSnippet } from "@/components/application/code-snippet"; +import { ExplainPanel } from "./ExplainPanel"; +import { FollowUpDrawer } from "./FollowUpDrawer"; +import { solutionFilename, solutionLanguage } from "./parts"; +import type { ExplainErrorInfo } from "./AnalysisError"; +import type { ChatMessage } from "./chat/types"; + +const STRING_FIELDS = [ + "summary", + "approach", + "time_complexity", + "space_complexity", +] as const; +const ARRAY_FIELDS = [ + "key_techniques", + "strengths", + "improvements", +] as const; +const SCORE_FIELDS = [ + "quality_score", + "efficiency_score", + "readability_score", + "correctness_score", + "best_practices_score", +] as const; + +// Best-effort progressive parse of the accumulating JSON. The server streams +// the analysis in a single JSON object, so until the closing brace arrives +// JSON.parse always fails — instead we scan for complete field values so the +// panel can hydrate live while generation is still running. +function hydratePartial(raw: string): Partial | null { + if (!raw.trim()) return null; + const out: Partial = {}; + let any = false; + + for (const key of STRING_FIELDS) { + const m = raw.match( + new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`), + ); + if (m) { + try { + out[key] = JSON.parse(`"${m[1]}"`); + any = true; + } catch { + // incomplete escape — keep waiting + } + } + } + + for (const key of ARRAY_FIELDS) { + const m = raw.match(new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]*)\\]`)); + if (m) { + const items = + m[1] + .match(/"((?:[^"\\]|\\.)*)"/g) + ?.map((tok) => { + try { + return JSON.parse(tok) as unknown; + } catch { + return null; + } + }) + .filter((x): x is string => typeof x === "string") ?? []; + if (items.length) { + out[key] = items; + any = true; + } + } + } + + for (const key of SCORE_FIELDS) { + const m = raw.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`)); + if (m) { + out[key] = Number(m[1]); + any = true; + } + } + + return any ? out : null; +} + +export function AnalysisModal({ + solution, + open, + onOpenChange, +}: { + solution: CommunitySolution; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + // Analysis streaming state. + const [explanation, setExplanation] = useState(null); + const [cached, setCached] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [partial, setPartial] = useState | null>(null); + const rawRef = useRef(""); + + // Chat state (rendered via FollowUpDrawer). + const [chatOpen, setChatOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [question, setQuestion] = useState(""); + const [chatLoading, setChatLoading] = useState(false); + const [streamingAnswer, setStreamingAnswer] = useState(null); + const pendingAnswerRef = useRef(""); + + // Clipboard state. + const [copiedCode, setCopiedCode] = useState(false); + const [copiedAnalysis, setCopiedAnalysis] = useState(false); + const codeTimer = useRef | null>(null); + const analysisTimer = useRef | null>(null); + + const filename = solutionFilename(solution.language); + const language = solutionLanguage(solution.language); + + useEffect( + () => () => { + if (codeTimer.current) clearTimeout(codeTimer.current); + if (analysisTimer.current) clearTimeout(analysisTimer.current); + }, + [], + ); + + const loadExplanation = useCallback(async () => { + if (explanation || loading) return; + setLoading(true); + setError(null); + rawRef.current = ""; + setPartial(null); + + await explainSolutionStream(solution.id, { + onDelta: (delta) => { + rawRef.current += delta; + const p = hydratePartial(rawRef.current); + if (p) setPartial(p); + }, + onFinal: (exp, wasCached) => { + setExplanation(exp); + setCached(wasCached); + setLoading(false); + }, + onError: (code, message) => { + setError({ message, code }); + setLoading(false); + }, + }); + }, [explanation, loading, solution.id]); + + // The analysis starts the moment the modal opens — and only once. + useEffect(() => { + if (!open) return; + const t = setTimeout(() => { + loadExplanation(); + }, 0); + return () => clearTimeout(t); + }, [open, loadExplanation]); + + const sendChat = async () => { + const q = question.trim(); + if (!q || chatLoading) return; + setMessages((prev) => [...prev, { role: "user", content: q }]); + setQuestion(""); + setChatLoading(true); + pendingAnswerRef.current = ""; + setStreamingAnswer(""); + + await explainSolutionChatStream(solution.id, q, { + onDelta: (delta) => { + pendingAnswerRef.current += delta; + setStreamingAnswer(pendingAnswerRef.current); + }, + onError: (code, message) => { + if (code === "AUTH_REQUIRED") { + toast.error(message); + } else if (code === "AI_RATE_LIMITED") { + toast.error("You have used up the AI chat quota for this minute. Wait a moment, then retry."); + } else { + toast.error(message || "Failed to get an answer"); + } + }, + }); + + // Snapshot the accumulated text before clearing the ref. Reading the ref + // inside the setMessages updater would see "" instead: React batches these + // state updates and runs the updater after this synchronous block, by which + // point pendingAnswerRef has already been reset. + const finalAnswer = pendingAnswerRef.current; + pendingAnswerRef.current = ""; + setStreamingAnswer(null); + setChatLoading(false); + if (finalAnswer) { + setMessages((prev) => [...prev, { role: "ai", content: finalAnswer }]); + } + }; + + const copyCode = async () => { + if (!navigator.clipboard?.writeText) return; + try { + await navigator.clipboard.writeText(solution.code); + setCopiedCode(true); + if (codeTimer.current) clearTimeout(codeTimer.current); + codeTimer.current = setTimeout(() => setCopiedCode(false), 1500); + } catch { + /* clipboard unavailable */ + } + }; + + const copyAnalysis = async () => { + if (!explanation) return; + const sections = [ + `AI Analysis — ${solution.problem_title || solution.problem_slug}`, + "", + `Summary: ${explanation.summary}`, + "", + `Approach: ${explanation.approach}`, + "", + `Time complexity: ${explanation.time_complexity}`, + `Space complexity: ${explanation.space_complexity}`, + "", + `Scores — Quality: ${explanation.quality_score}/100, Efficiency: ${explanation.efficiency_score}/100, Readability: ${explanation.readability_score}/100, Correctness: ${explanation.correctness_score}/100, Best practices: ${explanation.best_practices_score}/100`, + ]; + if (explanation.key_techniques.length) { + sections.push("", `Key techniques: ${explanation.key_techniques.join(", ")}`); + } + if (explanation.strengths.length) { + sections.push("", "Strengths:", ...explanation.strengths.map((s) => `- ${s}`)); + } + if (explanation.improvements.length) { + sections.push("", "Improvements:", ...explanation.improvements.map((s) => `- ${s}`)); + } + try { + await navigator.clipboard.writeText(sections.join("\n")); + setCopiedAnalysis(true); + if (analysisTimer.current) clearTimeout(analysisTimer.current); + analysisTimer.current = setTimeout(() => setCopiedAnalysis(false), 1500); + } catch { + toast.error("Could not copy analysis"); + } + }; + + return ( + <> + + + {/* Header */} + + + + Solution Analysis + + + NIM + + {cached && ( + + + cached + + )} + + Close + + + + + + + + {/* 50/50 Split Body */} +
+ {/* LEFT: Code Viewer Pane */} +
+
+ + {filename} + + + {language} + + +
+
+ +
+
+ + {/* RIGHT: Telemetry & Tabbed Analysis */} +
+ +
+
+ + {/* Footer Actions */} +
+ + +
+
+
+ + {/* Follow-up chat — renders above the dialog (z-100 > z-50). */} + setChatOpen(false)} + title={solution.problem_title || solution.problem_slug || "Solution"} + messages={messages} + loading={chatLoading} + streaming={streamingAnswer} + question={question} + onQuestionChange={setQuestion} + onSend={sendChat} + /> + + ); +} \ No newline at end of file diff --git a/frontend/components/best-practices/AnalysisScorecard.tsx b/frontend/components/best-practices/AnalysisScorecard.tsx deleted file mode 100644 index cfcefa1..0000000 --- a/frontend/components/best-practices/AnalysisScorecard.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import type { CSSProperties } from "react"; -import { Progress } from "@/components/ui/progress"; -import type { SolutionExplanation } from "@/lib/types"; - -export function gradeScore(score: number): { label: string; color: string } { - if (score >= 85) return { label: "Excellent", color: "#10b981" }; - if (score >= 70) return { label: "Good", color: "#14b8a6" }; - if (score >= 50) return { label: "Fair", color: "#f59e0b" }; - return { label: "Needs work", color: "#f43f5e" }; -} - -const DIMENSIONS: Array<{ - label: string; - score: (e: SolutionExplanation) => number; -}> = [ - { label: "Efficiency", score: (e) => e.efficiency_score }, - { label: "Readability", score: (e) => e.readability_score }, - { label: "Correctness", score: (e) => e.correctness_score }, - { label: "Best practices", score: (e) => e.best_practices_score }, -]; - -export function AnalysisScorecard({ - explanation, -}: { - explanation: SolutionExplanation; -}) { - const overall = gradeScore(explanation.quality_score); - - return ( -
-
- - {explanation.quality_score} - - - {overall.label} - - -
-
- {DIMENSIONS.map((d) => { - const g = gradeScore(d.score(explanation)); - return ( - - - {d.label} - - {d.score(explanation)} - - - ); - })} -
-
- ); -} \ No newline at end of file diff --git a/frontend/components/best-practices/ComplexityScale.tsx b/frontend/components/best-practices/ComplexityScale.tsx new file mode 100644 index 0000000..2b2d130 --- /dev/null +++ b/frontend/components/best-practices/ComplexityScale.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const TIERS: Array<{ label: string; desc: string; width: string; barCls: string }> = [ + { label: "O(1)", desc: "Constant — one operation, regardless of input", width: "8%", barCls: "bg-emerald-500" }, + { label: "O(log n)", desc: "Logarithmic — halves the input each step (binary search)", width: "20%", barCls: "bg-teal-500" }, + { label: "O(n)", desc: "Linear — one pass over the input", width: "45%", barCls: "bg-amber-500" }, + { label: "O(n log n)", desc: "Linearithmic — efficient sorts (merge sort)", width: "62%", barCls: "bg-orange-500" }, + { label: "O(n²)", desc: "Quadratic — nested loops over the input", width: "78%", barCls: "bg-orange-500" }, + { label: "O(2ⁿ)", desc: "Exponential — doubles per extra element", width: "90%", barCls: "bg-rose-500" }, + { label: "O(n!)", desc: "Factorial — permutations of the input", width: "100%", barCls: "bg-rose-500" }, +]; + +export function ComplexityScale() { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( +
+ {TIERS.map((t) => ( +
+ + {t.label} + +
+
+
+ + {t.desc} + +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/components/best-practices/ExplainPanel.tsx b/frontend/components/best-practices/ExplainPanel.tsx index 5bee05b..d2728bc 100644 --- a/frontend/components/best-practices/ExplainPanel.tsx +++ b/frontend/components/best-practices/ExplainPanel.tsx @@ -1,341 +1,111 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Bot, - Check, - CheckCircle2, - Copy, - MessageSquare, - Sparkles, -} from "lucide-react"; -import type { CommunitySolution, SolutionExplanation } from "@/lib/types"; -import { - explainSolutionStream, - explainSolutionChatStream, -} from "@/lib/api"; -import { toast } from "@/lib/toast"; +import { useState } from "react"; +import type { SolutionExplanation } from "@/lib/types"; +import { cn } from "@/lib/utils"; import { AnalysisHydration } from "./AnalysisHydration"; import { AnalysisError, type ExplainErrorInfo } from "./AnalysisError"; +import { QualityGauge } from "./QualityGauge"; +import { ScoreRadar } from "./ScoreRadar"; import { AnalysisSummary } from "./AnalysisSummary"; -import { AnalysisScorecard } from "./AnalysisScorecard"; import { AnalysisApproach } from "./AnalysisApproach"; -import { AnalysisComplexity } from "./AnalysisComplexity"; import { AnalysisTechniques } from "./AnalysisTechniques"; import { AnalysisPoints } from "./AnalysisPoints"; -import { FollowUpDrawer } from "./FollowUpDrawer"; -import type { ChatMessage } from "./chat/types"; - -const STRING_FIELDS = [ - "summary", - "approach", - "time_complexity", - "space_complexity", -] as const; -const ARRAY_FIELDS = [ - "key_techniques", - "strengths", - "improvements", -] as const; -const SCORE_FIELDS = [ - "quality_score", - "efficiency_score", - "readability_score", - "correctness_score", - "best_practices_score", -] as const; - -// Best-effort progressive parse of the accumulating JSON. The server streams -// the analysis in a single JSON object, so until the closing brace arrives -// JSON.parse always fails — instead we scan for complete field values so the -// panel can hydrate live while generation is still running. -function hydratePartial(raw: string): Partial | null { - if (!raw.trim()) return null; - const out: Partial = {}; - let any = false; - - for (const key of STRING_FIELDS) { - const m = raw.match( - new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`), - ); - if (m) { - try { - out[key] = JSON.parse(`"${m[1]}"`); - any = true; - } catch { - // incomplete escape — keep waiting - } - } - } - - for (const key of ARRAY_FIELDS) { - const m = raw.match(new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]*)\\]`)); - if (m) { - const items = - m[1] - .match(/"((?:[^"\\]|\\.)*)"/g) - ?.map((tok) => { - try { - return JSON.parse(tok) as unknown; - } catch { - return null; - } - }) - .filter((x): x is string => typeof x === "string") ?? []; - if (items.length) { - out[key] = items; - any = true; - } - } - } +import { AnalysisComplexity } from "./AnalysisComplexity"; +import { ComplexityScale } from "./ComplexityScale"; - for (const key of SCORE_FIELDS) { - const m = raw.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`)); - if (m) { - out[key] = Number(m[1]); - any = true; - } - } +type TabType = "overview" | "approach" | "points"; - return any ? out : null; -} +const TABS: Array<{ id: TabType; label: string }> = [ + { id: "overview", label: "Overview" }, + { id: "approach", label: "Approach" }, + { id: "points", label: "Pros & Cons" }, +]; export function ExplainPanel({ - solution, - autoStart = false, + explanation, + loading, + error, + partial, + onRetry, }: { - solution: CommunitySolution; - autoStart?: boolean; + explanation: SolutionExplanation | null; + loading: boolean; + error: ExplainErrorInfo | null; + partial: Partial | null; + onRetry: () => void; }) { - const [explanation, setExplanation] = useState(null); - const [cached, setCached] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [partial, setPartial] = useState | null>(null); - const rawRef = useRef(""); - const [copied, setCopied] = useState(false); - const copyTimer = useRef | null>(null); - - const [chatOpen, setChatOpen] = useState(false); - const [messages, setMessages] = useState([]); - const [question, setQuestion] = useState(""); - const [chatLoading, setChatLoading] = useState(false); - const [streamingAnswer, setStreamingAnswer] = useState(null); - const pendingAnswerRef = useRef(""); - - useEffect( - () => () => { - if (copyTimer.current) clearTimeout(copyTimer.current); - }, - [], - ); - - const loadExplanation = useCallback(async () => { - if (explanation || loading) return; - setLoading(true); - setError(null); - rawRef.current = ""; - setPartial(null); + const [activeTab, setActiveTab] = useState("overview"); - await explainSolutionStream(solution.id, { - onDelta: (delta) => { - rawRef.current += delta; - const p = hydratePartial(rawRef.current); - if (p) setPartial(p); - }, - onFinal: (exp, wasCached) => { - setExplanation(exp); - setCached(wasCached); - setLoading(false); - }, - onError: (code, message) => { - setError({ message, code }); - setLoading(false); - }, - }); - }, [explanation, loading, solution.id]); - - // The analysis only starts when the user asks for it — either via the - // "Run AI Analysis" button or the autoStart signal from the AI pill. - useEffect(() => { - if (!autoStart) return; - const t = setTimeout(() => { - loadExplanation(); - }, 0); - return () => clearTimeout(t); - }, [autoStart, loadExplanation]); - - const sendChat = async () => { - const q = question.trim(); - if (!q || chatLoading) return; - setMessages((prev) => [...prev, { role: "user", content: q }]); - setQuestion(""); - setChatLoading(true); - pendingAnswerRef.current = ""; - setStreamingAnswer(""); - - await explainSolutionChatStream(solution.id, q, { - onDelta: (delta) => { - pendingAnswerRef.current += delta; - setStreamingAnswer(pendingAnswerRef.current); - }, - onError: (code, message) => { - if (code === "AUTH_REQUIRED") { - toast.error(message); - } else if (code === "AI_RATE_LIMITED") { - toast.error("You have used up the AI chat quota for this minute. Wait a moment, then retry."); - } else { - toast.error(message || "Failed to get an answer"); - } - }, - }); + if (loading && !explanation) { + return ; + } - // Snapshot the accumulated text before clearing the ref. Reading the ref - // inside the setMessages updater would see "" instead: React batches these - // state updates and runs the updater after this synchronous block, by which - // point pendingAnswerRef has already been reset. - const finalAnswer = pendingAnswerRef.current; - pendingAnswerRef.current = ""; - setStreamingAnswer(null); - setChatLoading(false); - if (finalAnswer) { - setMessages((prev) => [...prev, { role: "ai", content: finalAnswer }]); - } - }; + if (!loading && !explanation && error) { + return ; + } - const copyAnalysis = async () => { - if (!explanation) return; - const sections = [ - `AI Analysis — ${solution.problem_title || solution.problem_slug}`, - "", - `Summary: ${explanation.summary}`, - "", - `Approach: ${explanation.approach}`, - "", - `Time complexity: ${explanation.time_complexity}`, - `Space complexity: ${explanation.space_complexity}`, - "", - `Scores — Quality: ${explanation.quality_score}/100, Efficiency: ${explanation.efficiency_score}/100, Readability: ${explanation.readability_score}/100, Correctness: ${explanation.correctness_score}/100, Best practices: ${explanation.best_practices_score}/100`, - ]; - if (explanation.key_techniques.length) { - sections.push("", `Key techniques: ${explanation.key_techniques.join(", ")}`); - } - if (explanation.strengths.length) { - sections.push("", "Strengths:", ...explanation.strengths.map((s) => `- ${s}`)); - } - if (explanation.improvements.length) { - sections.push("", "Improvements:", ...explanation.improvements.map((s) => `- ${s}`)); - } - try { - await navigator.clipboard.writeText(sections.join("\n")); - setCopied(true); - if (copyTimer.current) clearTimeout(copyTimer.current); - copyTimer.current = setTimeout(() => setCopied(false), 1500); - } catch { - toast.error("Could not copy analysis"); - } - }; + if (!explanation) return null; return ( -
- {!loading && !explanation && !error && ( -
-
- - AI Analysis - - NIM - -
+
+ {/* Top Telemetry: Quality Gauge + Score Radar */} +
+ + +
+ + {/* Tab Controls */} +
+ {TABS.map((tab) => ( -
- )} - - {loading && !explanation && ( -
- -
- )} - - {!loading && !explanation && error && ( -
- -
- )} - - {explanation && ( -
-
-
- - AI Analysis - - NIM - -
- {cached && ( - - - cached - - )} -
- - -
-
- - - - - - + ))} +
+ + {/* Scrollable Tab Content */} +
+ {activeTab === "overview" && ( + <> + + + + + )} + + {activeTab === "approach" && ( + <> + + + + )} + + {activeTab === "points" && ( -
- )} - - setChatOpen(false)} - title={solution.problem_title || solution.problem_slug || "Solution"} - messages={messages} - loading={chatLoading} - streaming={streamingAnswer} - question={question} - onQuestionChange={setQuestion} - onSend={sendChat} - /> + )} +
); } \ No newline at end of file diff --git a/frontend/components/best-practices/QualityGauge.tsx b/frontend/components/best-practices/QualityGauge.tsx new file mode 100644 index 0000000..c49de72 --- /dev/null +++ b/frontend/components/best-practices/QualityGauge.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Star } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const SIZE = 110; +const STROKE = 8; +const RADIUS = (SIZE - STROKE) / 2; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +export function gradeScore(score: number): { label: string; color: string } { + if (score >= 85) return { label: "Excellent", color: "#10b981" }; + if (score >= 70) return { label: "Good", color: "#14b8a6" }; + if (score >= 50) return { label: "Fair", color: "#f59e0b" }; + return { label: "Needs work", color: "#f43f5e" }; +} + +function Stars({ value }: { value: number }) { + return ( + + ); +} + +export function QualityGauge({ score }: { score: number }) { + const grade = gradeScore(score); + const stars = (score / 100) * 5; + + return ( +
+
+ + + + +
+ + {score} + + + {grade.label} + +
+
+ +
+ + + Quality Score + +
+
+ ); +} + +export function QualityGaugeSkeleton({ className }: { className?: string }) { + return ( +
+
+ + + + +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/best-practices/ScoreRadar.tsx b/frontend/components/best-practices/ScoreRadar.tsx new file mode 100644 index 0000000..a6e04f1 --- /dev/null +++ b/frontend/components/best-practices/ScoreRadar.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { + PolarAngleAxis, + PolarGrid, + Radar, + RadarChart, + ResponsiveContainer, +} from "recharts"; + +export function ScoreRadar({ + efficiency, + readability, + correctness, + bestPractices, +}: { + efficiency: number; + readability: number; + correctness: number; + bestPractices: number; +}) { + const data = [ + { subject: "Efficiency", score: efficiency }, + { subject: "Readability", score: readability }, + { subject: "Correctness", score: correctness }, + { subject: "Best practices", score: bestPractices }, + ]; + + return ( +
+ + Skill Breakdown + +
+ + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/best-practices/SolutionCard.tsx b/frontend/components/best-practices/SolutionCard.tsx index 539ea5f..5fcbb99 100644 --- a/frontend/components/best-practices/SolutionCard.tsx +++ b/frontend/components/best-practices/SolutionCard.tsx @@ -6,7 +6,7 @@ import { Check, ChevronDown, Copy, Zap } from "lucide-react"; import { CommunitySolution } from "@/lib/types"; import { cn, formatRelativeTime } from "@/lib/utils"; import { CodeSnippet } from "@/components/application/code-snippet"; -import { ExplainPanel } from "./ExplainPanel"; +import { AnalysisModal } from "./AnalysisModal"; import { AIAnalysisPill, LikeButton, @@ -44,7 +44,7 @@ export function SolutionCard({ onLike: (id: string, currentlyLiked: boolean) => void; }) { const [expanded, setExpanded] = useState(false); - const [aiRequested, setAiRequested] = useState(false); + const [analysisOpen, setAnalysisOpen] = useState(false); const [copied, setCopied] = useState(false); const copyTimer = useRef | null>(null); @@ -55,11 +55,6 @@ export function SolutionCard({ [], ); - const expandWithAI = () => { - setAiRequested(true); - setExpanded(true); - }; - const toggleExpanded = () => setExpanded((v) => !v); const copyCode = async () => { @@ -120,7 +115,7 @@ export function SolutionCard({ )} {!expanded && ( - + setAnalysisOpen(true)} /> )}
@@ -229,12 +224,17 @@ export function SolutionCard({ }} />
-
)}
+ + ); } diff --git a/frontend/components/best-practices/index.ts b/frontend/components/best-practices/index.ts index af8b93c..dfe4fa5 100644 --- a/frontend/components/best-practices/index.ts +++ b/frontend/components/best-practices/index.ts @@ -5,3 +5,6 @@ export { BestPracticesHeader } from "./BestPracticesHeader"; export { BestPracticesToolbar } from "./BestPracticesToolbar"; export { BestPracticesSkeleton } from "./BestPracticesSkeleton"; export { ExplainPanel } from "./ExplainPanel"; +export { AnalysisModal } from "./AnalysisModal"; +export { QualityGauge, gradeScore } from "./QualityGauge"; +export { ScoreRadar } from "./ScoreRadar"; diff --git a/internal/api/middleware.go b/internal/api/middleware.go index bf6310b..8ea7092 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -34,9 +34,22 @@ func generateRequestID() string { return hex.EncodeToString(b) } +// requestLoggingSkipPaths are infrastructure endpoints that produce no user +// traffic value in request logs. Platform health probes (Render/Azure) hit +// these at fixed intervals, and each hit would otherwise emit a full log line +// plus a fresh request ID / CSP nonce — noise that buries real traffic. +var requestLoggingSkipPaths = map[string]struct{}{ + "/health": {}, + "/version": {}, +} + // RequestLoggingMiddleware logs every request with method, path, status, duration, and correlation ID. func RequestLoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, skip := requestLoggingSkipPaths[r.URL.Path]; skip { + next.ServeHTTP(w, r) + return + } start := time.Now() reqID := generateRequestID() r = r.WithContext(context.WithValue(r.Context(), reqIDContextKey, reqID)) diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 8cc9cb3..37529d6 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -1,8 +1,10 @@ package api import ( + "bytes" "context" "encoding/json" + "log/slog" "net/http" "net/http/httptest" "testing" @@ -616,3 +618,42 @@ func TestRateLimitMiddleware(t *testing.T) { } }) } + +func TestRequestLoggingMiddleware_SkipsHealthAndVersion(t *testing.T) { + // Redirect slog to a buffer so we can assert on log output. + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil))) + defer slog.SetDefault(prev) + + handler := RequestLoggingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + for _, path := range []string{"/health", "/version"} { + buf.Reset() + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("%s: expected 200, got %d", path, w.Code) + } + if buf.Len() != 0 { + t.Errorf("%s: expected no request log line, got: %s", path, buf.String()) + } + } + + // A normal request must still emit a request log line. + buf.Reset() + req := httptest.NewRequest(http.MethodGet, "/problems", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("/problems: expected 200, got %d", w.Code) + } + if buf.Len() == 0 { + t.Error("/problems: expected a request log line") + } +}