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

215 lines
7.7 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { todayISO } from "@/lib/rng";
import { GAME_META, GAMES, GameId } from "@/lib/levels";
import { allStats, GameStats } from "@/lib/progress";
const GAME_SYMBOL: Record<GameId, string> = {
queens: "♛",
tango: "☀",
zip: "∞",
sudoku: "#",
patches: "▦",
};
// Check if today's daily puzzle is already solved from localStorage
const DAILY_STORAGE_KEY: Record<GameId, (d: string) => string> = {
queens: d => `queens-${d}`,
tango: d => `tango-${d}`,
zip: d => `zip-${d}`,
sudoku: d => `sudoku-${d}`,
patches: d => `patches-${d}`,
};
function isDailySolved(game: GameId, date: string): boolean {
if (typeof window === "undefined") return false;
try {
const s = localStorage.getItem(DAILY_STORAGE_KEY[game](date));
if (!s) return false;
// Check stats — if lastDate matches today, it was solved
const stats = localStorage.getItem(`stats-${game}`);
if (stats) {
const parsed = JSON.parse(stats);
if (parsed.lastDate === date) return true;
}
return false;
} catch { return false; }
}
function fmt(s: number): string {
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
}
function ProgressBar({ pct, color }: { pct: number; color: string }) {
return (
<div className="progress-track">
<div className="progress-fill" style={{ width: `${pct}%`, background: color }} />
</div>
);
}
function SkeletonCard() {
return <div className="skeleton h-[88px] rounded-2xl" />;
}
export default function Home() {
const today = todayISO();
const [stats, setStats] = useState<Record<GameId, GameStats> | null>(null);
const [solvedToday, setSolvedToday] = useState<Record<GameId, boolean>>({} as Record<GameId, boolean>);
useEffect(() => {
const refresh = () => {
setStats(allStats());
const solved = {} as Record<GameId, boolean>;
for (const g of GAMES) solved[g] = isDailySolved(g, today);
setSolvedToday(solved);
};
refresh();
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
return () => {
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
};
}, [today]);
const dateLabel = new Date(today + "T00:00:00").toLocaleDateString("fr-FR", {
weekday: "long", day: "numeric", month: "long",
});
const allSolvedToday = GAMES.every(g => solvedToday[g]);
const totalSolvedToday = GAMES.filter(g => solvedToday[g]).length;
return (
<div className="flex flex-col gap-10 max-w-xl mx-auto py-4 px-4">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">Puzzle Trainer</h1>
<p className="text-gray-400 mt-0.5 text-sm capitalize">{dateLabel}</p>
</div>
{totalSolvedToday > 0 && (
<div className="flex flex-col items-end gap-1 mt-1">
<span className="text-xs font-semibold text-gray-500 bg-gray-100 px-2.5 py-1 rounded-full">
{totalSolvedToday}/5 aujourd&apos;hui
</span>
{allSolvedToday && (
<span className="text-[11px] text-green-600 font-medium"> Tous faits !</span>
)}
</div>
)}
</div>
{/* Daily puzzles */}
<section>
<h2 className="text-[11px] font-semibold text-gray-400 uppercase tracking-widest mb-3">
Puzzle du jour
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5">
{GAMES.map((game) => {
const { name, accent, desc } = GAME_META[game];
const solved = solvedToday[game];
return (
<Link
key={game}
href={`/${game}`}
className={`group relative flex flex-col gap-2 p-4 bg-white rounded-2xl border transition-all ${
solved
? "border-green-200 shadow-sm"
: "border-gray-100 hover:border-gray-200 hover:shadow-sm"
}`}
style={{ borderTop: `3px solid ${solved ? "#16a34a" : accent}` }}
>
{/* Solved badge */}
{solved && (
<span className="absolute top-2.5 right-2.5 text-[10px] font-bold text-green-600 bg-green-50 px-1.5 py-0.5 rounded-full">
</span>
)}
<div className="flex items-center justify-between pr-4">
<span className={`text-sm font-semibold ${solved ? "text-green-800" : "text-gray-800 group-hover:text-gray-600"}`}>
{name}
</span>
<span
style={{ color: solved ? "#16a34a" : accent, fontSize: 15, lineHeight: 1, fontWeight: 700 }}
aria-hidden
>
{GAME_SYMBOL[game]}
</span>
</div>
<p className="text-xs text-gray-400 leading-snug">{desc}</p>
{solved && (
<span className="text-[10px] text-green-600 font-medium -mt-0.5">Résolu aujourd&apos;hui</span>
)}
</Link>
);
})}
</div>
</section>
{/* Progression */}
<section>
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-[11px] font-semibold text-gray-400 uppercase tracking-widest">
Progression
</h2>
<span className="text-xs text-gray-300">100 niveaux par jeu</span>
</div>
<div className="flex flex-col gap-2">
{GAMES.map((game) => {
const { name, accent } = GAME_META[game];
const s = stats?.[game];
const pct = s?.pct ?? 0;
const label = !s || s.completed === 0 ? "Commencer" : `Niv. ${s.nextLevel}`;
return (
<Link
key={game}
href={`/${game}/levels`}
className="group flex items-center gap-4 p-4 bg-white rounded-2xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all"
>
<div className="w-[68px] shrink-0">
<span className="text-sm font-semibold text-gray-800 group-hover:text-gray-600">
{name}
</span>
</div>
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
{s ? (
<>
<ProgressBar pct={pct} color={accent} />
<div className="flex items-center justify-between">
<span className="text-[11px] text-gray-400">{s.completed} / 100</span>
{s.bestTime > 0 && (
<span className="text-[11px] text-gray-300 timer-mono"> {fmt(s.bestTime)}</span>
)}
</div>
</>
) : (
<div className="skeleton h-4 rounded" />
)}
</div>
<div
className="shrink-0 text-xs font-semibold px-3 py-1.5 rounded-full whitespace-nowrap transition-colors"
style={{ background: `${accent}18`, color: accent }}
>
{label}
</div>
</Link>
);
})}
</div>
</section>
<Link
href="/archive"
className="text-xs text-gray-300 hover:text-gray-500 transition-colors self-start"
>
Archives des puzzles quotidiens
</Link>
</div>
);
}