215 lines
8.1 KiB
TypeScript
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'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 & 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>
|
|
);
|
|
}
|