feat(dashboard): project dashboard page

This commit is contained in:
Dave 2026-09-16 13:52:13 +02:00
parent 2792f4ea93
commit 53979f389d
3 changed files with 162 additions and 0 deletions

View file

@ -0,0 +1,113 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { KpiRow } from "@/components/dashboard/kpi-row";
import { ActivityCalendar } from "@/components/dashboard/activity-calendar";
import { BarList } from "@/components/dashboard/bar-list";
import { MonthBars } from "@/components/dashboard/month-bars";
import { Methodology } from "@/components/dashboard/methodology";
import { RecomputeButton } from "@/components/dashboard/recompute-button";
import { getSession } from "@/lib/auth/session";
import { getServerPb } from "@/lib/pb/server";
import { loadBoard } from "@/lib/data/board";
import { relativeTime } from "@/lib/time/relative";
import type { ProjectMetrics } from "@/lib/metrics/compute";
import type { ProjectMetricsRecord } from "@/lib/pb/types";
export default async function DashboardPage({
params,
}: {
params: Promise<{ clientSlug: string; projectSlug: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { clientSlug, projectSlug } = await params;
const board = await loadBoard(clientSlug, projectSlug);
if (!board) notFound();
const pb = await getServerPb();
const cached = await pb
.collection("project_metrics")
.getFirstListItem<ProjectMetricsRecord>(`project = "${board.project.id}"`)
.catch(() => null);
const metrics = cached?.payload as ProjectMetrics | undefined;
const boardHref = `/p/${clientSlug}/${projectSlug}`;
return (
<AppShell role={session.role} currentPath={boardHref}>
<TopBar title={board.project.name} subtitle={board.client.name}>
{session.role === "owner" && (
<RecomputeButton
projectId={board.project.id}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
)}
</TopBar>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="flex items-center gap-3">
<Link
href={boardHref}
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary"
>
<ArrowLeft size={13} strokeWidth={2} />
Back to board
</Link>
{/* Staleness is shown. A dashboard quietly twelve hours behind,
with no indication, is worse than one that says so. */}
{cached && (
<span className="text-xs text-text-muted">
Updated {relativeTime(cached.computed_at)}
</span>
)}
</div>
{!metrics || metrics.totalCommits === 0 ? (
<div className="rounded-[var(--radius-tile)] border border-dashed border-border-subtle p-10 text-center">
<h2 className="text-base font-medium text-text-primary">
No commit history yet
</h2>
<p className="mx-auto mt-1.5 max-w-sm text-sm text-text-muted">
{session.role === "owner"
? "Link a repository on this project's admin page and run a backfill."
: "This project's history has not been connected yet."}
</p>
</div>
) : (
<>
<KpiRow metrics={metrics} />
<ActivityCalendar
activity={metrics.activity}
from={metrics.firstCommit}
to={metrics.lastCommit}
activeDays={metrics.activeDays}
workdays={metrics.workdays}
/>
<MonthBars monthly={metrics.monthly} />
<div className="grid gap-8 sm:grid-cols-2">
<BarList title="Commits by type" items={metrics.byType} />
<BarList
title="Feature areas"
items={metrics.byScope}
emptyLabel="No scoped commits yet"
/>
</div>
<Methodology metrics={metrics} />
</>
)}
</div>
</div>
</AppShell>
);
}

View file

@ -29,6 +29,12 @@ export default async function BoardPage({
currentPath={`/p/${clientSlug}/${projectSlug}`}
>
<TopBar title={board.project.name} subtitle={board.client.name}>
<Link
href={`/p/${clientSlug}/${projectSlug}/dashboard`}
className="text-xs text-text-muted hover:text-text-secondary"
>
Dashboard
</Link>
<Link
href={`/p/${clientSlug}/${projectSlug}/archive`}
className="text-xs text-text-muted hover:text-text-secondary"

View file

@ -0,0 +1,43 @@
"use client";
import { useState, useTransition } from "react";
import { RefreshCw } from "lucide-react";
import { recomputeMetricsAction } from "@/app/actions/metrics";
export function RecomputeButton({
projectId,
clientSlug,
projectSlug,
}: {
projectId: string;
clientSlug: string;
projectSlug: string;
}) {
const [pending, startTransition] = useTransition();
const [done, setDone] = useState<string | null>(null);
return (
<span className="flex items-center gap-2">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await recomputeMetricsAction({
projectId,
clientSlug,
projectSlug,
});
setDone(`${result.commits} commits`);
})
}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-1.5 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
<RefreshCw size={13} strokeWidth={2} />
{pending ? "Recomputing…" : "Recompute"}
</button>
{done && <span className="text-xs text-text-muted">{done}</span>}
</span>
);
}