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