feat(dashboard): project dashboard page
This commit is contained in:
parent
2792f4ea93
commit
53979f389d
3 changed files with 162 additions and 0 deletions
113
src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx
Normal file
113
src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
43
src/components/dashboard/recompute-button.tsx
Normal file
43
src/components/dashboard/recompute-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue