feat(progress): spaced repetition dashboard

This commit is contained in:
2025-09-12 13:15:06 +01:00
parent 5f223b3ce2
commit 385ff2711e
14 changed files with 1117 additions and 24 deletions

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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";

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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();
}

View File

@@ -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);
}
}

View 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";
}

View 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();