Files
leetcode/apps/docs/lib/stats.ts
T

89 lines
3.2 KiB
TypeScript

/**
* 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<Difficulty, number>;
solved: Record<Difficulty, number>;
}
function isStats(value: unknown): value is Stats {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
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<Stats | null> | undefined;
/** The page's stats, fetched once however many islands ask. */
export function useStats(): Load {
const [load, setLoad] = useState<Load>({ phase: "loading" });
useEffect(() => {
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!));
}