feat(progress): spaced repetition dashboard
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user