98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
"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; // 0–100
|
||
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>;
|
||
}
|