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

241 lines
8.1 KiB
TypeScript

"use client";
import { useMemo, useState, useEffect, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { todayISO } from "@/lib/rng";
const GAMES = [
{
key: "queens",
label: "Queens",
symbol: "♛",
href: (d: string) => `/queens/${d}`,
storageKey: (d: string) => `queens-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as string[][]).flat().filter(c => c === "queen").length >= 6; } catch { return false; }
},
},
{
key: "tango",
label: "Tango",
symbol: "☀",
href: (d: string) => `/tango/${d}`,
storageKey: (d: string) => `tango-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as (string | null)[][]).flat().every(c => c !== null); } catch { return false; }
},
},
{
key: "zip",
label: "Zip",
symbol: "∞",
href: (d: string) => `/zip/${d}`,
storageKey: (d: string) => `zip-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as unknown[]).length === 25; } catch { return false; }
},
},
{
key: "sudoku",
label: "Sudoku",
symbol: "#",
href: (d: string) => `/sudoku/${d}`,
storageKey: (d: string) => `sudoku-${d}`,
isWon: (s: string) => {
try { return (JSON.parse(s) as number[][]).flat().every(n => n > 0); } catch { return false; }
},
},
{
key: "patches",
label: "Patches",
symbol: "▦",
href: (d: string) => `/patches/${d}`,
storageKey: (d: string) => `patches-${d}`,
isWon: (s: string) => {
try {
const p = JSON.parse(s);
// patches stores the placed pieces array
return Array.isArray(p) && p.length > 0;
} catch { return false; }
},
},
];
function getPastDates(n: number): string[] {
const dates: string[] = [];
const d = new Date();
for (let i = 0; i < n; i++) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
dates.push(`${y}-${m}-${day}`);
d.setDate(d.getDate() - 1);
}
return dates;
}
function fmtDate(iso: string, today: string): string {
if (iso === today) return "Aujourd'hui";
const [y, m, d] = iso.split("-").map(Number);
const date = new Date(y, m - 1, d);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
if (iso === getPastDates(2)[1]) return "Hier";
return date.toLocaleDateString("fr-FR", { weekday: "short", day: "numeric", month: "short" });
}
function GameChip({ game, date, won }: { game: typeof GAMES[0]; date: string; won: boolean | undefined }) {
return (
<Link
href={game.href(date)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors border ${
won
? "bg-green-50 border-green-200 text-green-700 hover:bg-green-100"
: "bg-gray-50 border-gray-100 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
}`}
>
<span className="text-[11px] opacity-60">{game.symbol}</span>
<span>{game.label}</span>
{won && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" className="text-green-600">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</Link>
);
}
function ArchiveContent() {
const params = useSearchParams();
const filter = params.get("game") ?? "all";
const [progress, setProgress] = useState<Record<string, boolean | undefined>>({});
const today = todayISO();
const dates = useMemo(() => getPastDates(90), []);
useEffect(() => {
const rec: Record<string, boolean | undefined> = {};
for (const game of GAMES) {
for (const date of dates) {
const s = localStorage.getItem(game.storageKey(date));
if (s) {
rec[`${game.key}-${date}`] = game.isWon(s);
}
}
}
setProgress(rec);
}, [dates]);
const visibleGames = filter === "all" ? GAMES : GAMES.filter(g => g.key === filter);
// Compute completion summary per visible game
const summary = useMemo(() => {
return visibleGames.map(game => {
const solved = dates.filter(d => progress[`${game.key}-${d}`] === true).length;
return { key: game.key, label: game.label, solved };
});
}, [visibleGames, dates, progress]);
return (
<div className="flex flex-col gap-6 max-w-2xl mx-auto">
{/* Header */}
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Archives</h1>
<p className="text-gray-400 text-sm mt-1">90 derniers jours</p>
</div>
{/* Filter tabs */}
<div className="flex gap-1.5 flex-wrap justify-center">
{[{ k: "all", l: "Tous", sym: "" }, ...GAMES.map(g => ({ k: g.key, l: g.label, sym: g.symbol }))].map(({ k, l, sym }) => (
<Link
key={k}
href={k === "all" ? "/archive" : `/archive?game=${k}`}
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
filter === k
? "bg-gray-900 text-white"
: "bg-white text-gray-500 border border-gray-200 hover:bg-gray-50 hover:text-gray-700"
}`}
>
{sym && <span className="text-[12px] opacity-70">{sym}</span>}
{l}
</Link>
))}
</div>
{/* Completion summary chips */}
{Object.keys(progress).length > 0 && (
<div className="flex gap-2 flex-wrap justify-center">
{summary.map(({ key, label, solved }) => (
<div key={key} className="flex items-center gap-1.5 text-xs text-gray-500 bg-white border border-gray-100 rounded-full px-3 py-1">
<span className="font-semibold text-gray-700">{solved}</span>
<span>/ 90</span>
<span className="text-gray-400">{label}</span>
</div>
))}
</div>
)}
{/* Date rows */}
<div className="flex flex-col gap-1.5">
{dates.map(date => {
const isToday = date === today;
const dateLabel = fmtDate(date, today);
const solvedAll = visibleGames.every(g => progress[`${g.key}-${date}`] === true);
const solvedCount = visibleGames.filter(g => progress[`${g.key}-${date}`] === true).length;
return (
<div
key={date}
className={`flex items-center gap-3 px-4 py-2.5 bg-white rounded-xl border transition-colors ${
isToday ? "border-amber-200 shadow-sm" : "border-gray-100"
}`}
>
{/* Date label */}
<div className="w-24 shrink-0 flex flex-col">
<span className={`text-sm font-semibold leading-tight ${isToday ? "text-amber-700" : "text-gray-700"}`}>
{dateLabel}
</span>
{isToday && (
<span className="text-[10px] text-amber-500 font-medium">Aujourd&apos;hui</span>
)}
</div>
{/* Game chips */}
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
{visibleGames.map(game => (
<GameChip
key={game.key}
game={game}
date={date}
won={progress[`${game.key}-${date}`]}
/>
))}
</div>
{/* Right: completion indicator */}
{solvedCount > 0 && (
<div className={`shrink-0 text-xs font-semibold tabular-nums ${solvedAll ? "text-green-600" : "text-gray-400"}`}>
{solvedCount}/{visibleGames.length}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
export default function ArchivePage() {
return (
<Suspense fallback={
<div className="flex flex-col gap-3 max-w-2xl mx-auto py-8">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="skeleton h-12 rounded-xl" />
))}
</div>
}>
<ArchiveContent />
</Suspense>
);
}