feat(progress): spaced repetition dashboard

This commit is contained in:
2025-09-12 13:15:06 +01:00
parent 79107582f6
commit 9838519113
14 changed files with 1117 additions and 24 deletions
@@ -0,0 +1,6 @@
export { StatCard } from "./stat-card";
export { ProgressBar } from "./progress-bar";
export { ProgressOverview } from "./progress-overview";
export { PatternProgress } from "./pattern-progress";
export { ReviewSuggestions } from "./review-suggestions";
export { ProgressExport } from "./progress-export";
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { ProgressBar } from "./progress-bar";
import type { ProgressData, Pattern, QuestionListItem } from "@/types";
interface PatternProgressProps {
progressData: ProgressData;
patterns: Pattern[];
questions: QuestionListItem[];
}
interface PatternStat {
slug: string;
name: string;
completed: number;
total: number;
}
export function PatternProgress({
progressData,
patterns,
questions,
}: PatternProgressProps) {
const completedSlugs = new Set(Object.keys(progressData.questions));
// Calculate completion per pattern
const patternStats: PatternStat[] = patterns.map((pattern) => {
const patternQuestions = questions.filter((q) =>
q.patterns.some((p) => p.slug === pattern.slug)
);
const completed = patternQuestions.filter((q) =>
completedSlugs.has(q.slug)
).length;
return {
slug: pattern.slug,
name: pattern.name,
completed,
total: patternQuestions.length,
};
});
// Sort by completion percentage (descending), then by name
const sortedStats = patternStats
.filter((s) => s.total > 0)
.sort((a, b) => {
const pctA = a.completed / a.total;
const pctB = b.completed / b.total;
if (pctB !== pctA) return pctB - pctA;
return a.name.localeCompare(b.name);
});
if (sortedStats.length === 0) {
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-6">
<h2 className="text-lg font-semibold mb-4">Progress by Pattern</h2>
<p className="text-[var(--muted-foreground)]">
Complete some questions to see your progress by pattern.
</p>
</div>
);
}
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-6">
<h2 className="text-lg font-semibold mb-4">Progress by Pattern</h2>
<div className="space-y-4">
{sortedStats.map((stat) => (
<Link
key={stat.slug}
href={`/patterns/${stat.slug}`}
className="block group"
>
<ProgressBar
value={stat.completed}
max={stat.total}
label={stat.name}
className="group-hover:opacity-80 transition-opacity"
barClassName={
stat.completed === stat.total
? "bg-green-500"
: stat.completed > 0
? "bg-[var(--primary)]"
: "bg-[var(--muted)]"
}
/>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
interface ProgressBarProps {
value: number;
max: number;
label?: string;
showCount?: boolean;
className?: string;
barClassName?: string;
}
export function ProgressBar({
value,
max,
label,
showCount = true,
className,
barClassName,
}: ProgressBarProps) {
const percentage = max > 0 ? Math.round((value / max) * 100) : 0;
return (
<div className={cn("space-y-1", className)}>
{(label || showCount) && (
<div className="flex items-center justify-between text-sm">
{label && <span className="text-[var(--foreground)]">{label}</span>}
{showCount && (
<span className="text-[var(--muted-foreground)]">
{value} / {max} ({percentage}%)
</span>
)}
</div>
)}
<div className="h-2 rounded-full bg-[var(--secondary)] overflow-hidden">
<div
className={cn(
"h-full rounded-full bg-[var(--primary)] transition-all duration-300",
barClassName
)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useRef, useState } from "react";
import { exportProgressData, importProgressData } from "@/lib/progress";
import { Download, Upload, Check, AlertCircle } from "lucide-react";
interface ProgressExportProps {
onImportSuccess?: () => void;
}
export function ProgressExport({ onImportSuccess }: ProgressExportProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [importStatus, setImportStatus] = useState<
"idle" | "success" | "error"
>("idle");
const handleExport = () => {
const data = exportProgressData();
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `codetutor-progress-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
const success = importProgressData(content);
setImportStatus(success ? "success" : "error");
if (success) {
onImportSuccess?.();
}
// Reset status after 3 seconds
setTimeout(() => setImportStatus("idle"), 3000);
};
reader.readAsText(file);
// Reset file input
e.target.value = "";
};
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-6">
<h2 className="text-lg font-semibold mb-2">Export / Import Progress</h2>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
Back up your progress or transfer it to another device.
</p>
<div className="flex flex-wrap gap-3">
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded bg-[var(--secondary)] border border-[var(--border)] hover:bg-[var(--accent)] transition-colors"
>
<Download className="h-4 w-4" />
Export Progress
</button>
<button
onClick={handleImportClick}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded bg-[var(--secondary)] border border-[var(--border)] hover:bg-[var(--accent)] transition-colors"
>
{importStatus === "success" ? (
<Check className="h-4 w-4 text-green-500" />
) : importStatus === "error" ? (
<AlertCircle className="h-4 w-4 text-red-500" />
) : (
<Upload className="h-4 w-4" />
)}
{importStatus === "success"
? "Imported!"
: importStatus === "error"
? "Invalid file"
: "Import Progress"}
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
);
}
@@ -0,0 +1,108 @@
"use client";
import { StatCard } from "./stat-card";
import { CheckCircle, Clock, Layers, Target } from "lucide-react";
import type { ProgressData } from "@/types";
interface ProgressOverviewProps {
progressData: ProgressData;
totalQuestions: number;
}
function formatTime(ms: number): string {
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
return `${minutes}m`;
}
function calculateStreak(progressData: ProgressData): number {
const questions = Object.values(progressData.questions);
if (questions.length === 0) return 0;
const dates = new Set<string>();
for (const q of questions) {
const date = new Date(q.lastAttemptAt).toDateString();
dates.add(date);
}
const sortedDates = Array.from(dates)
.map((d) => new Date(d))
.sort((a, b) => b.getTime() - a.getTime());
let streak = 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = 0; i < sortedDates.length; i++) {
const expectedDate = new Date(today);
expectedDate.setDate(today.getDate() - i);
expectedDate.setHours(0, 0, 0, 0);
const date = sortedDates[i];
date.setHours(0, 0, 0, 0);
if (date.getTime() === expectedDate.getTime()) {
streak++;
} else if (i === 0 && date.getTime() < expectedDate.getTime()) {
// First date is not today - check if yesterday
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
if (date.getTime() === yesterday.getTime()) {
streak = 1;
} else {
break;
}
} else {
break;
}
}
return streak;
}
export function ProgressOverview({
progressData,
totalQuestions,
}: ProgressOverviewProps) {
const completedCount = Object.keys(progressData.questions).length;
const patternsCount = progressData.patternsStudied.length;
const totalTime = formatTime(progressData.totalTimeMs);
const streak = calculateStreak(progressData);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Questions Completed"
value={completedCount}
subtitle={`of ${totalQuestions} total`}
icon={CheckCircle}
/>
<StatCard
title="Time Spent"
value={totalTime}
subtitle="total practice time"
icon={Clock}
/>
<StatCard
title="Patterns Studied"
value={patternsCount}
subtitle="unique patterns"
icon={Layers}
/>
<StatCard
title="Current Streak"
value={streak}
subtitle={streak === 1 ? "day" : "days"}
icon={Target}
/>
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { getDifficultyVariant, capitalize } from "@/lib/utils";
import { formatDaysSince, getRetentionLevel } from "@/lib/spaced-repetition";
import { RefreshCw, ArrowRight } from "lucide-react";
import type { ReviewCandidate, QuestionListItem } from "@/types";
interface ReviewSuggestionsProps {
candidates: ReviewCandidate[];
questions: QuestionListItem[];
}
export function ReviewSuggestions({
candidates,
questions,
}: ReviewSuggestionsProps) {
const questionMap = new Map(questions.map((q) => [q.slug, q]));
if (candidates.length === 0) {
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-6">
<div className="flex items-center gap-2 mb-4">
<RefreshCw className="h-5 w-5 text-[var(--primary)]" />
<h2 className="text-lg font-semibold">Review Suggestions</h2>
</div>
<p className="text-[var(--muted-foreground)]">
No questions need review yet. Keep practicing and check back later!
</p>
</div>
);
}
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-6">
<div className="flex items-center gap-2 mb-4">
<RefreshCw className="h-5 w-5 text-[var(--primary)]" />
<h2 className="text-lg font-semibold">Review Suggestions</h2>
</div>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
Based on spaced repetition, these questions could use a refresh.
</p>
<div className="space-y-3">
{candidates.map((candidate) => {
const question = questionMap.get(candidate.slug);
const retention = getRetentionLevel(candidate.score);
return (
<Link
key={candidate.slug}
href={`/questions/${candidate.slug}`}
className="flex items-center justify-between p-3 rounded-md bg-[var(--secondary)] hover:bg-[var(--accent)] transition-colors group"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium truncate">
{question?.title || candidate.slug}
</span>
<Badge
variant={getDifficultyVariant(candidate.difficulty)}
className="text-xs"
>
{capitalize(candidate.difficulty)}
</Badge>
{candidate.pattern && (
<Badge variant="pattern" className="text-xs">
{candidate.pattern}
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--muted-foreground)]">
<span>{formatDaysSince(candidate.daysSinceReview)}</span>
<span
className={
retention === "review"
? "text-red-500"
: retention === "moderate"
? "text-yellow-500"
: "text-green-500"
}
>
{retention === "review"
? "Needs review"
: retention === "moderate"
? "Getting rusty"
: "Good retention"}
</span>
</div>
</div>
<ArrowRight className="h-4 w-4 text-[var(--muted-foreground)] group-hover:text-[var(--foreground)] transition-colors flex-shrink-0 ml-2" />
</Link>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import { cn } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon?: LucideIcon;
className?: string;
}
export function StatCard({
title,
value,
subtitle,
icon: Icon,
className,
}: StatCardProps) {
return (
<div
className={cn(
"rounded-lg border border-[var(--border)] bg-[var(--card)] p-4",
className
)}
>
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-[var(--muted-foreground)]">{title}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
{subtitle && (
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{subtitle}
</p>
)}
</div>
{Icon && (
<div className="p-2 rounded-md bg-[var(--primary)]/10">
<Icon className="h-5 w-5 text-[var(--primary)]" />
</div>
)}
</div>
</div>
);
}