learning progression + completion tracking

This commit is contained in:
2025-09-12 14:12:38 +01:00
parent 385ff2711e
commit 4032d528af
2 changed files with 91 additions and 5 deletions

View File

@@ -1,7 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { getDifficultyVariant, capitalize } from "@/lib/utils"; import { getDifficultyVariant, capitalize } from "@/lib/utils";
import { isQuestionCompleted } from "@/lib/progress";
import { CheckCircle, Star } from "lucide-react";
import type { LearningProgression as LearningProgressionType } from "@/types"; import type { LearningProgression as LearningProgressionType } from "@/types";
interface LearningProgressionProps { interface LearningProgressionProps {
@@ -9,6 +14,24 @@ interface LearningProgressionProps {
} }
export function LearningProgression({ progression }: LearningProgressionProps) { export function LearningProgression({ progression }: LearningProgressionProps) {
const [completedSlugs, setCompletedSlugs] = useState<Set<string>>(new Set());
// Load completion status on mount (client-side only)
useEffect(() => {
const allQuestions = [
...progression.warmup,
...progression.core,
...progression.challenge,
];
const completed = new Set<string>();
for (const q of allQuestions) {
if (isQuestionCompleted(q.slug)) {
completed.add(q.slug);
}
}
setCompletedSlugs(completed);
}, [progression]);
const hasQuestions = const hasQuestions =
progression.warmup.length > 0 || progression.warmup.length > 0 ||
progression.core.length > 0 || progression.core.length > 0 ||
@@ -16,6 +39,11 @@ export function LearningProgression({ progression }: LearningProgressionProps) {
if (!hasQuestions) return null; if (!hasQuestions) return null;
// Calculate completion stats per tier
const warmupCompleted = progression.warmup.filter(q => completedSlugs.has(q.slug)).length;
const coreCompleted = progression.core.filter(q => completedSlugs.has(q.slug)).length;
const challengeCompleted = progression.challenge.filter(q => completedSlugs.has(q.slug)).length;
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -30,13 +58,18 @@ export function LearningProgression({ progression }: LearningProgressionProps) {
1 1
</span> </span>
Warmup Warmup
{progression.warmup.length > 0 && (
<span className="ml-auto text-xs font-normal text-[var(--muted-foreground)]">
{warmupCompleted}/{progression.warmup.length}
</span>
)}
</h4> </h4>
<p className="text-sm text-[var(--muted-foreground)] mb-3"> <p className="text-sm text-[var(--muted-foreground)] mb-3">
Start here to build foundational understanding. Start here to build foundational understanding.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{progression.warmup.map((q) => ( {progression.warmup.map((q) => (
<QuestionLink key={q.id} question={q} /> <QuestionLink key={q.id} question={q} isCompleted={completedSlugs.has(q.slug)} />
))} ))}
</div> </div>
</div> </div>
@@ -49,13 +82,18 @@ export function LearningProgression({ progression }: LearningProgressionProps) {
2 2
</span> </span>
Core Practice Core Practice
{progression.core.length > 0 && (
<span className="ml-auto text-xs font-normal text-[var(--muted-foreground)]">
{coreCompleted}/{progression.core.length}
</span>
)}
</h4> </h4>
<p className="text-sm text-[var(--muted-foreground)] mb-3"> <p className="text-sm text-[var(--muted-foreground)] mb-3">
Master the pattern with these representative problems. Master the pattern with these representative problems.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{progression.core.map((q) => ( {progression.core.map((q) => (
<QuestionLink key={q.id} question={q} /> <QuestionLink key={q.id} question={q} isCompleted={completedSlugs.has(q.slug)} />
))} ))}
</div> </div>
</div> </div>
@@ -68,13 +106,18 @@ export function LearningProgression({ progression }: LearningProgressionProps) {
3 3
</span> </span>
Challenge Challenge
{progression.challenge.length > 0 && (
<span className="ml-auto text-xs font-normal text-[var(--muted-foreground)]">
{challengeCompleted}/{progression.challenge.length}
</span>
)}
</h4> </h4>
<p className="text-sm text-[var(--muted-foreground)] mb-3"> <p className="text-sm text-[var(--muted-foreground)] mb-3">
Test your mastery with complex variations. Test your mastery with complex variations.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{progression.challenge.map((q) => ( {progression.challenge.map((q) => (
<QuestionLink key={q.id} question={q} /> <QuestionLink key={q.id} question={q} isCompleted={completedSlugs.has(q.slug)} />
))} ))}
</div> </div>
</div> </div>
@@ -87,15 +130,27 @@ export function LearningProgression({ progression }: LearningProgressionProps) {
interface QuestionLinkProps { interface QuestionLinkProps {
question: LearningProgressionType["warmup"][number]; question: LearningProgressionType["warmup"][number];
isCompleted: boolean;
} }
function QuestionLink({ question }: QuestionLinkProps) { function QuestionLink({ question, isCompleted }: QuestionLinkProps) {
return ( return (
<Link <Link
href={`/questions/${question.slug}`} href={`/questions/${question.slug}`}
className="flex items-center justify-between p-3 rounded-lg border border-[var(--border)] bg-[var(--card)] hover:border-[var(--primary)] transition-colors" className="flex items-center justify-between p-3 rounded-lg border border-[var(--border)] bg-[var(--card)] hover:border-[var(--primary)] transition-colors"
> >
<div className="flex items-center gap-2">
{isCompleted && (
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
)}
<span className="font-medium">{question.title}</span> <span className="font-medium">{question.title}</span>
{question.is_optimal && (
<span className="flex items-center gap-1 text-xs text-amber-500" title="This pattern is optimal for this problem">
<Star className="h-3 w-3 fill-current" />
Optimal
</span>
)}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{question.leetcode_id && ( {question.leetcode_id && (
<span className="text-xs text-[var(--muted-foreground)]"> <span className="text-xs text-[var(--muted-foreground)]">

View File

@@ -52,6 +52,7 @@ export interface LearningQuestion {
slug: string; slug: string;
difficulty: Difficulty; difficulty: Difficulty;
leetcode_id: number | null; leetcode_id: number | null;
is_optimal: boolean;
} }
export interface LearningProgression { export interface LearningProgression {
@@ -118,6 +119,7 @@ export interface Explanation {
time_complexity: string; time_complexity: string;
space_complexity: string; space_complexity: string;
complexity_explanation: string | null; complexity_explanation: string | null;
pattern_comparison: string | null;
} }
export interface Solution { export interface Solution {
@@ -202,6 +204,35 @@ export interface HiddenTestOutput {
output: unknown; output: unknown;
} }
// Progress Dashboard Types
export interface QuestionProgress {
slug: string;
completedAt: string;
attempts: number;
timeSpentMs: number;
lastAttemptAt: string;
primaryPattern: string;
difficulty: Difficulty;
code: string;
}
export interface ProgressData {
version: 1;
questions: Record<string, QuestionProgress>;
patternsStudied: string[];
totalTimeMs: number;
lastActiveAt: string;
}
export interface ReviewCandidate {
slug: string;
score: number;
daysSinceReview: number;
difficulty: Difficulty;
pattern: string;
}
export interface SubmissionRequest { export interface SubmissionRequest {
code: string; code: string;
hidden_outputs: HiddenTestOutput[]; hidden_outputs: HiddenTestOutput[];