puzzle-trainer/progress.ts
2026-05-23 01:05:21 +00:00

98 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* Progress tracking — localStorage-based profile.
* Structure ready for server-side sync in the future.
*/
import { GAMES, GameId, TOTAL_LEVELS } from "./levels";
const PROFILE_KEY = "pt-profile";
const PROGRESS_KEY = (game: GameId) => `pt-progress-${game}`;
export interface Profile {
id: string; // UUID
createdAt: string; // ISO date
name?: string;
}
export interface LevelRecord {
completedAt: string; // ISO datetime
bestTime: number; // seconds
attempts: number;
}
export type GameProgress = Record<number, LevelRecord>; // level → record
// ── Profile ───────────────────────────────────────────────────────────────────
function uuid(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16);
});
}
export function getProfile(): Profile {
if (typeof window === "undefined") return { id: "ssr", createdAt: new Date().toISOString() };
const raw = localStorage.getItem(PROFILE_KEY);
if (raw) try { return JSON.parse(raw) as Profile; } catch { /* corrupted, re-create */ }
const profile: Profile = { id: uuid(), createdAt: new Date().toISOString() };
localStorage.setItem(PROFILE_KEY, JSON.stringify(profile));
return profile;
}
// ── Progress ──────────────────────────────────────────────────────────────────
export function getGameProgress(game: GameId): GameProgress {
if (typeof window === "undefined") return {};
const raw = localStorage.getItem(PROGRESS_KEY(game));
if (!raw) return {};
try { return JSON.parse(raw); } catch { return {}; }
}
export function recordLevelSolve(game: GameId, level: number, elapsed: number): GameProgress {
const progress = getGameProgress(game);
const existing = progress[level];
progress[level] = {
completedAt: existing?.completedAt ?? new Date().toISOString(),
bestTime: existing ? Math.min(existing.bestTime, elapsed) : elapsed,
attempts: (existing?.attempts ?? 0) + 1,
};
localStorage.setItem(PROGRESS_KEY(game), JSON.stringify(progress));
return progress;
}
/** Returns the next uncompleted level (1-based), or null if all done. */
export function nextLevel(game: GameId): number {
const progress = getGameProgress(game);
for (let l = 1; l <= TOTAL_LEVELS; l++) {
if (!progress[l]) return l;
}
return TOTAL_LEVELS; // all done → stay on last
}
/** Summary stats for a game. */
export interface GameStats {
completed: number;
total: number;
pct: number; // 0100
bestTime: number; // fastest solve (seconds), 0 if none
nextLevel: number; // next uncompleted level
}
export function gameStats(game: GameId): GameStats {
const progress = getGameProgress(game);
const completed = Object.keys(progress).length;
const best = Object.values(progress).reduce((m, r) => Math.min(m, r.bestTime), Infinity);
return {
completed,
total: TOTAL_LEVELS,
pct: Math.round((completed / TOTAL_LEVELS) * 100),
bestTime: isFinite(best) ? best : 0,
nextLevel: nextLevel(game),
};
}
export function allStats(): Record<GameId, GameStats> {
return Object.fromEntries(GAMES.map(g => [g, gameStats(g)])) as Record<GameId, GameStats>;
}