mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
feat(docs): add progress dashboard components and styling
This commit is contained in:
1 parent
5ed33057ed
commit
63f0a81af3
16 files changed
+1266
-430
No files matched your search
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "theme.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* What the four progress islands share besides the data: the not-ready body
|
||||
* (a skeleton while /api/stats loads, one muted line if it fails) and the
|
||||
* hover tooltip for the two charts drawn as plain SVG (the heatmap calendar
|
||||
* and the topic bubbles — Recharts has neither layout). The tooltip wears the
|
||||
* exact classes of shadcn's ChartTooltipContent, so all four cards' tooltips
|
||||
* look like one component.
|
||||
*/
|
||||
import { type PointerEvent, type ReactNode, useRef, useState } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Load } from "../../lib/stats";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function StatsPending({ load, className }: { load: Exclude<Load, { phase: "ready" }>; className: string }) {
|
||||
if (load.phase === "loading") return <Skeleton className={className} />;
|
||||
return (
|
||||
<p className="my-0! text-muted-foreground text-sm">
|
||||
Stats unavailable — they are served live from the SRS Worker, and it did not respond.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface Tip {
|
||||
x: number;
|
||||
y: number;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer-following tooltip for SVG marks. Put `ref` on a `relative`
|
||||
* wrapper, call `show` from a mark's pointer events, render `tip` inside the
|
||||
* wrapper. Positions are relative to the wrapper, so page scroll never skews it.
|
||||
*/
|
||||
export function useHoverTip<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [tip, setTip] = useState<Tip | null>(null);
|
||||
const show = (event: PointerEvent, content: ReactNode) => {
|
||||
const box = ref.current?.getBoundingClientRect();
|
||||
if (box) setTip({ x: event.clientX - box.left, y: event.clientY - box.top, content });
|
||||
};
|
||||
const hide = () => setTip(null);
|
||||
const node = tip && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-10 grid min-w-[8rem] -translate-x-1/2 -translate-y-[calc(100%+0.75rem)] items-start gap-1.5",
|
||||
"rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
)}
|
||||
style={{ left: tip.x, top: tip.y }}
|
||||
>
|
||||
{tip.content}
|
||||
</div>
|
||||
);
|
||||
return { ref, show, hide, node };
|
||||
}
|
||||
|
||||
/** One tooltip row, the shape ChartTooltipContent draws: colour dot, label, mono value. */
|
||||
export function TipRow({ color, label, value }: { color: string; label: ReactNode; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="size-2.5 shrink-0 rounded-[2px]" style={{ background: color }} />
|
||||
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium font-mono text-foreground tabular-nums">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }. Blume switches dark mode with
|
||||
// [data-theme="dark"] on <html>, not shadcn's .dark class.
|
||||
const THEMES = { light: "", dark: ':root[data-theme="dark"]' } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,7 +1,12 @@
|
||||
---
|
||||
title: Progress
|
||||
description: Live SRS campaign dashboard — phases, ladder, review queue, gates, and recent attempts, fetched from the SRS Worker.
|
||||
description: Live campaign progress — solved by difficulty, the past year of attempts, topic coverage, and the most recently solved problems, fetched from the SRS Worker.
|
||||
icon: loader
|
||||
---
|
||||
|
||||
<ProgressDashboard />
|
||||
<div className="not-prose flex flex-col gap-6">
|
||||
<SolvedRing />
|
||||
<SubmissionHeatmap />
|
||||
<TopicBubbles />
|
||||
<RecentSolves />
|
||||
</div>
|
||||
@@ -1,428 +0,0 @@
|
||||
/**
|
||||
* Progress dashboard island — the expanded, always-current version of the
|
||||
* daily email footer. Rendered on /progress; fetches campaign state from the
|
||||
* SRS Worker at page load, entirely client-side, so `blume build` never
|
||||
* depends on the API being up. API_BASE is a placeholder stamped in after the
|
||||
* Worker deploys — do not inline it anywhere else.
|
||||
*
|
||||
* Invariants: the component never throws on malformed API data (a shape guard
|
||||
* downgrades to the error state), and the first frame is always the loading
|
||||
* state so server and client render identically.
|
||||
*/
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const API_BASE = "https://srs-api.prdlk.workers.dev";
|
||||
|
||||
// ── /api/stats response shape ────────────────────────────────────
|
||||
|
||||
// Mirrors STAGES in apps/api/src/srs.ts — the ladder rungs, in order. The
|
||||
// Worker ships every rung in `ladder`, zeros included.
|
||||
const STAGES = ["new", "+3", "+7", "retired"] as const;
|
||||
|
||||
type Stage = (typeof STAGES)[number];
|
||||
|
||||
interface Stats {
|
||||
generated: string;
|
||||
campaign: { day: number; week: number; start: string; days: number };
|
||||
phases: {
|
||||
milestone: number;
|
||||
name: string;
|
||||
core_done: number;
|
||||
core_total: number;
|
||||
optional_done: number;
|
||||
optional_total: number;
|
||||
deferred_done: number;
|
||||
deferred_total: number;
|
||||
}[];
|
||||
ladder: Record<Stage, number>;
|
||||
/** week is the 1-based campaign week — the Worker converts from ISO. */
|
||||
gates: {
|
||||
week: number;
|
||||
issue: number | null;
|
||||
pass_rate: number | null;
|
||||
closed_on: string | null;
|
||||
}[];
|
||||
streak: number;
|
||||
queue: { date: string; due: number }[];
|
||||
/** Every campaign day, zeros included; this page reads only the tail. */
|
||||
heat: { date: string; attempts: number; passes: number }[];
|
||||
}
|
||||
|
||||
function isStats(value: unknown): value is Stats {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.campaign === "object" &&
|
||||
v.campaign !== null &&
|
||||
typeof v.ladder === "object" &&
|
||||
v.ladder !== null &&
|
||||
Array.isArray(v.phases) &&
|
||||
Array.isArray(v.gates) &&
|
||||
Array.isArray(v.queue) &&
|
||||
Array.isArray(v.heat) &&
|
||||
typeof v.streak === "number"
|
||||
);
|
||||
}
|
||||
|
||||
// ── formatting ───────────────────────────────────────────────────
|
||||
|
||||
/** Legacy gates stored integer percent; the Worker may emit a 0–1 fraction. */
|
||||
function percent(rate: number): string {
|
||||
return `${Math.round(rate <= 1 ? rate * 100 : rate)}%`;
|
||||
}
|
||||
|
||||
/** "2026-08-24" → "Mon 24" without timezone drift. */
|
||||
function dayLabel(iso: string): string {
|
||||
const d = new Date(`${iso}T00:00:00`);
|
||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
return `${weekday} ${iso.slice(8)}`;
|
||||
}
|
||||
|
||||
// ── shared styles ────────────────────────────────────────────────
|
||||
// Theme tokens only, with two deliberate exceptions: the phase-bar sets get
|
||||
// fixed hues (accent-blue core, yellow optional, red deferred — they must be
|
||||
// distinguishable, not shades of one colour), and the last-7-days heatmap
|
||||
// reuses the GREENS ramp from apps/api/src/charts.ts so the page matches the
|
||||
// README/email heatmaps (level 0 stays a theme token so light mode works).
|
||||
|
||||
const card: CSSProperties = {
|
||||
border: "1px solid var(--blume-border)",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "1rem",
|
||||
};
|
||||
|
||||
const muted: CSSProperties = {
|
||||
color: "var(--blume-muted-foreground)",
|
||||
fontSize: "0.85em",
|
||||
};
|
||||
|
||||
const heading: CSSProperties = {
|
||||
fontWeight: 600,
|
||||
marginBottom: "0.5rem",
|
||||
};
|
||||
|
||||
function Bar({ done, total }: { done: number; total: number }) {
|
||||
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "10rem",
|
||||
maxWidth: "40vw",
|
||||
height: "0.5rem",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
background: "var(--blume-muted)",
|
||||
verticalAlign: "middle",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
width: `${ratio * 100}%`,
|
||||
height: "100%",
|
||||
background: "var(--blume-accent)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── sections ─────────────────────────────────────────────────────
|
||||
|
||||
function Header({ stats }: { stats: Stats }) {
|
||||
const { campaign, streak } = stats;
|
||||
return (
|
||||
<div style={{ ...card, display: "flex", gap: "1.5rem", flexWrap: "wrap" }}>
|
||||
<span>
|
||||
<strong>
|
||||
Day {campaign.day}/{campaign.days}
|
||||
</strong>
|
||||
</span>
|
||||
<span>Week {campaign.week}</span>
|
||||
<span>
|
||||
Streak: <strong>{streak}</strong> day{streak === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span style={muted}>started {campaign.start}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One stacked bar per phase: solved core / optional / deferred segments over
|
||||
// a muted track sized by the phase's total problem count, so both phase size
|
||||
// and progress read at a glance.
|
||||
const SETS = [
|
||||
["core", "var(--blume-accent)"],
|
||||
["optional", "#d29922"],
|
||||
["deferred", "#e5534b"],
|
||||
] as const;
|
||||
|
||||
function Phases({ stats }: { stats: Stats }) {
|
||||
const maxTotal = Math.max(
|
||||
1,
|
||||
...stats.phases.map((p) => p.core_total + p.optional_total + p.deferred_total),
|
||||
);
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Phases</div>
|
||||
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap", marginBottom: "0.6rem" }}>
|
||||
{SETS.map(([set, color]) => (
|
||||
<span key={set} style={muted}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "0.65rem",
|
||||
height: "0.65rem",
|
||||
borderRadius: "2px",
|
||||
background: color,
|
||||
marginRight: "0.35rem",
|
||||
}}
|
||||
/>
|
||||
{set}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{stats.phases.map((p) => {
|
||||
const total = p.core_total + p.optional_total + p.deferred_total;
|
||||
const done = p.core_done + p.optional_done + p.deferred_done;
|
||||
return (
|
||||
<div
|
||||
key={p.milestone}
|
||||
title={`core ${p.core_done}/${p.core_total} · optional ${p.optional_done}/${p.optional_total} · deferred ${p.deferred_done}/${p.deferred_total}`}
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.75rem", padding: "0.25rem 0" }}
|
||||
>
|
||||
<span style={{ width: "9.5rem", flexShrink: 0 }}>{p.name}</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
width: `${(total / maxTotal) * 100}%`,
|
||||
height: "0.65rem",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
background: "var(--blume-muted)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{SETS.map(([set, color]) => {
|
||||
const solved = p[`${set}_done`];
|
||||
return solved > 0 ? (
|
||||
<span
|
||||
key={set}
|
||||
style={{ width: `${(solved / total) * 100}%`, background: color }}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ ...muted, width: "3.5rem", textAlign: "right", flexShrink: 0 }}>
|
||||
{done}/{total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ladder({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>SRS ladder</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{STAGES.map((stage) => (
|
||||
<span
|
||||
key={stage}
|
||||
style={{
|
||||
background: "var(--blume-muted)",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
padding: "0.35rem 0.75rem",
|
||||
}}
|
||||
>
|
||||
<span style={muted}>{stage}</span>{" "}
|
||||
<strong>{stats.ladder[stage] ?? 0}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Queue({ stats }: { stats: Stats }) {
|
||||
const max = Math.max(1, ...stats.queue.map((q) => q.due));
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Review queue — next 14 days</div>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<tbody>
|
||||
{stats.queue.map((q) => (
|
||||
<tr key={q.date}>
|
||||
<td style={{ ...muted, padding: "0.1rem 1rem 0.1rem 0" }}>
|
||||
{dayLabel(q.date)}
|
||||
</td>
|
||||
<td style={{ padding: "0.1rem 0.75rem 0.1rem 0" }}>
|
||||
<Bar done={q.due} total={max} />
|
||||
</td>
|
||||
<td>{q.due}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gates({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Gate log</div>
|
||||
{stats.gates.length === 0 ? (
|
||||
<span style={muted}>No gates yet — the first weekly review opens Saturday night.</span>
|
||||
) : (
|
||||
stats.gates.map((g) => (
|
||||
<div
|
||||
key={g.week}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
alignItems: "baseline",
|
||||
flexWrap: "wrap",
|
||||
padding: "0.15rem 0",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: "4.5rem", flexShrink: 0 }}>Week {g.week}</span>
|
||||
{g.issue === null ? (
|
||||
<span style={muted}>no review issue</span>
|
||||
) : (
|
||||
<a
|
||||
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
#{g.issue}
|
||||
</a>
|
||||
)}
|
||||
{g.pass_rate === null ? (
|
||||
<span style={muted}>in progress</span>
|
||||
) : (
|
||||
<span>
|
||||
{percent(g.pass_rate)} passed
|
||||
{g.closed_on && <span style={muted}> · closed {g.closed_on}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div style={{ ...muted, marginTop: "0.4rem" }}>
|
||||
One gate per campaign week — scored when its review issue closes.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// GREENS ramp from apps/api/src/charts.ts; level 0 swapped for the theme's
|
||||
// muted token so an empty day looks right in both colour schemes.
|
||||
const GREENS = [
|
||||
"var(--blume-muted)",
|
||||
"#0e4429",
|
||||
"#006d32",
|
||||
"#26a641",
|
||||
"#39d353",
|
||||
];
|
||||
|
||||
function Recent({ stats }: { stats: Stats }) {
|
||||
// heat spans the whole campaign, future days and all. The window wanted here
|
||||
// is the seven days ending today, and campaign.day is that day, 1-based.
|
||||
const week = stats.heat.slice(Math.max(0, stats.campaign.day - 7), stats.campaign.day);
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Last 7 days</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||
{week.map((r) => (
|
||||
<span
|
||||
key={r.date}
|
||||
title={`${r.date}: ${r.passes}/${r.attempts} passed`}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
width: "2.6rem",
|
||||
height: "2rem",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
background: GREENS[Math.min(r.attempts, 4)],
|
||||
}}
|
||||
/>
|
||||
<span style={{ ...muted, display: "block", marginTop: "0.25rem", fontSize: "0.75em" }}>
|
||||
{dayLabel(r.date)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ ...muted, marginTop: "0.5rem", display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
||||
<span>Less</span>
|
||||
{GREENS.map((color) => (
|
||||
<span
|
||||
key={color}
|
||||
style={{ display: "inline-block", width: "0.65rem", height: "0.65rem", borderRadius: "2px", background: color }}
|
||||
/>
|
||||
))}
|
||||
<span>More · colour = attempts, hover for passes</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── dashboard ────────────────────────────────────────────────────
|
||||
|
||||
type Load =
|
||||
| { phase: "loading" }
|
||||
| { phase: "error" }
|
||||
| { phase: "ready"; stats: Stats };
|
||||
|
||||
export default function ProgressDashboard() {
|
||||
const [load, setLoad] = useState<Load>({ phase: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_BASE}/api/stats`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((body: unknown) => {
|
||||
setLoad(isStats(body) ? { phase: "ready", stats: body } : { phase: "error" });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setLoad({ phase: "error" });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (load.phase === "loading") {
|
||||
return <p style={muted}>Loading progress…</p>;
|
||||
}
|
||||
if (load.phase === "error") {
|
||||
return (
|
||||
<p style={muted}>
|
||||
API unreachable — stats are served live from the SRS Worker and it did
|
||||
not respond.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { stats } = load;
|
||||
return (
|
||||
<div>
|
||||
<Header stats={stats} />
|
||||
<Phases stats={stats} />
|
||||
<Ladder stats={stats} />
|
||||
<Queue stats={stats} />
|
||||
<Gates stats={stats} />
|
||||
<Recent stats={stats} />
|
||||
<p style={muted}>Generated {stats.generated}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The ten most recently solved problems — LeetCode's "Recent AC" list. Each
|
||||
* row is dated by the problem's first pass (an ET calendar date: the Worker
|
||||
* logs days, not times), read relative to today in ET with the full date on
|
||||
* hover. A row links to the problem's docs page when one exists: the
|
||||
* sync-generated short links (`redirects.ts`) are exactly the pages built.
|
||||
*/
|
||||
import { StatsPending } from "../components/progress/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { useStats, utcDate } from "../lib/stats";
|
||||
import redirects from "../redirects";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const PAGES = new Map(redirects.map((r) => [r.from, r.to]));
|
||||
|
||||
const ET_TODAY = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" });
|
||||
const RELATIVE = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export default function RecentSolves() {
|
||||
const load = useStats();
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recently solved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-80 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const today = utcDate(ET_TODAY.format(new Date())).getTime();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recently solved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{load.stats.recent.length === 0 ? (
|
||||
<p className="my-0! text-muted-foreground text-sm">Nothing solved yet.</p>
|
||||
) : (
|
||||
<ol className="my-0! flex flex-col">
|
||||
{load.stats.recent.map((p) => {
|
||||
const date = utcDate(p.date);
|
||||
const page = PAGES.get(`/${p.lc}`);
|
||||
const name = (
|
||||
<>
|
||||
<span className="text-muted-foreground tabular-nums">{p.lc}.</span> {p.title}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={p.lc}
|
||||
className="flex items-center justify-between gap-4 rounded-lg px-4 py-3 text-sm odd:bg-muted"
|
||||
>
|
||||
{page ? (
|
||||
<a href={page} className="truncate font-normal! text-foreground no-underline! hover:underline!">
|
||||
{name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="truncate">{name}</span>
|
||||
)}
|
||||
<time dateTime={p.date} title={FULL.format(date)} className="shrink-0 text-muted-foreground">
|
||||
{RELATIVE.format(Math.round((date.getTime() - today) / DAY_MS), "day")}
|
||||
</time>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Solved-by-difficulty ring — LeetCode's profile gauge. A 270° arc split into
|
||||
* one segment per difficulty, each sized by that difficulty's share of the
|
||||
* catalog; the faint track is the whole set, the solid arc the solved part.
|
||||
* The centre reads the total, or one difficulty while its segment (or its
|
||||
* stat box) is hovered. "Attempting" is the open-miss count: problems whose
|
||||
* latest attempt failed.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Pie, PieChart } from "recharts";
|
||||
import { StatsPending } from "../components/progress/shared";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { type ChartConfig, ChartContainer } from "../components/ui/chart";
|
||||
import { DIFFICULTIES, type Difficulty, useStats } from "../lib/stats";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const chartConfig = {
|
||||
easy: { label: "Easy", color: "var(--chart-1)" },
|
||||
medium: { label: "Med.", color: "var(--chart-2)" },
|
||||
hard: { label: "Hard", color: "var(--chart-3)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
// Static class names, so Tailwind's scanner sees every one (the chart's
|
||||
// --color-* variables exist only inside ChartContainer).
|
||||
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
|
||||
easy: "text-chart-1",
|
||||
medium: "text-chart-2",
|
||||
hard: "text-chart-3",
|
||||
};
|
||||
|
||||
// The gauge opens at the bottom: from 225° (lower left) clockwise to -45°
|
||||
// (lower right), in Recharts' counter-clockwise-from-3-o'clock degrees.
|
||||
const START = 225;
|
||||
const SWEEP = 270;
|
||||
const GAP = 8;
|
||||
|
||||
export default function SolvedRing() {
|
||||
const load = useStats();
|
||||
const [focus, setFocus] = useState<Difficulty | null>(null);
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-52 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = DIFFICULTIES.map(
|
||||
(difficulty) => load.stats.difficulty.find((d) => d.difficulty === difficulty) ?? { difficulty, total: 0, done: 0 },
|
||||
);
|
||||
const total = rows.reduce((n, r) => n + r.total, 0);
|
||||
const done = rows.reduce((n, r) => n + r.done, 0);
|
||||
const span = SWEEP - GAP * (rows.length - 1);
|
||||
|
||||
let cursor = START;
|
||||
const segments = rows.map((r) => {
|
||||
const size = total > 0 ? (span * r.total) / total : 0;
|
||||
const from = cursor;
|
||||
cursor -= size + GAP;
|
||||
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
|
||||
return { ...r, from, to: from - size, solvedTo: from - solved };
|
||||
});
|
||||
|
||||
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
|
||||
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-6 sm:flex-row">
|
||||
<div className="relative w-full max-w-56 shrink-0">
|
||||
<ChartContainer config={chartConfig} className="aspect-square w-full">
|
||||
<PieChart>
|
||||
{segments.map((s) => (
|
||||
<Pie
|
||||
key={`${s.difficulty}-track`}
|
||||
{...arc}
|
||||
data={[{ value: 1 }]}
|
||||
startAngle={s.from}
|
||||
endAngle={s.to}
|
||||
fill={`var(--color-${s.difficulty})`}
|
||||
fillOpacity={focus && focus !== s.difficulty ? 0.12 : 0.25}
|
||||
isAnimationActive={false}
|
||||
onMouseEnter={() => setFocus(s.difficulty)}
|
||||
onMouseLeave={() => setFocus(null)}
|
||||
/>
|
||||
))}
|
||||
{segments
|
||||
.filter((s) => s.done > 0)
|
||||
.map((s) => (
|
||||
<Pie
|
||||
key={`${s.difficulty}-solved`}
|
||||
{...arc}
|
||||
data={[{ value: 1 }]}
|
||||
startAngle={s.from}
|
||||
endAngle={s.solvedTo}
|
||||
fill={`var(--color-${s.difficulty})`}
|
||||
fillOpacity={focus && focus !== s.difficulty ? 0.35 : 1}
|
||||
onMouseEnter={() => setFocus(s.difficulty)}
|
||||
onMouseLeave={() => setFocus(null)}
|
||||
/>
|
||||
))}
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
|
||||
<div className="flex items-baseline">
|
||||
<span className="font-semibold text-4xl tabular-nums">{focused ? focused.done : done}</span>
|
||||
<span className="text-muted-foreground tabular-nums">/{focused ? focused.total : total}</span>
|
||||
</div>
|
||||
<span className={cn("text-sm", focused ? DIFFICULTY_TEXT[focused.difficulty] : "text-muted-foreground")}>
|
||||
{focused ? chartConfig[focused.difficulty].label : "Solved"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 text-center text-muted-foreground text-xs">
|
||||
<span className="font-semibold text-foreground tabular-nums">{load.stats.attempting}</span> Attempting
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-1">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.difficulty}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-lg bg-muted px-4 py-2 transition-opacity",
|
||||
focus && focus !== r.difficulty && "opacity-50",
|
||||
)}
|
||||
onPointerEnter={() => setFocus(r.difficulty)}
|
||||
onPointerLeave={() => setFocus(null)}
|
||||
>
|
||||
<span className={cn("font-medium text-sm", DIFFICULTY_TEXT[r.difficulty])}>
|
||||
{chartConfig[r.difficulty].label}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums">
|
||||
{r.done}/{r.total}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The past year of attempts — LeetCode's calendar heatmap: one cell per day,
|
||||
* weeks as columns, each month its own block with a gap between, and the
|
||||
* year's attempt count, active days and longest streak above it. The colour
|
||||
* ramp is the README/email heatmap's (theme.css `--heat-0..4`, level =
|
||||
* attempts capped at 4) and rows start on Monday like it, so the three
|
||||
* heatmaps read the same. Drawn as SVG in a viewBox, so it scales to the
|
||||
* column instead of scrolling.
|
||||
*/
|
||||
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
|
||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { type Stats, useStats, utcDate } from "../lib/stats";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const CELL = 10;
|
||||
const STEP = 13; // cell + gap
|
||||
const MONTH_GAP = 8;
|
||||
const LABEL_H = 16;
|
||||
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
|
||||
|
||||
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
|
||||
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
||||
|
||||
interface Block {
|
||||
label: string;
|
||||
x: number;
|
||||
width: number;
|
||||
cells: { x: number; y: number; date: Date; attempts: number; passes: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Month blocks laid left to right. Within a block a day sits at row =
|
||||
* weekday (Mon = 0) and column = its week counted from the block's first day,
|
||||
* so a month starting on a Thursday opens with a short first column.
|
||||
*/
|
||||
function layout(days: Stats["year"]["days"]): { blocks: Block[]; width: number } {
|
||||
const blocks: Block[] = [];
|
||||
let x = 0;
|
||||
let month = -1;
|
||||
let block: Block | undefined;
|
||||
let lead = 0;
|
||||
let index = 0;
|
||||
for (const day of days) {
|
||||
const date = utcDate(day.date);
|
||||
if (date.getUTCMonth() !== month) {
|
||||
if (block) x += block.width + MONTH_GAP;
|
||||
month = date.getUTCMonth();
|
||||
lead = (date.getUTCDay() + 6) % 7;
|
||||
index = 0;
|
||||
block = { label: MONTH.format(date), x, width: 0, cells: [] };
|
||||
blocks.push(block);
|
||||
}
|
||||
const row = (lead + index) % 7;
|
||||
const col = Math.floor((lead + index) / 7);
|
||||
block!.cells.push({ x: x + col * STEP, y: row * STEP, date, attempts: day.attempts, passes: day.passes });
|
||||
block!.width = (col + 1) * STEP - (STEP - CELL);
|
||||
index++;
|
||||
}
|
||||
return { blocks, width: block ? x + block.width : 0 };
|
||||
}
|
||||
|
||||
export default function SubmissionHeatmap() {
|
||||
const load = useStats();
|
||||
const hover = useHoverTip<HTMLDivElement>();
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { year } = load.stats;
|
||||
const { blocks, width } = layout(year.days);
|
||||
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-baseline gap-2">
|
||||
<span className="font-semibold text-2xl tabular-nums">{year.attempts}</span>
|
||||
<span className="font-normal text-muted-foreground">attempts in the past one year</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Total active days: <span className="text-foreground tabular-nums">{year.active_days}</span>
|
||||
</CardDescription>
|
||||
<CardAction className="text-muted-foreground text-sm">
|
||||
Max streak: <span className="text-foreground tabular-nums">{year.max_streak}</span>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={hover.ref} className="relative" onPointerLeave={hover.hide}>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" role="img" aria-label="Attempts per day over the past year">
|
||||
{blocks.map((block) => (
|
||||
<g key={`${block.label}-${block.x}`}>
|
||||
{block.cells.map((cell) => (
|
||||
<rect
|
||||
key={cell.x * 1000 + cell.y}
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
rx={2}
|
||||
fill={HEAT[Math.min(cell.attempts, 4)]}
|
||||
onPointerMove={(event) =>
|
||||
hover.show(
|
||||
event,
|
||||
<>
|
||||
<div className="font-medium">{DAY.format(cell.date)}</div>
|
||||
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
|
||||
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
|
||||
</>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{block.width >= 2 * STEP && (
|
||||
<text
|
||||
x={block.x + block.width / 2}
|
||||
y={height - 3}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[9px]"
|
||||
>
|
||||
{block.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{hover.node}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The campaign's topics as packed bubbles — LeetCode's "All Topics" view,
|
||||
* over our curriculum instead of LeetCode's tags. A bubble's area is the
|
||||
* topic's problem count; it fills from the bottom like a glass as problems
|
||||
* are solved, and turns solid once every one is. A topic the schedule has not
|
||||
* reached yet is dimmed. Packing is d3-hierarchy's front-chain (packSiblings,
|
||||
* largest first, so the layout is deterministic), drawn as SVG in a viewBox:
|
||||
* Recharts has no packed layout.
|
||||
*/
|
||||
import { packEnclose, packSiblings } from "d3-hierarchy";
|
||||
import { useId } from "react";
|
||||
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { type Stats, useStats } from "../lib/stats";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const PAD = 2; // gap between bubbles, in layout units
|
||||
const UNIT = 12; // radius of a one-problem bubble
|
||||
|
||||
type Topic = Stats["topics"][number];
|
||||
type Bubble = Topic & { x: number; y: number; r: number };
|
||||
|
||||
/** Split a name across lines of at most ~10 characters, never breaking a word. */
|
||||
function lines(name: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const word of name.split(" ")) {
|
||||
const last = out.at(-1);
|
||||
if (last !== undefined && `${last} ${word}`.length <= 10) out[out.length - 1] = `${last} ${word}`;
|
||||
else out.push(word);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function TopicBubbles() {
|
||||
const load = useStats();
|
||||
const hover = useHoverTip<HTMLDivElement>();
|
||||
const clip = useId().replace(/:/g, "");
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Topics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="aspect-square w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const topics = load.stats.topics.filter((t) => t.total > 0);
|
||||
const circles: Bubble[] = topics
|
||||
.slice()
|
||||
.sort((a, b) => b.total - a.total || a.issue - b.issue)
|
||||
.map((t) => ({ ...t, x: 0, y: 0, r: UNIT * Math.sqrt(t.total) + PAD }));
|
||||
packSiblings(circles);
|
||||
const bound = packEnclose(circles) ?? { x: 0, y: 0, r: 1 };
|
||||
const solved = topics.reduce((n, t) => n + t.done, 0);
|
||||
const total = topics.reduce((n, t) => n + t.total, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Topics</CardTitle>
|
||||
<CardDescription>
|
||||
{solved} of {total} problems across {topics.length} topics · size is problem count, fill is solved
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={hover.ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hover.hide}>
|
||||
<svg
|
||||
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="Campaign topics sized by problem count and filled by problems solved"
|
||||
>
|
||||
{circles.map((c) => {
|
||||
const r = c.r - PAD;
|
||||
const ratio = c.total > 0 ? c.done / c.total : 0;
|
||||
const complete = c.done >= c.total;
|
||||
const words = lines(c.name);
|
||||
const size = Math.min(r / 3.2, (1.7 * r) / Math.max(...words.map((w) => w.length)));
|
||||
const id = `${clip}-${c.issue}`;
|
||||
return (
|
||||
<g
|
||||
key={c.issue}
|
||||
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
|
||||
onPointerMove={(event) =>
|
||||
hover.show(
|
||||
event,
|
||||
<>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<TipRow color="var(--chart-4)" label="Solved" value={`${c.done}/${c.total}`} />
|
||||
{!c.taught && <div className="text-muted-foreground">Not reached yet</div>}
|
||||
</>,
|
||||
)
|
||||
}
|
||||
>
|
||||
<clipPath id={id}>
|
||||
<circle cx={c.x} cy={c.y} r={r} />
|
||||
</clipPath>
|
||||
<circle cx={c.x} cy={c.y} r={r} className="fill-muted stroke-border" strokeWidth={0.5} />
|
||||
<rect
|
||||
x={c.x - r}
|
||||
y={c.y + r - 2 * r * ratio}
|
||||
width={2 * r}
|
||||
height={2 * r * ratio}
|
||||
clipPath={`url(#${id})`}
|
||||
fill="var(--chart-4)"
|
||||
fillOpacity={complete ? 1 : 0.35}
|
||||
/>
|
||||
<text
|
||||
x={c.x}
|
||||
y={c.y - ((words.length - 1) * size * 1.15) / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
clipPath={`url(#${id})`}
|
||||
className={cn("pointer-events-none select-none", complete ? "fill-background" : "fill-foreground")}
|
||||
style={{ fontSize: size }}
|
||||
>
|
||||
{words.map((w, i) => (
|
||||
<tspan key={`${i}-${w}`} x={c.x} dy={i === 0 ? 0 : size * 1.15}>
|
||||
{w}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{hover.node}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Vendored
+14
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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!));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* shadcn/ui tokens on top of Blume's theme. Blume (Tailwind v4) already maps
|
||||
* background/foreground/muted/border onto its --blume-* tokens; this adds the
|
||||
* names shadcn components expect, pointed at the same tokens so the progress
|
||||
* cards follow the site in both colour schemes. Blume inlines this file into
|
||||
* its Tailwind entry and switches dark mode with [data-theme="dark"], not
|
||||
* shadcn's .dark class.
|
||||
*
|
||||
* Deliberately NOT remapped: `accent`. In Blume it is the brand colour
|
||||
* (bg-accent), in shadcn a subtle hover surface; the vendored components here
|
||||
* never use it.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Difficulty hues, LeetCode's: easy teal, medium amber, hard red. */
|
||||
--chart-1: oklch(0.72 0.12 195);
|
||||
--chart-2: oklch(0.8 0.16 80);
|
||||
--chart-3: oklch(0.63 0.22 25);
|
||||
/* Solved fill for the topic bubbles. */
|
||||
--chart-4: oklch(0.72 0.17 150);
|
||||
--chart-5: oklch(0.6 0.12 250);
|
||||
/* Heatmap ramp — the GREENS of apps/api/src/charts.ts (README + email), with
|
||||
level 0 on the theme's muted surface so an empty day reads in light mode. */
|
||||
--heat-0: var(--blume-muted);
|
||||
--heat-1: #9be9a8;
|
||||
--heat-2: #40c463;
|
||||
--heat-3: #30a14e;
|
||||
--heat-4: #216e39;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--chart-1: oklch(0.75 0.12 195);
|
||||
--chart-2: oklch(0.83 0.16 80);
|
||||
--chart-3: oklch(0.68 0.21 25);
|
||||
--chart-4: oklch(0.76 0.17 150);
|
||||
--chart-5: oklch(0.7 0.12 250);
|
||||
--heat-0: var(--blume-muted);
|
||||
--heat-1: #0e4429;
|
||||
--heat-2: #006d32;
|
||||
--heat-3: #26a641;
|
||||
--heat-4: #39d353;
|
||||
}
|
||||
|
||||
/* shadcn components draw `border` with no colour and rely on a global
|
||||
`* { border-color: var(--border) }` base rule. Blume has none (its own
|
||||
components always name border-border), so give shadcn's parts — every one
|
||||
carries data-slot — that default, scoped, without touching Blume's. */
|
||||
@layer base {
|
||||
[data-slot],
|
||||
[data-slot] * {
|
||||
border-color: var(--blume-border);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-card: var(--blume-background);
|
||||
--color-card-foreground: var(--blume-foreground);
|
||||
--color-popover: var(--blume-background);
|
||||
--color-popover-foreground: var(--blume-foreground);
|
||||
--color-primary: var(--blume-foreground);
|
||||
--color-primary-foreground: var(--blume-background);
|
||||
--color-secondary: var(--blume-muted);
|
||||
--color-secondary-foreground: var(--blume-foreground);
|
||||
--color-input: var(--blume-border);
|
||||
--color-ring: var(--blume-muted-foreground);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["islands", "components/ui", "lib", "topic-landings.ts", "redirects.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user