mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
86 lines
3.2 KiB
TypeScript
86 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, plus LeetCode's three figures. */
|
|
year: {
|
|
days: { date: string; attempts: number; passes: number }[];
|
|
attempts: number;
|
|
active_days: number;
|
|
max_streak: number;
|
|
};
|
|
}
|
|
|
|
function isStats(value: unknown): value is Stats {
|
|
if (typeof value !== "object" || value === null) return false;
|
|
const v = value as Record<string, unknown>;
|
|
const year = v.year as Record<string, unknown> | null | undefined;
|
|
return (
|
|
Array.isArray(v.difficulty) &&
|
|
typeof v.attempting === "number" &&
|
|
Array.isArray(v.topics) &&
|
|
Array.isArray(v.recent) &&
|
|
typeof year === "object" &&
|
|
year !== null &&
|
|
Array.isArray(year.days)
|
|
);
|
|
}
|
|
|
|
// ── 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!));
|
|
}
|