diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 5f832b3..0464327 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -88,6 +88,12 @@ export default function RootLayout({
>
Patterns
+
+ Progress
+
diff --git a/frontend/src/app/progress/loading.tsx b/frontend/src/app/progress/loading.tsx
new file mode 100644
index 0000000..602704d
--- /dev/null
+++ b/frontend/src/app/progress/loading.tsx
@@ -0,0 +1,44 @@
+export default function ProgressLoading() {
+ return (
+
+
+
+ {/* Stat cards skeleton */}
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ {/* Content skeleton */}
+
+ {Array.from({ length: 2 }).map((_, i) => (
+
+
+
+ {Array.from({ length: 5 }).map((_, j) => (
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/app/progress/page.tsx b/frontend/src/app/progress/page.tsx
new file mode 100644
index 0000000..dea4967
--- /dev/null
+++ b/frontend/src/app/progress/page.tsx
@@ -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(null);
+ const [questions, setQuestions] = useState([]);
+ const [patterns, setPatterns] = useState([]);
+ const [reviewCandidates, setReviewCandidates] = useState(
+ []
+ );
+ 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 (
+
+
+
+ );
+ }
+
+ const hasProgress = Object.keys(progressData.questions).length > 0;
+
+ return (
+
+
+
Progress Dashboard
+
+ Track your learning journey and review questions using spaced
+ repetition.
+
+
+
+
+
+ {!hasProgress && (
+
+
Get Started
+
+ Complete some questions to start tracking your progress. Your data
+ is stored locally in your browser.
+
+
+ Browse Questions
+
+
+ )}
+
+ {hasProgress && (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/progress/index.ts b/frontend/src/components/progress/index.ts
new file mode 100644
index 0000000..20ccf4c
--- /dev/null
+++ b/frontend/src/components/progress/index.ts
@@ -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";
diff --git a/frontend/src/components/progress/pattern-progress.tsx b/frontend/src/components/progress/pattern-progress.tsx
new file mode 100644
index 0000000..7923e8e
--- /dev/null
+++ b/frontend/src/components/progress/pattern-progress.tsx
@@ -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 (
+
+
Progress by Pattern
+
+ Complete some questions to see your progress by pattern.
+
+
+ );
+ }
+
+ return (
+
+
Progress by Pattern
+
+ {sortedStats.map((stat) => (
+
+
0
+ ? "bg-[var(--primary)]"
+ : "bg-[var(--muted)]"
+ }
+ />
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/progress/progress-bar.tsx b/frontend/src/components/progress/progress-bar.tsx
new file mode 100644
index 0000000..8158c3c
--- /dev/null
+++ b/frontend/src/components/progress/progress-bar.tsx
@@ -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 (
+
+ {(label || showCount) && (
+
+ {label && {label}}
+ {showCount && (
+
+ {value} / {max} ({percentage}%)
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/progress/progress-export.tsx b/frontend/src/components/progress/progress-export.tsx
new file mode 100644
index 0000000..6228d4f
--- /dev/null
+++ b/frontend/src/components/progress/progress-export.tsx
@@ -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(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) => {
+ 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 (
+
+
Export / Import Progress
+
+ Back up your progress or transfer it to another device.
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/progress/progress-overview.tsx b/frontend/src/components/progress/progress-overview.tsx
new file mode 100644
index 0000000..75809c5
--- /dev/null
+++ b/frontend/src/components/progress/progress-overview.tsx
@@ -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();
+ 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 (
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/progress/review-suggestions.tsx b/frontend/src/components/progress/review-suggestions.tsx
new file mode 100644
index 0000000..19a8b8f
--- /dev/null
+++ b/frontend/src/components/progress/review-suggestions.tsx
@@ -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 (
+
+
+
+
Review Suggestions
+
+
+ No questions need review yet. Keep practicing and check back later!
+
+
+ );
+ }
+
+ return (
+
+
+
+
Review Suggestions
+
+
+ Based on spaced repetition, these questions could use a refresh.
+
+
+ {candidates.map((candidate) => {
+ const question = questionMap.get(candidate.slug);
+ const retention = getRetentionLevel(candidate.score);
+
+ return (
+
+
+
+
+ {question?.title || candidate.slug}
+
+
+ {capitalize(candidate.difficulty)}
+
+ {candidate.pattern && (
+
+ {candidate.pattern}
+
+ )}
+
+
+ {formatDaysSince(candidate.daysSinceReview)}
+
+ {retention === "review"
+ ? "Needs review"
+ : retention === "moderate"
+ ? "Getting rusty"
+ : "Good retention"}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/components/progress/stat-card.tsx b/frontend/src/components/progress/stat-card.tsx
new file mode 100644
index 0000000..cd050db
--- /dev/null
+++ b/frontend/src/components/progress/stat-card.tsx
@@ -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 (
+
+
+
+
{title}
+
{value}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {Icon && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/hooks/use-time-tracker.ts b/frontend/src/hooks/use-time-tracker.ts
new file mode 100644
index 0000000..018661c
--- /dev/null
+++ b/frontend/src/hooks/use-time-tracker.ts
@@ -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();
+}
diff --git a/frontend/src/lib/progress.ts b/frontend/src/lib/progress.ts
index 3613b2e..b49a8f4 100644
--- a/frontend/src/lib/progress.ts
+++ b/frontend/src/lib/progress.ts
@@ -1,40 +1,199 @@
-const PROGRESS_KEY = "codetutor-progress";
-const SOLUTIONS_KEY = "codetutor-solutions";
+import type { Difficulty, ProgressData, QuestionProgress } from "@/types";
-export function getCompletedQuestions(): Set {
- if (typeof window === "undefined") return new Set();
- const stored = localStorage.getItem(PROGRESS_KEY);
- return stored ? new Set(JSON.parse(stored)) : new Set();
+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(),
+ };
}
-export function markQuestionCompleted(slug: string, code?: string): void {
- const completed = getCompletedQuestions();
- completed.add(slug);
- localStorage.setItem(PROGRESS_KEY, JSON.stringify([...completed]));
+function migrateProgressData(): ProgressData {
+ if (typeof window === "undefined") return createEmptyProgressData();
- if (code) {
- saveSolution(slug, code);
+ // 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 = v1Solutions
+ ? JSON.parse(v1Solutions)
+ : {};
+
+ const now = new Date().toISOString();
+ const questions: Record = {};
+
+ 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 isQuestionCompleted(slug: string): boolean {
- return getCompletedQuestions().has(slug);
+export function getProgressData(): ProgressData {
+ if (typeof window === "undefined") return createEmptyProgressData();
+ return migrateProgressData();
}
-export function saveSolution(slug: string, code: string): void {
+export function saveProgressData(data: ProgressData): void {
if (typeof window === "undefined") return;
- const solutions = getSavedSolutions();
- solutions[slug] = code;
- localStorage.setItem(SOLUTIONS_KEY, JSON.stringify(solutions));
+ 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 {
+ const data = getProgressData();
+ return new Set(Object.keys(data.questions));
+}
+
+export function isQuestionCompleted(slug: string): boolean {
+ 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 {
- 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);
+ }
}
diff --git a/frontend/src/lib/spaced-repetition.ts b/frontend/src/lib/spaced-repetition.ts
new file mode 100644
index 0000000..7f555be
--- /dev/null
+++ b/frontend/src/lib/spaced-repetition.ts
@@ -0,0 +1,103 @@
+import type {
+ Difficulty,
+ ProgressData,
+ QuestionProgress,
+ ReviewCandidate,
+ QuestionListItem,
+} from "@/types";
+
+const DECAY_RATES: Record = {
+ 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 = {};
+ 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";
+}
diff --git a/frontend/src/lib/time-tracker.ts b/frontend/src/lib/time-tracker.ts
new file mode 100644
index 0000000..0105ad1
--- /dev/null
+++ b/frontend/src/lib/time-tracker.ts
@@ -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 | 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();