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