/** * The progress page's data: GET /api/stats from the SRS Worker, fetched in the * browser so `blume build` never depends on the API being up. API_BASE is the * deployed Worker — do not inline it anywhere else. * * Four islands read this (SolvedRing, SubmissionHeatmap, RecentSolves, * TopicBubbles). Each hydrates on its own, but they share this module, so the * one in-flight request is shared too: the page asks the Worker once. * * Invariants: a malformed body is an error state, never a throw; the first * frame is always the loading state, so server and client render identically. */ import { useEffect, useState } from "react"; const API_BASE = "https://srs-api.prdlk.workers.dev"; // ── /api/stats response shape (the fields these islands read) ──── export const DIFFICULTIES = ["easy", "medium", "hard"] as const; export type Difficulty = (typeof DIFFICULTIES)[number]; export interface Stats { generated: string; /** Per difficulty, catalog size and how many entered the ladder. */ difficulty: { difficulty: Difficulty; total: number; done: number }[]; /** Problems whose most recent attempt failed — the sweep day's miss log. */ attempting: number; topics: { issue: number; name: string; total: number; done: number; taught: boolean }[]; /** The ten most recently solved, newest first, dated by first pass (ET). */ recent: { lc: number; title: string; difficulty: Difficulty; date: string }[]; /** * The 365 days ending today (ET), zeros included — always the current * calendar year to date. Per difficulty: every logged attempt, and every * first solve (the first-pass date `recent` uses). */ year: Day[]; } export interface Day { date: string; passes: number; attempts: Record; solved: Record; } function isStats(value: unknown): value is Stats { if (typeof value !== "object" || value === null) return false; const v = value as Record; return ( Array.isArray(v.difficulty) && typeof v.attempting === "number" && Array.isArray(v.topics) && Array.isArray(v.recent) && Array.isArray(v.year) ); } // ── the shared request ─────────────────────────────────────────── export type Load = { phase: "loading" } | { phase: "error" } | { phase: "ready"; stats: Stats }; let request: Promise | undefined; /** The page's stats, fetched once however many islands ask. */ export function useStats(): Load { const [load, setLoad] = useState({ phase: "loading" }); useEffect(() => { if (!request) { request = fetch(`${API_BASE}/api/stats`) .then((res) => (res.ok ? res.json() : null)) .then((body: unknown) => (isStats(body) ? body : null)) .catch(() => null); } let live = true; request.then((stats) => { if (live) setLoad(stats ? { phase: "ready", stats } : { phase: "error" }); }); return () => { live = false; }; }, []); return load; } /** "YYYY-MM-DD" → a UTC-midnight Date, so calendar math never drifts with the reader's timezone. */ export function utcDate(iso: string): Date { const [y, m, d] = iso.split("-").map(Number); return new Date(Date.UTC(y!, m! - 1, d!)); }