dd9497193a
Build and push image / build (push) Successful in 8m18s
Prerendering every past answer page in six languages meant 700 static pages, and the CI runner blew past Next's 60s per-page export timeout on /puzzle/236, failing the build. The set also grew by six pages a day, so every build would have been slower than the last. generateStaticParams now covers the most recent 30 days per locale (202 pages, ~35s locally). Older days keep working exactly as before: they are still linked, still in the sitemap and still indexable, but render on first request and are then cached by ISR — measured at 180ms cold. Also raises staticPageGenerationTimeout to 180s for headroom, since the runner shares a host with Gitea and Postgres. Refs #6 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QvQ1ErZNRUcWgCEkJ9uyrn
149 lines
5.6 KiB
TypeScript
149 lines
5.6 KiB
TypeScript
import playersData from "@/data/players.json";
|
|
import puzzlesData from "@/data/puzzles.json";
|
|
import { getDateKeyInTimeZone } from "./time";
|
|
import type { Difficulty, Feedback, Guess, Player, Position, Puzzle } from "./types";
|
|
|
|
export const POSITIONS: Position[] = ["GK", "DEF", "MID", "WING", "ST"];
|
|
export const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
|
|
export const MAX_ATTEMPTS = 6;
|
|
|
|
export const players = playersData as Player[];
|
|
export const puzzles = puzzlesData as Puzzle[];
|
|
|
|
export function getTodayKey(date = new Date()): string {
|
|
return getDateKeyInTimeZone(date);
|
|
}
|
|
|
|
export function getPuzzleNumber(dateKey: string): number {
|
|
const epoch = Date.UTC(2026, 0, 1);
|
|
const current = Date.parse(`${dateKey}T00:00:00.000Z`);
|
|
return Math.max(1, Math.floor((current - epoch) / 86400000) + 1);
|
|
}
|
|
|
|
export function getDateKeyForPuzzleNumber(puzzleNumber: number): string {
|
|
const epoch = Date.UTC(2026, 0, 1);
|
|
return new Date(epoch + (puzzleNumber - 1) * 86400000).toISOString().slice(0, 10);
|
|
}
|
|
|
|
// Past days that have a dedicated (non-rotated) puzzle set — the only ones the
|
|
// public archive exposes, so answer pages never duplicate or leak content.
|
|
export function getArchiveDateKeys(todayKey: string): string[] {
|
|
const complete = new Map<string, number>();
|
|
puzzles.forEach((puzzle) => {
|
|
complete.set(puzzle.date, (complete.get(puzzle.date) ?? 0) + 1);
|
|
});
|
|
|
|
return Array.from(complete.entries())
|
|
.filter(([date, count]) => count === DIFFICULTIES.length && date < todayKey)
|
|
.map(([date]) => date)
|
|
.sort((left, right) => right.localeCompare(left));
|
|
}
|
|
|
|
// Resolves a public answer-page target from a raw URL segment. Returns null for
|
|
// malformed numbers, for days without a dedicated puzzle set, and for today or
|
|
// any future day — solutions are never rendered before they have been played.
|
|
export function getPastPuzzleSet(rawNumber: string): { number: number; dateKey: string; sets: Puzzle[] } | null {
|
|
const number = Number.parseInt(rawNumber, 10);
|
|
if (!Number.isFinite(number) || number < 1 || String(number) !== rawNumber) return null;
|
|
|
|
const dateKey = getDateKeyForPuzzleNumber(number);
|
|
if (dateKey >= getTodayKey()) return null;
|
|
|
|
const sets = puzzles.filter((puzzle) => puzzle.date === dateKey);
|
|
if (sets.length !== DIFFICULTIES.length) return null;
|
|
|
|
return { number, dateKey, sets };
|
|
}
|
|
|
|
// How many recent days get prerendered at build time. The archive grows by one
|
|
// day forever, so an unbounded generateStaticParams would make every CI build
|
|
// slower than the last — 700 pages already blew past Next's per-page export
|
|
// timeout. Older answer pages stay fully public and indexable: they are just
|
|
// rendered on first request and then cached by ISR.
|
|
export const PRERENDERED_ARCHIVE_DAYS = 30;
|
|
|
|
export function getRecentArchiveDateKeys(todayKey: string): string[] {
|
|
return getArchiveDateKeys(todayKey).slice(0, PRERENDERED_ARCHIVE_DAYS);
|
|
}
|
|
|
|
export function getDailyPuzzles(dateKey: string): Puzzle[] {
|
|
const dated = puzzles.filter((puzzle) => puzzle.date === dateKey);
|
|
const source = dated.length === DIFFICULTIES.length ? dated : getRotatingPuzzleSet(dateKey);
|
|
|
|
return DIFFICULTIES.map((difficulty) => {
|
|
const puzzle = source.find((item) => item.difficulty === difficulty);
|
|
if (!puzzle) {
|
|
throw new Error(`Missing ${difficulty} puzzle`);
|
|
}
|
|
return puzzle;
|
|
});
|
|
}
|
|
|
|
function getRotatingPuzzleSet(dateKey: string): Puzzle[] {
|
|
const grouped = Array.from(
|
|
puzzles.reduce((map, puzzle) => {
|
|
const bucket = map.get(puzzle.date) ?? [];
|
|
bucket.push(puzzle);
|
|
map.set(puzzle.date, bucket);
|
|
return map;
|
|
}, new Map<string, Puzzle[]>()),
|
|
)
|
|
.map(([, items]) => items)
|
|
.filter((items) => items.length === DIFFICULTIES.length)
|
|
.sort((left, right) => left[0].date.localeCompare(right[0].date));
|
|
|
|
if (grouped.length === 0) {
|
|
throw new Error("No complete puzzle sets available");
|
|
}
|
|
|
|
const index = (getPuzzleNumber(dateKey) - 1) % grouped.length;
|
|
return grouped[index];
|
|
}
|
|
|
|
export function findPlayer(id: string | null): Player | undefined {
|
|
if (!id) return undefined;
|
|
return players.find((player) => player.id === id);
|
|
}
|
|
|
|
export function validateGuess(puzzle: Puzzle, guess: Record<Position, string | null>): Guess {
|
|
return {
|
|
slots: POSITIONS.map((position) => ({
|
|
position,
|
|
playerId: guess[position],
|
|
feedback: scoreSlot(puzzle.solution[position], guess[position]),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function isSolved(guess: Guess): boolean {
|
|
return guess.slots.every((slot) => slot.feedback === "correct");
|
|
}
|
|
|
|
export function isAttemptComplete(guess: Record<Position, string | null>): boolean {
|
|
return POSITIONS.every((position) => Boolean(guess[position]));
|
|
}
|
|
|
|
export function getFixedGuess(puzzle: Puzzle): Record<Position, string | null> {
|
|
return POSITIONS.reduce<Record<Position, string | null>>((acc, position) => {
|
|
acc[position] = position === puzzle.fixedPosition ? puzzle.solution[position] : null;
|
|
return acc;
|
|
}, {} as Record<Position, string | null>);
|
|
}
|
|
|
|
function scoreSlot(solutionId: string, guessId: string | null): Feedback {
|
|
if (!guessId) return "empty";
|
|
if (guessId === solutionId) return "correct";
|
|
|
|
const solution = findPlayer(solutionId);
|
|
const guess = findPlayer(guessId);
|
|
|
|
if (!solution || !guess) return "wrong";
|
|
|
|
const hasClubRelation = solution.club === guess.club;
|
|
const hasLeagueMatch = solution.league === guess.league;
|
|
const hasNationalityMatch = solution.nationality === guess.nationality;
|
|
const hasPositionMatch = solution.position === guess.position;
|
|
|
|
return hasClubRelation || hasLeagueMatch || hasNationalityMatch || hasPositionMatch ? "partial" : "wrong";
|
|
}
|