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
|
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
|
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