Add dashboard and hack insights

This commit is contained in:
Jared Schoeny
2025-11-03 16:25:53 -10:00
parent ee9159a09e
commit 04229b2a00
16 changed files with 1355 additions and 12 deletions

30
package-lock.json generated
View File

@@ -15,10 +15,12 @@
"@headlessui/react": "^2.2.9",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.74.0",
"chart.js": "^4.5.1",
"embla-carousel-react": "8.6.0",
"minio": "^8.0.6",
"next": "15.5.4",
"react": "19.1.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"react-markdown": "9.0.3",
@@ -690,6 +692,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@next/env": {
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz",
@@ -2052,6 +2060,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -4771,6 +4791,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",

View File

@@ -16,10 +16,12 @@
"@headlessui/react": "^2.2.9",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.74.0",
"chart.js": "^4.5.1",
"embla-carousel-react": "8.6.0",
"minio": "^8.0.6",
"next": "15.5.4",
"react": "19.1.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"react-markdown": "9.0.3",

View File

@@ -0,0 +1,233 @@
"use server";
import { unstable_cache as cache } from "next/cache";
import { createClient, createServiceClient } from "@/utils/supabase/server";
interface SeriesDataset {
slug: string;
counts: number[]; // aligned with labels
}
export interface DownloadsSeriesAll {
labels: string[]; // YYYY-MM-DD (UTC), length = days, ending yesterday
datasets: SeriesDataset[];
lastComputedUtc: string; // ISO of generation time
}
export interface HackInsights {
versionCounts: { version: string; downloads: number }[];
totalUniqueDevices: number;
latestUniqueDevices: number;
adoptionRate: number; // 0..1
isNewToday: boolean;
}
function getUtcBounds(days: number) {
const now = new Date();
const startOfTodayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const endISO = startOfTodayUtc.toISOString(); // exclude today
const startUtc = new Date(startOfTodayUtc);
startUtc.setUTCDate(startUtc.getUTCDate() - days);
const startISO = startUtc.toISOString();
const nextMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const ttl = Math.max(1, Math.ceil((+nextMidnight - +now) / 1000));
const dayStamp = startOfTodayUtc.toISOString().slice(0, 10); // YYYY-MM-DD
return { startISO, endISO, ttl, dayStamp, startOfTodayUtc };
}
function buildUtcDateLabels(days: number, endExclusiveUtc: Date): string[] {
const labels: string[] = [];
for (let i = days; i >= 1; i--) {
const d = new Date(endExclusiveUtc);
d.setUTCDate(d.getUTCDate() - i);
labels.push(d.toISOString().slice(0, 10));
}
return labels;
}
export const getDownloadsSeriesAll = async ({ days = 30 }: { days?: number }): Promise<DownloadsSeriesAll> => {
const { startISO, endISO, ttl, dayStamp, startOfTodayUtc } = getUtcBounds(days);
// Resolve user and owned slugs OUTSIDE cache (cookies not allowed in cache)
const supa = await createClient();
const { data: userResp } = await supa.auth.getUser();
const user = userResp.user;
if (!user) throw new Error("Unauthorized");
const { data: hacks } = await supa
.from("hacks")
.select("slug")
.eq("created_by", user.id);
const slugs = (hacks ?? []).map((h) => h.slug);
const runner = cache(
async () => {
if (slugs.length === 0) {
return {
labels: buildUtcDateLabels(days, startOfTodayUtc),
datasets: [],
lastComputedUtc: new Date().toISOString(),
} satisfies DownloadsSeriesAll;
}
// Fetch patches for all owned hacks (service client only)
const svc = await createServiceClient();
const { error: patchError, data: patchRows } = await svc
.from("patches")
.select("id,parent_hack")
.in("parent_hack", slugs);
if (patchError) throw patchError;
const patchIdToSlug = new Map<number, string>();
const patchIds: number[] = [];
(patchRows ?? []).forEach((p) => {
if (typeof p.id === "number" && p.parent_hack) {
patchIdToSlug.set(p.id, p.parent_hack);
patchIds.push(p.id);
}
});
const labels = buildUtcDateLabels(days, startOfTodayUtc);
const dateIndex = new Map<string, number>();
labels.forEach((d, i) => dateIndex.set(d, i));
const countsBySlug: Record<string, number[]> = {};
slugs.forEach((s) => (countsBySlug[s] = new Array(labels.length).fill(0)));
if (patchIds.length > 0) {
const { error: dlError, data: dlRows } = await svc
.from("patch_downloads")
.select("created_at,patch")
.in("patch", patchIds)
.gte("created_at", startISO)
.lt("created_at", endISO);
if (dlError) throw dlError;
(dlRows ?? []).forEach((row: any) => {
const pid = row.patch as number | null;
if (!pid) return;
const slug = patchIdToSlug.get(pid);
if (!slug) return;
const day = new Date(row.created_at).toISOString().slice(0, 10);
const idx = dateIndex.get(day);
if (idx == null) return;
countsBySlug[slug][idx] += 1;
});
}
const datasets: SeriesDataset[] = slugs.map((slug) => ({ slug, counts: countsBySlug[slug] || new Array(labels.length).fill(0) }));
return {
labels,
datasets,
lastComputedUtc: new Date().toISOString(),
} satisfies DownloadsSeriesAll;
},
[
`downloads-series-all:${user.id}:${dayStamp}`,
],
{ revalidate: ttl }
);
return runner();
};
export const getHackInsights = async ({ slug }: { slug: string }): Promise<HackInsights> => {
const { endISO, ttl, dayStamp, startOfTodayUtc } = getUtcBounds(30);
// Authorization OUTSIDE cache (cookies not allowed in cache)
const supa = await createClient();
const { data: userResp } = await supa.auth.getUser();
const user = userResp.user;
if (!user) throw new Error("Unauthorized");
const { data: hack } = await supa
.from("hacks")
.select("slug,created_by,current_patch,created_at")
.eq("slug", slug)
.maybeSingle();
if (!hack) throw new Error("Not found");
let isOwner = hack.created_by === user.id;
if (!isOwner) {
const { data: admin } = await supa.rpc("is_admin");
if (!admin) throw new Error("Forbidden");
}
const currentPatchId = (hack.current_patch as number | null) ?? null;
const hackCreatedAt = hack.created_at ? new Date(hack.created_at) : null;
const runner = cache(
async () => {
const svc = await createServiceClient();
const { error: patchError, data: patches } = await svc
.from("patches")
.select("id,version,created_at")
.eq("parent_hack", slug);
if (patchError) throw patchError;
const patchIds = (patches ?? []).map((p) => p.id).filter((v): v is number => typeof v === "number");
// Determine latest patch id
let latestPatchId: number | null = currentPatchId;
if (latestPatchId == null && (patches ?? []).length > 0) {
const latest = [...(patches ?? [])].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
latestPatchId = latest?.id ?? null;
}
// New today message if hack or latest patch is created today (UTC)
const latestPatch = (patches ?? []).find((p) => p.id === latestPatchId) || null;
const isNewToday = Boolean(
(hackCreatedAt && hackCreatedAt >= startOfTodayUtc) || (latestPatch && new Date(latestPatch.created_at) >= startOfTodayUtc)
);
if (patchIds.length === 0) {
return {
versionCounts: [],
totalUniqueDevices: 0,
latestUniqueDevices: 0,
adoptionRate: 0,
isNewToday,
} satisfies HackInsights;
}
const { error: dlError, data: dlRows } = await svc
.from("patch_downloads")
.select("patch,device_id")
.in("patch", patchIds)
.lt("created_at", endISO);
if (dlError) throw dlError;
const byPatchCount = new Map<number, number>();
const allDevices = new Set<string>();
const latestDevices = new Set<string>();
(dlRows ?? []).forEach((r: any) => {
const pid = r.patch as number | null;
const dev = r.device_id as string | null;
if (pid == null) return;
byPatchCount.set(pid, (byPatchCount.get(pid) ?? 0) + 1);
if (dev) allDevices.add(dev);
if (latestPatchId != null && pid === latestPatchId && dev) latestDevices.add(dev);
});
const versionCounts = (patches ?? [])
.map((p) => ({ version: p.version, downloads: byPatchCount.get(p.id) ?? 0 }))
.sort((a, b) => b.downloads - a.downloads);
const totalUniqueDevices = allDevices.size;
const latestUniqueDevices = latestDevices.size;
const adoptionRate = totalUniqueDevices > 0 ? latestUniqueDevices / totalUniqueDevices : 0;
return {
versionCounts,
totalUniqueDevices,
latestUniqueDevices,
adoptionRate,
isNewToday,
} satisfies HackInsights;
},
[
`hack-insights:${user.id}:${slug}:${dayStamp}`,
],
{ revalidate: ttl }
);
return runner();
};

View File

@@ -0,0 +1,44 @@
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import DashboardClient from "@/components/Dashboard/DashboardClient";
import { getDownloadsSeriesAll } from "./actions";
export default async function DashboardPage() {
const supa = await createClient();
const { data: userResp } = await supa.auth.getUser();
const user = userResp.user;
if (!user) redirect("/login");
const { data: profile } = await supa
.from("profiles")
.select("username,full_name")
.eq("id", user.id)
.maybeSingle();
if (!profile || profile.username == null) {
redirect("/account");
}
const { username, full_name } = profile;
const { data: hacks } = await supa
.from("hacks")
.select("slug,title,approved,updated_at,downloads,current_patch,version,created_at")
.eq("created_by", user.id)
.order("updated_at", { ascending: false });
const seriesAll = await getDownloadsSeriesAll({ days: 30 });
return (
<div className="mx-auto my-auto max-w-screen-2xl px-6 py-8">
<DashboardClient
hacks={hacks ?? []}
initialSeriesAll={seriesAll}
displayName={full_name || `@${username}`}
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { createClient } from "@/utils/supabase/server";
import { notFound, redirect } from "next/navigation";
import HackStatsClient from "@/components/Hack/Stats/HackStatsClient";
import { getDownloadsSeriesAll, getHackInsights } from "@/app/dashboard/actions";
export default async function HackStatsPage({ params: { slug } }: { params: { slug: string } }) {
const supa = await createClient();
const { data: userResp } = await supa.auth.getUser();
const user = userResp.user;
if (!user) redirect("/login");
const { data: hack } = await supa
.from("hacks")
.select("slug,created_by,title")
.eq("slug", slug)
.maybeSingle();
if (!hack) notFound();
let isOwner = hack.created_by === user.id;
if (!isOwner) {
const { data: admin } = await supa.rpc("is_admin");
if (!admin) notFound();
}
const allSeries = await getDownloadsSeriesAll({ days: 30 });
const series = {
labels: allSeries.labels,
datasets: allSeries.datasets.filter((d) => d.slug === slug),
lastComputedUtc: allSeries.lastComputedUtc,
};
const insights = await getHackInsights({ slug });
return (
<div className="mx-auto my-auto max-w-screen-2xl px-6 py-8">
<HackStatsClient slug={slug} title={hack.title} initialSeries={series} initialInsights={insights} />
</div>
);
}

View File

@@ -47,7 +47,7 @@ export async function login(state: AuthActionState, payload: FormData) {
return {
error: null,
user: authData.user,
redirectTo: isValidInternalPath ? redirectTo : '/account'
redirectTo: isValidInternalPath ? redirectTo : '/dashboard'
}
}

View File

@@ -30,7 +30,7 @@ export default function LoginForm() {
useEffect(() => {
if (state && state.error === null && !navigatedRef.current) {
setUser(state.user);
const to = state.redirectTo || (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//') ? redirectTo : '/account');
const to = state.redirectTo || (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//') ? redirectTo : '/dashboard');
navigatedRef.current = true;
router.replace(to);
}
@@ -40,7 +40,7 @@ export default function LoginForm() {
useEffect(() => {
if (!user || navigatedRef.current) return;
const isValidInternalPath = !!redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//');
const to = isValidInternalPath ? (redirectTo as string) : '/account';
const to = isValidInternalPath ? (redirectTo as string) : '/dashboard';
navigatedRef.current = true;
router.replace(to);
}, [user, redirectTo, router]);

View File

@@ -0,0 +1,167 @@
"use client";
import React from "react";
import Link from "next/link";
import type { DownloadsSeriesAll } from "@/app/dashboard/actions";
import { DashboardProvider } from "@/contexts/DashboardContext";
import DownloadsChart from "@/components/Dashboard/DownloadsChart";
import HackList from "@/components/Dashboard/HackList";
type HackRow = {
slug: string;
title: string;
approved: boolean;
updated_at: string | null;
downloads: number;
current_patch: number | null;
version: string;
created_at: string;
};
export default function DashboardClient({
hacks,
initialSeriesAll,
displayName,
}: {
hacks: HackRow[];
initialSeriesAll: DownloadsSeriesAll;
displayName: string;
}) {
const [selectedSlugs, setSelectedSlugs] = React.useState<string[]>(() => hacks.map((h) => h.slug));
const totalDownloads = React.useMemo(() => hacks.reduce((acc, h) => acc + (h.downloads || 0), 0), [hacks]);
const pendingCount = hacks.filter((h) => !h.approved).length;
const localCutover = React.useMemo(() => {
const now = new Date();
const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
return new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).format(utcMidnight);
}, []);
return (
<DashboardProvider initialSeriesAll={initialSeriesAll}>
<div className="mx-auto max-w-screen-2xl">
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-start gap-3 lg:gap-4">
<div className="flex flex-col grow-1">
<h1 className="text-3xl font-bold tracking-tight">Creator Dashboard</h1>
<p className="mt-1 text-[18px] text-foreground/90">Welcome back, {displayName}!</p>
<p className="mt-4 text-[15px] text-foreground/60">
Analytics update daily at 00:00 UTC. Today&apos;s data will be available after {localCutover}.
</p>
</div>
<div className="flex flex-col ml-auto my-4 w-full md:flex-row md:w-auto md:mb-0 lg:my-0 gap-2">
<Link
href="/account"
className="inline-flex h-12 px-4 items-center justify-center w-full md:w-auto md:h-10 rounded-md text-sm font-medium ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)] hover:cursor-pointer"
>
Account Settings
</Link>
<form action="/auth/signout" method="post">
<button
type="submit"
className="inline-flex h-12 px-8 items-center justify-center w-full md:w-auto md:h-10 rounded-md border border-red-600/40 bg-red-600/5 dark:border-red-400/40 dark:bg-red-400/5 text-sm font-medium text-red-600/90 dark:text-red-400/80 transition-colors hover:bg-red-600/5 dark:hover:bg-red-400/10 hover:cursor-pointer"
>
Sign out
</button>
</form>
</div>
</div>
{/* Quick stats */}
<div className="mt-6 grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="Your hacks" value={hacks.length} />
<StatCard label="Pending approval" value={pendingCount} />
<StatCard label="Total downloads" value={totalDownloads} />
<StatCard label="Last 30 days (UTC)" value={initialSeriesAll.datasets.reduce((acc, d) => acc + d.counts.reduce((a, b) => a + b, 0), 0)} />
</div>
{/* Downloads over time */}
<div className="mt-10">
<div className="flex flex-col gap-3">
<h2 className="text-xl font-semibold">Downloads over time (last 30 days, UTC)</h2>
<SlugMultiSelect
hacks={hacks}
values={selectedSlugs}
onChange={setSelectedSlugs}
/>
</div>
<div className="mt-4">
<DownloadsChart selectedSlugs={selectedSlugs} />
</div>
</div>
{/* Hacks list */}
<div className="mt-12">
<h2 className="text-xl font-semibold">Your hacks</h2>
<HackList hacks={hacks} />
</div>
{/* Per-hack insights removed; deeper stats are on each hack's /stats page */}
</div>
</DashboardProvider>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-4">
<div className="text-[13px] text-foreground/70">{label}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
}
function SlugMultiSelect({
hacks,
values,
onChange,
}: {
hacks: HackRow[];
values: string[];
onChange: (v: string[]) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-2 w-full -mx-1 px-1">
{hacks.map((h) => {
const selected = values.includes(h.slug);
return (
<button
key={h.slug}
type="button"
onClick={() => onChange(selected ? values.filter((s) => s !== h.slug) : [...values, h.slug])}
className={`shrink-0 rounded-full px-3 py-2 text-sm ring-1 ring-inset transition-colors hover:cursor-pointer ${
selected
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35"
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
}`}
>
{h.title}
</button>
);
})}
{hacks.length > 1 && (
<button
type="button"
onClick={() => onChange(hacks.map((h) => h.slug))}
className="shrink-0 rounded-full ml-auto px-3 py-2 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 hover:cursor-pointer"
>
Select all
</button>
)}
{values.length > 0 && (
<button
type="button"
onClick={() => onChange([])}
className={`shrink-0 rounded-full px-3 py-2 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 hover:cursor-pointer ${values.length === 0 ? "ml-auto" : ""}`}
>
Clear
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import React from "react";
import { Line } from "react-chartjs-2";
import {
Chart as ChartJS,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
Filler,
ChartOptions,
} from "chart.js";
import { useDashboard } from "@/contexts/DashboardContext";
ChartJS.register(LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend, Filler);
export default function DownloadsChart({ selectedSlugs }: { selectedSlugs: string[] }) {
const { seriesAllHacks } = useDashboard();
const palette = React.useMemo(
() => [
"#22c55e",
"#3b82f6",
"#f59e0b",
"#ef4444",
"#a855f7",
"#06b6d4",
],
[]
);
const datasets = React.useMemo(() => {
const filtered = seriesAllHacks.datasets.filter((d) => selectedSlugs.includes(d.slug));
return filtered.map((d, idx) => {
const color = palette[idx % palette.length];
return {
label: d.slug,
data: d.counts,
borderColor: color,
backgroundColor: color + "33",
fill: true,
tension: 0.25,
pointRadius: 2,
} as const;
});
}, [seriesAllHacks.datasets, selectedSlugs, palette]);
const data = React.useMemo(() => {
const years = new Set(seriesAllHacks.labels.map((ds) => Number(ds.slice(0, 4))));
// Format YYYY-MM-DD to "06 Oct" or "Oct 06" depending on locale (always treat as UTC)
const labels = seriesAllHacks.labels.map((dateStr, i) => {
// Directly parse components from YYYY-MM-DD string to avoid timezone conversion
const [yearStr, monthStr, dayStr] = dateStr.split("-");
const year = Number(yearStr);
const month = Number(monthStr); // 1-based
const day = Number(dayStr);
// Show the year if it's the first/second of January and years are not all the same
// Two days because maxTicksLimit is half of the label count.
let shouldShowYear = false;
if (i === 0 || (day === 1 && month === 1) || (day === 2 && month === 1)) {
if (years.size > 1 && month === 1 && day === 1) {
shouldShowYear = true;
}
}
// Build a GMT Date so locale options format, but still UTC.
// month-1 because Date.UTC months are zero-based.
const d = new Date(Date.UTC(year, month - 1, day));
let baseFormat = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
timeZone: "UTC",
}).format(d);
if (shouldShowYear) {
baseFormat += ` ${year}`;
}
return baseFormat;
});
return {
labels,
datasets,
};
}, [seriesAllHacks.labels, datasets]);
const options: ChartOptions<"line"> = React.useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index" as const, intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
footer: (items: any[]) => {
try {
const sum = items.reduce((acc, it) => acc + (Number(it.parsed.y) || 0), 0);
return `Total: ${sum}`;
} catch {
return "";
}
},
},
},
},
scales: {
x: {
ticks: { autoSkip: true, maxTicksLimit: 15 },
grid: { display: false },
},
y: {
beginAtZero: true,
ticks: { stepSize: 1 },
grid: { color: "rgba(0,0,0,0.1)" },
},
},
}),
[]
);
return (
<div className="h-[40vh] sm:h-72 w-full max-w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
{datasets.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">Select at least one hack to display.</div>
) : (
<Line data={data} options={options} className="!w-full !h-full block" style={{ width: "100%", height: "100%" }} />
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import React from "react";
import { useDashboard } from "@/contexts/DashboardContext";
import { Bar } from "react-chartjs-2";
import { Chart as ChartJS, BarElement, CategoryScale, LinearScale, Tooltip, Legend } from "chart.js";
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend);
interface HackRow {
slug: string;
title: string;
approved: boolean;
updated_at: string | null;
downloads: number;
current_patch: number | null;
version: string;
created_at: string;
}
export default function HackInsights({ hack }: { hack: HackRow }) {
const { getInsights } = useDashboard();
const [open, setOpen] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [data, setData] = React.useState<null | Awaited<ReturnType<typeof getInsights>>>(null);
React.useEffect(() => {
if (!open || data) return;
let mounted = true;
setLoading(true);
getInsights(hack.slug)
.then((res) => {
if (mounted) setData(res);
})
.finally(() => mounted && setLoading(false));
return () => {
mounted = false;
};
}, [open, data, getInsights, hack.slug]);
return (
<div className="rounded-lg border border-[var(--border)]">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-[var(--surface-2)]"
>
<div className="min-w-0">
<div className="truncate font-medium">{hack.title}</div>
<div className="mt-0.5 text-xs text-foreground/60">/{hack.slug}</div>
</div>
<div className="text-sm text-foreground/70">{open ? "Hide" : "Show"} insights</div>
</button>
{open && (
<div className="px-4 pb-4">
{loading || !data ? (
<div className="py-6 text-sm text-foreground/70">Loading insights</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
<div className="md:col-span-2 rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="text-sm font-medium mb-2">Downloads per version</div>
{data.versionCounts.length === 0 ? (
<div className="text-sm text-foreground/70">No downloads yet. Upload a patch to see data.</div>
) : (
<Bar
data={{
labels: data.versionCounts.map((v) => v.version),
datasets: [
{
label: "Downloads",
data: data.versionCounts.map((v) => v.downloads),
backgroundColor: "#3b82f6",
},
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true } },
}}
height={220}
/>
)}
</div>
<div className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="text-sm font-medium mb-2">Latest-version adoption</div>
{data.isNewToday ? (
<div className="text-sm text-foreground/70">New upload todayplease check back tomorrow (UTC) for analytics.</div>
) : (
<div>
<div className="text-3xl font-semibold">{Math.round(data.adoptionRate * 100)}%</div>
<div className="mt-1 text-xs text-foreground/70">{data.latestUniqueDevices} of {data.totalUniqueDevices} unique devices on latest</div>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import React from "react";
import Link from "next/link";
import { FiExternalLink, FiEdit2, FiUpload, FiShare2, FiBarChart2, FiMoreVertical, FiCheck } from "react-icons/fi";
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react";
import ActionSheet from "@/components/Primitives/ActionSheet";
type HackRow = {
slug: string;
title: string;
approved: boolean;
updated_at: string | null;
downloads: number;
version: string;
};
export default function HackList({ hacks }: { hacks: HackRow[] }) {
const [activeSlug, setActiveSlug] = React.useState<string | null>(null);
const [sheetOpen, setSheetOpen] = React.useState(false);
if (hacks.length === 0) {
return (
<div className="mt-4 rounded-md border border-[var(--border)] bg-[var(--surface-2)] p-6 text-sm text-foreground/80">
You haven&apos;t uploaded any hacks yet. <Link className="underline" href="/submit">Submit a hack</Link> to get started.
</div>
);
}
return (
<div className="mt-4 overflow-hidden rounded-lg border border-[var(--border)]">
{/* Header row (desktop only) */}
<div className="hidden lg:grid grid-cols-12 bg-[var(--surface-2)] px-4 py-2 text-xs text-foreground/60">
<div className="col-span-4">Title</div>
<div className="col-span-2">Status</div>
<div className="col-span-2">Version</div>
<div className="col-span-2">Downloads</div>
<div className="col-span-2 text-right">Actions</div>
</div>
<div className="divide-y divide-[var(--border)]">
{hacks.map((h) => (
<div key={h.slug} className="px-4 py-3 text-sm">
{/* Desktop row */}
<div className="hidden lg:grid grid-cols-12 items-center">
<Link href={`/hack/${h.slug}`} target="_blank" className="group flex items-center gap-4 col-span-4 min-w-0 hover:text-foreground">
<div className="flex flex-col items-start">
<div className="truncate font-medium group-hover:underline">{h.title}</div>
<div className="mt-0.5 text-xs text-foreground/60 group-hover:text-foreground group-hover:underline">/{h.slug}</div>
</div>
<FiExternalLink className="h-4 w-4 text-foreground/80 group-hover:text-foreground" />
</Link>
<div className="col-span-2">
{h.approved ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-400 ring-1 ring-emerald-600/30">Approved</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-400 ring-1 ring-amber-600/30">Pending</span>
)}
</div>
<div className="col-span-2">{h.version}</div>
<div className="col-span-2">{h.downloads}</div>
<div className="col-span-2 hidden md:flex items-center justify-end gap-1.5">
<IconTooltipButton href={`/hack/${h.slug}/stats`} target="_blank" label="Stats">
<FiBarChart2 className="h-4 w-4" />
</IconTooltipButton>
<IconTooltipButton href={`/hack/${h.slug}/edit`} label="Edit">
<FiEdit2 className="h-4 w-4" />
</IconTooltipButton>
<IconTooltipButton href={`/hack/${h.slug}/edit/patch`} label="Upload patch">
<FiUpload className="h-4 w-4" />
</IconTooltipButton>
<ShareIconButton slug={h.slug} />
</div>
</div>
{/* Mobile card */}
<div className="lg:hidden flex justify-between items-center">
<div className="flex flex-col items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium break-words">{h.title}</div>
<div className="mt-0.5 text-xs text-foreground/60 break-all">/{h.slug}</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
{h.approved ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-400 ring-1 ring-emerald-600/30">Approved</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-amber-400 ring-1 ring-amber-600/30">Pending</span>
)}
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{h.version}</span>
<span className="rounded-full bg-[var(--surface-2)] px-2 py-0.5 ring-1 ring-[var(--border)]">{h.downloads} downloads</span>
</div>
</div>
<IconTooltipButton onClick={() => { setActiveSlug(h.slug); setSheetOpen(true); }} label="More" ariaLabel="More">
<FiMoreVertical className="h-4 w-4" />
</IconTooltipButton>
</div>
</div>
))}
</div>
<ActionSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
title={activeSlug ? `Actions for ${activeSlug}` : undefined}
actions={buildActions(activeSlug)}
/>
</div>
);
}
function ShareIconButton({ slug }: { slug: string }) {
const [copied, setCopied] = React.useState(false);
const handleClick = async () => {
try {
const origin = typeof window !== "undefined" ? window.location.origin : "";
const url = `${origin}/hack/${slug}`;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {}
};
return (
<IconTooltipButton onClick={handleClick} label={copied ? "Copied!" : "Share link"} ariaLabel="Share link">
{copied ? <FiCheck className="h-4 w-4 text-emerald-400/80" /> : <FiShare2 className="h-4 w-4" />}
</IconTooltipButton>
);
}
function buildActions(slug: string | null) {
if (!slug) return [];
return [
{ key: "view", label: "View", href: `/hack/${slug}`, icon: <FiExternalLink className="h-4 w-4" /> },
{ key: "stats", label: "Stats", href: `/hack/${slug}/stats`, icon: <FiBarChart2 className="h-4 w-4" /> },
{ key: "edit", label: "Edit", href: `/hack/${slug}/edit`, icon: <FiEdit2 className="h-4 w-4" /> },
{ key: "upload", label: "Upload patch", href: `/hack/${slug}/edit/patch`, icon: <FiUpload className="h-4 w-4" /> },
{ key: "share", label: "Share link", onClick: () => copyShare(slug), icon: <FiShare2 className="h-4 w-4" /> },
];
}
async function copyShare(slug: string) {
try {
const origin = typeof window !== "undefined" ? window.location.origin : "";
const url = `${origin}/hack/${slug}`;
await navigator.clipboard.writeText(url);
} catch {}
}
type IconTooltipButtonProps = {
href: string;
target?: string;
onClick?: never;
label: string;
ariaLabel?: string;
children: React.ReactNode;
} | {
href?: never;
target?: never;
onClick: () => void;
label: string;
ariaLabel?: string;
children: React.ReactNode;
};
function IconTooltipButton({ href, target, onClick, label, ariaLabel, children }: IconTooltipButtonProps) {
return (
<Tooltip label={label}>
{href ? (
<Link
href={href}
target={target}
aria-label={ariaLabel ?? label}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
>
{children}
</Link>
) : (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel ?? label}
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
>
{children}
</button>
)}
</Tooltip>
);
}
function Tooltip({ label, children }: { label: string; children: React.ReactNode }) {
const [open, setOpen] = React.useState(false);
const { refs, floatingStyles, update } = useFloating({
placement: "top",
middleware: [offset(6), flip(), shift()],
});
React.useEffect(() => {
const ref = refs.reference.current;
const float = refs.floating.current;
if (!ref || !float) return;
return autoUpdate(ref, float, update);
}, [refs.reference, refs.floating, update]);
return (
<span
className="relative inline-flex"
ref={refs.setReference as any}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
{children}
{open && (
<span
ref={refs.setFloating as any}
style={floatingStyles as React.CSSProperties}
className="hidden lg:block z-50 whitespace-nowrap rounded-md bg-black/80 px-2 py-1 text-[11px] text-white shadow-md dark:bg-white/90 dark:text-black"
role="tooltip"
>
{label}
</span>
)}
</span>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import React from "react";
import { Line, Bar } from "react-chartjs-2";
import {
Chart as ChartJS,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
Filler,
BarElement,
ChartData,
ChartDataset,
ChartOptions,
} from "chart.js";
import type { DownloadsSeriesAll, HackInsights } from "@/app/dashboard/actions";
ChartJS.register(LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend, Filler, BarElement);
type LineData = ChartData<"line", number[], string>;
type BarData = ChartData<"bar", (number | [number, number] | null)[], unknown>;
interface HackStatsChartsProps {
series: DownloadsSeriesAll;
insights: HackInsights;
activeTab?: "overview" | "versions";
}
export default function HackStatsCharts({ series, insights, activeTab }: HackStatsChartsProps) {
const lineData: LineData = React.useMemo(() => ({
labels: series.labels,
datasets: series.datasets.map((d, i) => ({
label: d.slug,
data: d.counts,
borderColor: "#22c55e",
backgroundColor: "#22c55e33",
fill: true,
tension: 0.25,
pointRadius: 2,
}) satisfies ChartDataset<"line">),
}) satisfies LineData, [series]);
const lineOptions: ChartOptions<"line"> = React.useMemo(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index" as const, intersect: false },
plugins: { legend: { display: false } },
scales: { x: { grid: { display: false } }, y: { beginAtZero: true } },
}) satisfies ChartOptions<"line">, []);
const barData: BarData = React.useMemo(() => ({
labels: insights.versionCounts.map((v) => v.version),
datasets: [{ label: "Downloads", data: insights.versionCounts.map((v) => v.downloads), backgroundColor: "#3b82f6" }],
}) satisfies BarData, [insights]);
// Mobile: show one chart by tab; Desktop: show both
if (activeTab) {
return (
<div className="space-y-6 md:space-y-0 md:grid md:grid-cols-3 md:gap-6">
{activeTab === "overview" ? (
<div className="md:col-span-3 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="mb-2 text-sm font-medium">Downloads over time (last 30 days, UTC)</div>
<div className="h-[60vh] sm:h-72 max-w-full overflow-hidden">
{series.datasets.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No data yet.</div>
) : (
<Line data={lineData} options={lineOptions} className="!w-full !h-full block" style={{ width: "100%", height: "100%" }} />
)}
</div>
</div>
) : (
<>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="mb-2 text-sm font-medium">Latest-version adoption</div>
{insights.isNewToday ? (
<div className="text-sm text-foreground/70">New upload todayplease check back tomorrow (UTC) for analytics.</div>
) : (
<div>
<div className="text-4xl font-semibold">{Math.round(insights.adoptionRate * 100)}%</div>
<div className="mt-1 text-xs text-foreground/70">{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest</div>
</div>
)}
</div>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="mb-2 text-sm font-medium">Downloads per version (all-time)</div>
<div className="h-[50vh] sm:h-64">
{insights.versionCounts.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No downloads yet.</div>
) : (
<Bar data={barData} options={{ responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }} />
)}
</div>
</div>
</>
)}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="mb-2 text-sm font-medium">Downloads over time (last 30 days, UTC)</div>
<div className="h-72 max-w-full overflow-hidden">
{series.datasets.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No data yet.</div>
) : (
<Line data={lineData} options={lineOptions} className="!w-full !h-full block" style={{ width: "100%", height: "100%" }} />
)}
</div>
</div>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3">
<div className="mb-2 text-sm font-medium">Latest-version adoption</div>
{insights.isNewToday ? (
<div className="text-sm text-foreground/70">New upload todayplease check back tomorrow (UTC) for analytics.</div>
) : (
<div>
<div className="text-4xl font-semibold">{Math.round(insights.adoptionRate * 100)}%</div>
<div className="mt-1 text-xs text-foreground/70">{insights.latestUniqueDevices} of {insights.totalUniqueDevices} unique devices on latest</div>
</div>
)}
</div>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3 lg:col-span-3">
<div className="mb-2 text-sm font-medium">Downloads per version (all-time)</div>
<div className="h-64">
{insights.versionCounts.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/70">No downloads yet.</div>
) : (
<Bar data={barData} options={{ responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }} />
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import React from "react";
import Link from "next/link";
import type { DownloadsSeriesAll, HackInsights } from "@/app/dashboard/actions";
import HackStatsCharts from "@/components/Hack/Stats/HackStatsCharts";
export default function HackStatsClient({
slug,
title,
initialSeries,
initialInsights,
}: {
slug: string;
title: string;
initialSeries: DownloadsSeriesAll;
initialInsights: HackInsights;
}) {
const [activeTab, setActiveTab] = React.useState<"overview" | "versions">("overview");
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
setIsMobile(window.innerWidth < 1024);
}, []);
return (
<div className="mx-auto max-w-screen-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Stats: {title}</h1>
<p className="mt-2 text-[15px] text-foreground/80">Analytics update daily at 00:00 UTC. Today&apos;s data appears tomorrow.</p>
</div>
<Link href={`/hack/${slug}`} className="inline-flex h-10 items-center rounded-md px-4 text-sm ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]">Back to hack</Link>
</div>
{/* Mobile segmented control */}
{isMobile && (
<div className="mt-4" role="tablist" aria-label="Stats tabs">
<div className="inline-flex rounded-md ring-1 ring-[var(--border)] p-0.5">
<button
role="tab"
aria-selected={activeTab === "overview"}
onClick={() => setActiveTab("overview")}
className={`px-3 py-1.5 text-sm rounded ${activeTab === "overview" ? "bg-[var(--surface-2)]" : "text-foreground/70"}`}
>
Overview
</button>
<button
role="tab"
aria-selected={activeTab === "versions"}
onClick={() => setActiveTab("versions")}
className={`px-3 py-1.5 text-sm rounded ${activeTab === "versions" ? "bg-[var(--surface-2)]" : "text-foreground/70"}`}
>
Versions
</button>
</div>
</div>
)}
<div className="mt-8">
<HackStatsCharts series={initialSeries} insights={initialInsights} activeTab={isMobile ? activeTab : undefined} />
</div>
</div>
);
}

View File

@@ -120,11 +120,11 @@ export default function Header() {
</Link>
{isAuthenticated && (
<Link
href="/account"
data-active={pathname === "/account" || undefined}
href="/dashboard"
data-active={pathname === "/dashboard" || undefined}
className="ml-1 relative group inline-flex items-center justify-center rounded-full ring-1 ring-[var(--border)] p-[2px] data-active:ring-2 data-active:ring-[var(--ring)]"
aria-label="Open account"
title="Account"
aria-label="Open dashboard"
title="Dashboard"
>
<Avatar uid={userId} url={avatarUrl} size={36} />
<div className="absolute inset-0 rounded-full bg-transparent group-hover:bg-black/30 transition-colors m-[2px]" />
@@ -183,15 +183,15 @@ export default function Header() {
/>
{isAuthenticated && (
<Link
href="/account"
href="/dashboard"
onClick={() => setIsMobileMenuOpen(false)}
data-active={pathname === "/account" || undefined}
data-active={pathname === "/dashboard" || undefined}
className="mt-1 inline-flex items-center gap-3 rounded-md px-3 py-3 ring-1 ring-[var(--border)]"
aria-label="Open account"
title="Account"
aria-label="Open dashboard"
title="Dashboard"
>
<Avatar uid={userId} url={avatarUrl} size={28} />
<span className="text-[15px]">Account</span>
<span className="text-[15px]">Dashboard</span>
</Link>
)}
</div>

View File

@@ -0,0 +1,103 @@
"use client";
import React, { Fragment } from "react";
import { Dialog, Transition, TransitionChild, DialogPanel } from "@headlessui/react";
export interface ActionItem {
key: string;
label: string;
icon?: React.ReactNode;
onClick?: () => void;
href?: string;
}
interface ActionSheetProps {
open: boolean;
onClose: () => void;
title?: string;
actions: ActionItem[];
}
export default function ActionSheet({ open, onClose, title, actions }: ActionSheetProps) {
return (
<Transition show={open} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/50" />
</TransitionChild>
<div className="fixed inset-0 overflow-hidden">
<div className="absolute inset-0 flex items-end">
<TransitionChild
as={Fragment}
enter="transform transition ease-out duration-200"
enterFrom="translate-y-full"
enterTo="translate-y-0"
leave="transform transition ease-in duration-150"
leaveFrom="translate-y-0"
leaveTo="translate-y-full"
>
<DialogPanel className="w-full">
<div className="mx-auto w-full max-w-screen-sm rounded-t-2xl bg-background ring-1 ring-[var(--border)]">
<div className="mx-auto mt-2 mb-1 h-1 w-10 rounded-full bg-foreground/20" />
{title && (
<div className="px-4 pb-2 pt-1 text-center text-sm font-medium text-foreground/90">{title}</div>
)}
<div className="px-2 pb-2">
<div className="overflow-hidden rounded-xl border border-[var(--border)]">
<ul className="divide-y divide-[var(--border)]">
{actions.map((a) => (
<li key={a.key}>
{a.href ? (
<a
href={a.href}
onClick={onClose}
className="flex items-center gap-3 px-4 py-3 text-[15px] hover:bg-[var(--surface-2)]"
>
<span className="text-foreground/80">{a.icon}</span>
<span className="text-foreground">{a.label}</span>
</a>
) : (
<button
type="button"
onClick={() => {
try { a.onClick?.(); } finally { onClose(); }
}}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-[15px] hover:bg-[var(--surface-2)]"
>
<span className="text-foreground/80">{a.icon}</span>
<span className="text-foreground">{a.label}</span>
</button>
)}
</li>
))}
</ul>
</div>
<button
type="button"
onClick={onClose}
className="mt-2 mb-4 flex w-full items-center justify-center rounded-xl bg-[var(--surface-2)] px-4 py-3 text-[15px] ring-1 ring-[var(--border)] hover:bg-[var(--surface-3)]"
>
Cancel
</button>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import React from "react";
import type { DownloadsSeriesAll, HackInsights } from "@/app/dashboard/actions";
import { getHackInsights } from "@/app/dashboard/actions";
type DashboardContextValue = {
seriesAllHacks: DownloadsSeriesAll;
insightsBySlug: Map<string, HackInsights>;
getInsights: (slug: string) => Promise<HackInsights>;
};
export const DashboardContext = React.createContext<DashboardContextValue | null>(null);
export function DashboardProvider({
children,
initialSeriesAll,
}: {
children: React.ReactNode;
initialSeriesAll: DownloadsSeriesAll;
}) {
const insightsRef = React.useRef<Map<string, HackInsights>>(new Map());
const getInsights = React.useCallback(async (slug: string) => {
const cached = insightsRef.current.get(slug);
if (cached) return cached;
const result = await getHackInsights({ slug });
insightsRef.current.set(slug, result);
return result;
}, []);
const value = React.useMemo<DashboardContextValue>(() => ({
seriesAllHacks: initialSeriesAll,
insightsBySlug: insightsRef.current,
getInsights,
}), [initialSeriesAll, getInsights]);
return <DashboardContext.Provider value={value}>{children}</DashboardContext.Provider>;
}
export function useDashboard() {
const ctx = React.useContext(DashboardContext);
if (!ctx) throw new Error("useDashboard must be used within DashboardProvider");
return ctx;
}