feat(docs): add progress dashboard components and styling

This commit is contained in:
Prad Nukala committed 2026-09-29 17:28:59 -04:00
1 parent 5ed33057ed
commit 63f0a81af3
16 files changed
+1266 -430

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
// Islands and the Blume modules they import (blume/components/islands/
// base-path.ts) are bundled by Vite, which provides import.meta.env. Vite is
// Blume's dependency, not this workspace's, so its client types are not
// resolvable from here; this mirrors the slice of them (ImportMetaEnv) tsc needs.
interface ImportMeta {
readonly env: {
readonly [key: string]: unknown;
readonly BASE_URL: string;
readonly MODE: string;
readonly DEV: boolean;
readonly PROD: boolean;
readonly SSR: boolean;
};
}
+85
View File
@@ -0,0 +1,85 @@
/**
* 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!));
}
+7
View File
@@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/** shadcn's class combiner: conditional classes, later Tailwind utilities win. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}