feat(progress): spaced repetition dashboard
This commit is contained in:
@@ -88,6 +88,12 @@ export default function RootLayout({
|
||||
>
|
||||
Patterns
|
||||
</Link>
|
||||
<Link
|
||||
href="/progress"
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
Progress
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
44
frontend/src/app/progress/loading.tsx
Normal file
44
frontend/src/app/progress/loading.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
export default function ProgressLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<div className="h-8 w-64 bg-[var(--secondary)] rounded animate-pulse" />
|
||||
<div className="h-4 w-96 bg-[var(--secondary)] rounded animate-pulse mt-2" />
|
||||
</div>
|
||||
|
||||
{/* Stat cards skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
|
||||
>
|
||||
<div className="h-4 w-24 bg-[var(--secondary)] rounded animate-pulse" />
|
||||
<div className="h-8 w-16 bg-[var(--secondary)] rounded animate-pulse mt-2" />
|
||||
<div className="h-3 w-32 bg-[var(--secondary)] rounded animate-pulse mt-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content skeleton */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-6"
|
||||
>
|
||||
<div className="h-6 w-48 bg-[var(--secondary)] rounded animate-pulse mb-4" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="h-12 bg-[var(--secondary)] rounded animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
frontend/src/app/progress/page.tsx
Normal file
121
frontend/src/app/progress/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { getProgressData } from "@/lib/progress";
|
||||
import { getReviewSuggestions } from "@/lib/spaced-repetition";
|
||||
import { getQuestions, getPatterns } from "@/lib/api";
|
||||
import {
|
||||
ProgressOverview,
|
||||
PatternProgress,
|
||||
ReviewSuggestions,
|
||||
ProgressExport,
|
||||
} from "@/components/progress";
|
||||
import type {
|
||||
ProgressData,
|
||||
Pattern,
|
||||
QuestionListItem,
|
||||
ReviewCandidate,
|
||||
} from "@/types";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function ProgressPage() {
|
||||
const [progressData, setProgressData] = useState<ProgressData | null>(null);
|
||||
const [questions, setQuestions] = useState<QuestionListItem[]>([]);
|
||||
const [patterns, setPatterns] = useState<Pattern[]>([]);
|
||||
const [reviewCandidates, setReviewCandidates] = useState<ReviewCandidate[]>(
|
||||
[]
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const progress = getProgressData();
|
||||
setProgressData(progress);
|
||||
|
||||
const [questionsRes, patternsRes] = await Promise.all([
|
||||
getQuestions({ limit: 1000 }),
|
||||
getPatterns(),
|
||||
]);
|
||||
|
||||
setQuestions(questionsRes.items);
|
||||
setPatterns(patternsRes.items);
|
||||
|
||||
const candidates = getReviewSuggestions(progress, questionsRes.items);
|
||||
setReviewCandidates(candidates);
|
||||
} catch (error) {
|
||||
console.error("Failed to load progress data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleImportSuccess = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
if (isLoading || !progressData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[var(--primary)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasProgress = Object.keys(progressData.questions).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Progress Dashboard</h1>
|
||||
<p className="text-[var(--muted-foreground)] mt-2">
|
||||
Track your learning journey and review questions using spaced
|
||||
repetition.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProgressOverview
|
||||
progressData={progressData}
|
||||
totalQuestions={questions.length}
|
||||
/>
|
||||
|
||||
{!hasProgress && (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-8 text-center">
|
||||
<h2 className="text-lg font-semibold mb-2">Get Started</h2>
|
||||
<p className="text-[var(--muted-foreground)] mb-4">
|
||||
Complete some questions to start tracking your progress. Your data
|
||||
is stored locally in your browser.
|
||||
</p>
|
||||
<Link
|
||||
href="/questions"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Browse Questions
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasProgress && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<ReviewSuggestions
|
||||
candidates={reviewCandidates}
|
||||
questions={questions}
|
||||
/>
|
||||
<PatternProgress
|
||||
progressData={progressData}
|
||||
patterns={patterns}
|
||||
questions={questions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProgressExport onImportSuccess={handleImportSuccess} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
frontend/src/components/progress/index.ts
Normal file
6
frontend/src/components/progress/index.ts
Normal file
@@ -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";
|
||||
93
frontend/src/components/progress/pattern-progress.tsx
Normal file
93
frontend/src/components/progress/pattern-progress.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/progress/progress-bar.tsx
Normal file
45
frontend/src/components/progress/progress-bar.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/progress/progress-export.tsx
Normal file
103
frontend/src/components/progress/progress-export.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/progress/progress-overview.tsx
Normal file
108
frontend/src/components/progress/progress-overview.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/progress/review-suggestions.tsx
Normal file
98
frontend/src/components/progress/review-suggestions.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/progress/stat-card.tsx
Normal file
44
frontend/src/components/progress/stat-card.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/hooks/use-time-tracker.ts
Normal file
22
frontend/src/hooks/use-time-tracker.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { timeTracker } from "@/lib/time-tracker";
|
||||
|
||||
export function useTimeTracker(slug: string): number {
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
timeTracker.start(slug);
|
||||
|
||||
return () => {
|
||||
elapsedRef.current = timeTracker.stop();
|
||||
};
|
||||
}, [slug]);
|
||||
|
||||
return elapsedRef.current;
|
||||
}
|
||||
|
||||
export function getTimeTrackerElapsed(): number {
|
||||
return timeTracker.getElapsedMs();
|
||||
}
|
||||
@@ -1,40 +1,199 @@
|
||||
const PROGRESS_KEY = "codetutor-progress";
|
||||
const SOLUTIONS_KEY = "codetutor-solutions";
|
||||
import type { Difficulty, ProgressData, QuestionProgress } from "@/types";
|
||||
|
||||
const PROGRESS_KEY_V1 = "codetutor-progress";
|
||||
const SOLUTIONS_KEY_V1 = "codetutor-solutions";
|
||||
const PROGRESS_KEY_V2 = "codetutor-progress-v2";
|
||||
|
||||
function createEmptyProgressData(): ProgressData {
|
||||
return {
|
||||
version: 1,
|
||||
questions: {},
|
||||
patternsStudied: [],
|
||||
totalTimeMs: 0,
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProgressData(): ProgressData {
|
||||
if (typeof window === "undefined") return createEmptyProgressData();
|
||||
|
||||
// Check if we already have v2 data
|
||||
const v2Data = localStorage.getItem(PROGRESS_KEY_V2);
|
||||
if (v2Data) {
|
||||
try {
|
||||
return JSON.parse(v2Data) as ProgressData;
|
||||
} catch {
|
||||
return createEmptyProgressData();
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate from v1 format
|
||||
const v1Progress = localStorage.getItem(PROGRESS_KEY_V1);
|
||||
const v1Solutions = localStorage.getItem(SOLUTIONS_KEY_V1);
|
||||
|
||||
if (!v1Progress) {
|
||||
return createEmptyProgressData();
|
||||
}
|
||||
|
||||
try {
|
||||
const completedSlugs: string[] = JSON.parse(v1Progress);
|
||||
const solutions: Record<string, string> = v1Solutions
|
||||
? JSON.parse(v1Solutions)
|
||||
: {};
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const questions: Record<string, QuestionProgress> = {};
|
||||
|
||||
for (const slug of completedSlugs) {
|
||||
questions[slug] = {
|
||||
slug,
|
||||
completedAt: now,
|
||||
attempts: 1,
|
||||
timeSpentMs: 0,
|
||||
lastAttemptAt: now,
|
||||
primaryPattern: "",
|
||||
difficulty: "medium",
|
||||
code: solutions[slug] || "",
|
||||
};
|
||||
}
|
||||
|
||||
const progressData: ProgressData = {
|
||||
version: 1,
|
||||
questions,
|
||||
patternsStudied: [],
|
||||
totalTimeMs: 0,
|
||||
lastActiveAt: now,
|
||||
};
|
||||
|
||||
// Save migrated data
|
||||
localStorage.setItem(PROGRESS_KEY_V2, JSON.stringify(progressData));
|
||||
|
||||
return progressData;
|
||||
} catch {
|
||||
return createEmptyProgressData();
|
||||
}
|
||||
}
|
||||
|
||||
export function getProgressData(): ProgressData {
|
||||
if (typeof window === "undefined") return createEmptyProgressData();
|
||||
return migrateProgressData();
|
||||
}
|
||||
|
||||
export function saveProgressData(data: ProgressData): void {
|
||||
if (typeof window === "undefined") return;
|
||||
data.lastActiveAt = new Date().toISOString();
|
||||
localStorage.setItem(PROGRESS_KEY_V2, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export interface QuestionMetadata {
|
||||
primaryPattern: string;
|
||||
difficulty: Difficulty;
|
||||
code: string;
|
||||
timeSpentMs?: number;
|
||||
}
|
||||
|
||||
export function markQuestionCompleted(
|
||||
slug: string,
|
||||
metadata: QuestionMetadata
|
||||
): void {
|
||||
const data = getProgressData();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existing = data.questions[slug];
|
||||
|
||||
data.questions[slug] = {
|
||||
slug,
|
||||
completedAt: existing?.completedAt || now,
|
||||
attempts: (existing?.attempts || 0) + 1,
|
||||
timeSpentMs: (existing?.timeSpentMs || 0) + (metadata.timeSpentMs || 0),
|
||||
lastAttemptAt: now,
|
||||
primaryPattern: metadata.primaryPattern,
|
||||
difficulty: metadata.difficulty,
|
||||
code: metadata.code,
|
||||
};
|
||||
|
||||
// Track pattern if not already studied
|
||||
if (
|
||||
metadata.primaryPattern &&
|
||||
!data.patternsStudied.includes(metadata.primaryPattern)
|
||||
) {
|
||||
data.patternsStudied.push(metadata.primaryPattern);
|
||||
}
|
||||
|
||||
// Update total time
|
||||
if (metadata.timeSpentMs) {
|
||||
data.totalTimeMs += metadata.timeSpentMs;
|
||||
}
|
||||
|
||||
saveProgressData(data);
|
||||
}
|
||||
|
||||
export function incrementAttempts(slug: string): void {
|
||||
const data = getProgressData();
|
||||
const existing = data.questions[slug];
|
||||
|
||||
if (existing) {
|
||||
existing.attempts += 1;
|
||||
existing.lastAttemptAt = new Date().toISOString();
|
||||
saveProgressData(data);
|
||||
}
|
||||
}
|
||||
|
||||
export function addTimeSpent(slug: string, timeMs: number): void {
|
||||
const data = getProgressData();
|
||||
const existing = data.questions[slug];
|
||||
|
||||
if (existing) {
|
||||
existing.timeSpentMs += timeMs;
|
||||
data.totalTimeMs += timeMs;
|
||||
saveProgressData(data);
|
||||
}
|
||||
}
|
||||
|
||||
export function exportProgressData(): string {
|
||||
const data = getProgressData();
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function importProgressData(json: string): boolean {
|
||||
try {
|
||||
const data = JSON.parse(json) as ProgressData;
|
||||
|
||||
// Validate structure
|
||||
if (data.version !== 1 || typeof data.questions !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
saveProgressData(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Backward-compatible functions
|
||||
|
||||
export function getCompletedQuestions(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
const stored = localStorage.getItem(PROGRESS_KEY);
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set();
|
||||
}
|
||||
|
||||
export function markQuestionCompleted(slug: string, code?: string): void {
|
||||
const completed = getCompletedQuestions();
|
||||
completed.add(slug);
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify([...completed]));
|
||||
|
||||
if (code) {
|
||||
saveSolution(slug, code);
|
||||
}
|
||||
const data = getProgressData();
|
||||
return new Set(Object.keys(data.questions));
|
||||
}
|
||||
|
||||
export function isQuestionCompleted(slug: string): boolean {
|
||||
return getCompletedQuestions().has(slug);
|
||||
}
|
||||
|
||||
export function saveSolution(slug: string, code: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const solutions = getSavedSolutions();
|
||||
solutions[slug] = code;
|
||||
localStorage.setItem(SOLUTIONS_KEY, JSON.stringify(solutions));
|
||||
const data = getProgressData();
|
||||
return slug in data.questions;
|
||||
}
|
||||
|
||||
export function getSavedSolution(slug: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const solutions = getSavedSolutions();
|
||||
return solutions[slug] || null;
|
||||
const data = getProgressData();
|
||||
return data.questions[slug]?.code || null;
|
||||
}
|
||||
|
||||
function getSavedSolutions(): Record<string, string> {
|
||||
const stored = localStorage.getItem(SOLUTIONS_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
export function saveSolution(slug: string, code: string): void {
|
||||
const data = getProgressData();
|
||||
const existing = data.questions[slug];
|
||||
|
||||
if (existing) {
|
||||
existing.code = code;
|
||||
saveProgressData(data);
|
||||
}
|
||||
}
|
||||
|
||||
103
frontend/src/lib/spaced-repetition.ts
Normal file
103
frontend/src/lib/spaced-repetition.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type {
|
||||
Difficulty,
|
||||
ProgressData,
|
||||
QuestionProgress,
|
||||
ReviewCandidate,
|
||||
QuestionListItem,
|
||||
} from "@/types";
|
||||
|
||||
const DECAY_RATES: Record<Difficulty, number> = {
|
||||
easy: 14,
|
||||
medium: 10,
|
||||
hard: 7,
|
||||
};
|
||||
|
||||
function daysSinceDate(isoDate: string): number {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
return diffMs / (1000 * 60 * 60 * 24);
|
||||
}
|
||||
|
||||
function calculateReviewScore(progress: QuestionProgress): number {
|
||||
const daysSince = daysSinceDate(progress.lastAttemptAt);
|
||||
const decayRate = DECAY_RATES[progress.difficulty] || 10;
|
||||
|
||||
// Exponential decay: score approaches 1 as time passes
|
||||
// Higher score = lower retention = needs review
|
||||
return 1 - Math.exp(-daysSince / decayRate);
|
||||
}
|
||||
|
||||
export interface GetReviewSuggestionsOptions {
|
||||
count?: number;
|
||||
minScore?: number;
|
||||
maxPerPattern?: number;
|
||||
}
|
||||
|
||||
export function getReviewSuggestions(
|
||||
progressData: ProgressData,
|
||||
questions: QuestionListItem[],
|
||||
options: GetReviewSuggestionsOptions = {}
|
||||
): ReviewCandidate[] {
|
||||
const { count = 5, minScore = 0.3, maxPerPattern = 2 } = options;
|
||||
|
||||
const questionMap = new Map(questions.map((q) => [q.slug, q]));
|
||||
const candidates: ReviewCandidate[] = [];
|
||||
|
||||
for (const progress of Object.values(progressData.questions)) {
|
||||
const score = calculateReviewScore(progress);
|
||||
|
||||
// Only include questions with low enough retention
|
||||
if (score < minScore) continue;
|
||||
|
||||
const question = questionMap.get(progress.slug);
|
||||
const pattern = question?.patterns[0]?.slug || progress.primaryPattern || "";
|
||||
|
||||
candidates.push({
|
||||
slug: progress.slug,
|
||||
score,
|
||||
daysSinceReview: Math.floor(daysSinceDate(progress.lastAttemptAt)),
|
||||
difficulty: progress.difficulty,
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score descending (highest need for review first)
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Diversify by pattern: limit to maxPerPattern per pattern
|
||||
const patternCounts: Record<string, number> = {};
|
||||
const diversified: ReviewCandidate[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const pattern = candidate.pattern || "other";
|
||||
const currentCount = patternCounts[pattern] || 0;
|
||||
|
||||
if (currentCount < maxPerPattern) {
|
||||
diversified.push(candidate);
|
||||
patternCounts[pattern] = currentCount + 1;
|
||||
|
||||
if (diversified.length >= count) break;
|
||||
}
|
||||
}
|
||||
|
||||
return diversified;
|
||||
}
|
||||
|
||||
export function formatDaysSince(days: number): string {
|
||||
if (days < 1) return "Today";
|
||||
if (days < 2) return "Yesterday";
|
||||
if (days < 7) return `${Math.floor(days)} days ago`;
|
||||
if (days < 14) return "1 week ago";
|
||||
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
||||
if (days < 60) return "1 month ago";
|
||||
return `${Math.floor(days / 30)} months ago`;
|
||||
}
|
||||
|
||||
export function getRetentionLevel(
|
||||
score: number
|
||||
): "good" | "moderate" | "review" {
|
||||
if (score < 0.3) return "good";
|
||||
if (score < 0.6) return "moderate";
|
||||
return "review";
|
||||
}
|
||||
141
frontend/src/lib/time-tracker.ts
Normal file
141
frontend/src/lib/time-tracker.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { addTimeSpent } from "./progress";
|
||||
|
||||
const FLUSH_INTERVAL_MS = 30000;
|
||||
|
||||
class TimeTracker {
|
||||
private currentSlug: string | null = null;
|
||||
private startTime: number = 0;
|
||||
private accumulatedMs: number = 0;
|
||||
private isPaused: boolean = false;
|
||||
private flushIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private boundHandleVisibilityChange: () => void;
|
||||
private boundHandleBlur: () => void;
|
||||
private boundHandleFocus: () => void;
|
||||
|
||||
constructor() {
|
||||
this.boundHandleVisibilityChange = this.handleVisibilityChange.bind(this);
|
||||
this.boundHandleBlur = this.pause.bind(this);
|
||||
this.boundHandleFocus = this.resume.bind(this);
|
||||
}
|
||||
|
||||
start(slug: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// If already tracking this slug, do nothing
|
||||
if (this.currentSlug === slug) return;
|
||||
|
||||
// Stop previous tracking if any
|
||||
if (this.currentSlug) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
this.currentSlug = slug;
|
||||
this.startTime = Date.now();
|
||||
this.accumulatedMs = 0;
|
||||
this.isPaused = false;
|
||||
|
||||
// Set up event listeners
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
this.boundHandleVisibilityChange
|
||||
);
|
||||
window.addEventListener("blur", this.boundHandleBlur);
|
||||
window.addEventListener("focus", this.boundHandleFocus);
|
||||
|
||||
// Set up periodic flush
|
||||
this.flushIntervalId = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private handleVisibilityChange(): void {
|
||||
if (document.hidden) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.resume();
|
||||
}
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (this.isPaused || !this.currentSlug) return;
|
||||
|
||||
this.accumulatedMs += Date.now() - this.startTime;
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (!this.isPaused || !this.currentSlug) return;
|
||||
|
||||
this.startTime = Date.now();
|
||||
this.isPaused = false;
|
||||
}
|
||||
|
||||
stop(): number {
|
||||
if (typeof window === "undefined") return 0;
|
||||
|
||||
if (!this.currentSlug) return 0;
|
||||
|
||||
// Calculate final time
|
||||
if (!this.isPaused) {
|
||||
this.accumulatedMs += Date.now() - this.startTime;
|
||||
}
|
||||
|
||||
const totalTime = this.accumulatedMs;
|
||||
|
||||
// Clean up event listeners
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
this.boundHandleVisibilityChange
|
||||
);
|
||||
window.removeEventListener("blur", this.boundHandleBlur);
|
||||
window.removeEventListener("focus", this.boundHandleFocus);
|
||||
|
||||
// Clear flush interval
|
||||
if (this.flushIntervalId) {
|
||||
clearInterval(this.flushIntervalId);
|
||||
this.flushIntervalId = null;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this.currentSlug = null;
|
||||
this.startTime = 0;
|
||||
this.accumulatedMs = 0;
|
||||
this.isPaused = false;
|
||||
|
||||
return totalTime;
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (!this.currentSlug) return;
|
||||
|
||||
// Calculate time since last flush
|
||||
let timeToSave = 0;
|
||||
|
||||
if (!this.isPaused) {
|
||||
timeToSave = this.accumulatedMs + (Date.now() - this.startTime);
|
||||
this.startTime = Date.now();
|
||||
this.accumulatedMs = 0;
|
||||
} else {
|
||||
timeToSave = this.accumulatedMs;
|
||||
this.accumulatedMs = 0;
|
||||
}
|
||||
|
||||
if (timeToSave > 0) {
|
||||
addTimeSpent(this.currentSlug, timeToSave);
|
||||
}
|
||||
}
|
||||
|
||||
getElapsedMs(): number {
|
||||
if (!this.currentSlug) return 0;
|
||||
|
||||
if (this.isPaused) {
|
||||
return this.accumulatedMs;
|
||||
}
|
||||
|
||||
return this.accumulatedMs + (Date.now() - this.startTime);
|
||||
}
|
||||
|
||||
getCurrentSlug(): string | null {
|
||||
return this.currentSlug;
|
||||
}
|
||||
}
|
||||
|
||||
export const timeTracker = new TimeTracker();
|
||||
Reference in New Issue
Block a user