puzzle-trainer/app/stats/page.tsx
2026-05-23 01:05:21 +00:00

215 lines
8.1 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { GAME_META, GAMES, GameId } from "@/lib/levels";
import { loadStats, GameStats } from "@/lib/stats";
import { allStats as allLevelStats, GameStats as LevelStats } from "@/lib/progress";
function fmt(s: number): string {
if (s === 0) return "--:--";
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
function getDaysRange(days: number): string[] {
const result: string[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
result.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`);
}
return result;
}
// ── Heatmap ───────────────────────────────────────────────────────────────────
function Heatmap({ game, solvedDates, accent }: { game: GameId; solvedDates: string[]; accent: string }) {
const days = getDaysRange(91); // 13 weeks
const solvedSet = new Set(solvedDates);
// Group by weeks (7 days per column)
const weeks: string[][] = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
return (
<div className="flex gap-1 overflow-x-auto pb-1">
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col gap-1">
{week.map(day => {
const solved = solvedSet.has(day);
return (
<div
key={day}
title={day}
className="w-3.5 h-3.5 rounded-sm"
style={{
background: solved ? accent : "#f1f5f9",
opacity: solved ? 1 : 1,
}}
/>
);
})}
</div>
))}
</div>
);
}
// ── Game stat card ────────────────────────────────────────────────────────────
function GameStatCard({
game,
dailyStats,
levelStats,
}: {
game: GameId;
dailyStats: GameStats;
levelStats: LevelStats | undefined;
}) {
const { name, accent, symbol, subtitle } = GAME_META[game];
return (
<div className="bg-white rounded-2xl border border-gray-100 p-4 flex flex-col gap-4">
{/* Header */}
<div className="flex items-center gap-3">
<span
className="w-9 h-9 rounded-xl flex items-center justify-center text-base font-bold shrink-0"
style={{ background: `${accent}18`, color: accent }}
aria-hidden
>
{symbol}
</span>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-gray-900">{name}</h3>
<p className="text-xs text-gray-400 truncate">{subtitle}</p>
</div>
</div>
{/* Daily stats */}
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col items-center gap-0.5 px-2 py-2 bg-gray-50 rounded-xl">
<span className="text-[10px] text-gray-400 uppercase tracking-wide">Série</span>
<span className="text-base font-bold text-gray-800 tabular-nums">{dailyStats.streak}j</span>
</div>
<div className="flex flex-col items-center gap-0.5 px-2 py-2 bg-gray-50 rounded-xl">
<span className="text-[10px] text-gray-400 uppercase tracking-wide">Total</span>
<span className="text-base font-bold text-gray-800 tabular-nums">{dailyStats.total}</span>
</div>
<div className="flex flex-col items-center gap-0.5 px-2 py-2 bg-gray-50 rounded-xl">
<span className="text-[10px] text-gray-400 uppercase tracking-wide">Record</span>
<span className="text-base font-bold text-gray-800 tabular-nums timer-mono">{fmt(dailyStats.bestTime)}</span>
</div>
</div>
{/* Heatmap (last 13 weeks) */}
{dailyStats.solvedDates && dailyStats.solvedDates.length > 0 ? (
<div>
<p className="text-[10px] text-gray-400 mb-1.5">13 dernières semaines</p>
<Heatmap game={game} solvedDates={dailyStats.solvedDates} accent={accent} />
</div>
) : (
<p className="text-xs text-gray-300 text-center py-1">Joue ta première partie pour voir l&apos;historique</p>
)}
{/* Level progress */}
{levelStats && levelStats.completed > 0 && (
<div className="flex items-center gap-2 pt-1 border-t border-gray-50">
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${levelStats.pct}%`, background: accent }} />
</div>
<span className="text-[11px] text-gray-400 shrink-0">{levelStats.completed}/100 niv.</span>
<Link
href={`/${game}/levels`}
className="text-[11px] font-semibold shrink-0"
style={{ color: accent }}
>
Continuer
</Link>
</div>
)}
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function StatsPage() {
const [dailyStats, setDailyStats] = useState<Record<GameId, GameStats> | null>(null);
const [levelStatsAll, setLevelStatsAll] = useState<Record<GameId, LevelStats> | null>(null);
useEffect(() => {
const ds = {} as Record<GameId, GameStats>;
for (const g of GAMES) ds[g] = loadStats(g);
setDailyStats(ds);
setLevelStatsAll(allLevelStats());
}, []);
const totalDaily = dailyStats
? GAMES.reduce((sum, g) => sum + (dailyStats[g]?.total ?? 0), 0)
: 0;
const bestStreak = dailyStats
? Math.max(...GAMES.map(g => dailyStats[g]?.streak ?? 0))
: 0;
return (
<div className="flex flex-col gap-6 max-w-sm mx-auto py-4 px-4">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/" className="text-gray-400 hover:text-gray-600 transition-colors">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>
</Link>
<div>
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Mes statistiques</h1>
<p className="text-xs text-gray-400 mt-0.5">Puzzles quotidiens &amp; entraînement</p>
</div>
</div>
{/* Summary strip */}
{dailyStats && (
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-2xl border border-gray-100 p-4 text-center">
<p className="text-3xl font-black text-gray-900 tabular-nums">{totalDaily}</p>
<p className="text-xs text-gray-400 mt-0.5">puzzles résolus</p>
</div>
<div className="bg-white rounded-2xl border border-gray-100 p-4 text-center">
<div className="flex items-center justify-center gap-1">
<span className="text-2xl" aria-hidden>🔥</span>
<p className="text-3xl font-black text-gray-900 tabular-nums">{bestStreak}</p>
</div>
<p className="text-xs text-gray-400 mt-0.5">meilleure série</p>
</div>
</div>
)}
{/* Per-game cards */}
{dailyStats ? (
<div className="flex flex-col gap-3">
{GAMES.map(game => (
<GameStatCard
key={game}
game={game}
dailyStats={dailyStats[game]}
levelStats={levelStatsAll?.[game]}
/>
))}
</div>
) : (
<div className="flex flex-col gap-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="skeleton h-[200px] rounded-2xl" />
))}
</div>
)}
<Link
href="/"
className="text-xs text-gray-400 hover:text-gray-600 transition-colors text-center pb-2"
>
Retour aux puzzles du jour
</Link>
</div>
);
}