feat(progress): spaced repetition dashboard
This commit is contained in:
@@ -88,6 +88,12 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
Patterns
|
Patterns
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/progress"
|
||||||
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
Progress
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</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";
|
import type { Difficulty, ProgressData, QuestionProgress } from "@/types";
|
||||||
const SOLUTIONS_KEY = "codetutor-solutions";
|
|
||||||
|
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> {
|
export function getCompletedQuestions(): Set<string> {
|
||||||
if (typeof window === "undefined") return new Set();
|
const data = getProgressData();
|
||||||
const stored = localStorage.getItem(PROGRESS_KEY);
|
return new Set(Object.keys(data.questions));
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isQuestionCompleted(slug: string): boolean {
|
export function isQuestionCompleted(slug: string): boolean {
|
||||||
return getCompletedQuestions().has(slug);
|
const data = getProgressData();
|
||||||
}
|
return slug in data.questions;
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSavedSolution(slug: string): string | null {
|
export function getSavedSolution(slug: string): string | null {
|
||||||
if (typeof window === "undefined") return null;
|
const data = getProgressData();
|
||||||
const solutions = getSavedSolutions();
|
return data.questions[slug]?.code || null;
|
||||||
return solutions[slug] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSavedSolutions(): Record<string, string> {
|
export function saveSolution(slug: string, code: string): void {
|
||||||
const stored = localStorage.getItem(SOLUTIONS_KEY);
|
const data = getProgressData();
|
||||||
return stored ? JSON.parse(stored) : {};
|
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