feat(dashboard): add Refresh commits button, separate from Recompute

The single Recompute button conflated two ideas: re-aggregating the cached
metrics (instant, costs nothing) and pulling new commits from Forgejo
(slow, hits the external API). A user who clicked Recompute expecting
Recent Work to update was waiting on a metric that only depends on what
is already in the database.

Split the two actions:

* Refresh commits — wraps ingestProject(stopAfterKnownPages: 1), the
  same path as the hourly cron, scoped to one project. Snappy because
  it stops once a full page is already known.
* Recompute — unchanged behaviour. Re-aggregates the cached commits
  into the project_metrics row.

Both actions revalidate the dashboard, so Recent Work, head-line KPIs
and the 'Updated just now' indicator all stay consistent within a
single click.
This commit is contained in:
Dave 2026-09-17 14:58:01 +02:00
parent d909a8bb34
commit 4ce9b76874
3 changed files with 124 additions and 5 deletions

View file

@ -77,4 +77,44 @@ export async function backfillProject(input: {
revalidatePath(`/admin/projects/${input.projectId}`);
return { created: result.created, skipped: result.skipped };
}
/**
* Pull whatever is new from Forgejo for one project.
*
* Same code path as the hourly `/api/cron/reconcile`, scoped to a single
* project and gated behind the same `requireOwner` the backfill uses. Stops at
* the first fully-known page — the dashboard button is for "did anything push
* since the last reconcile?" rather than for restoring a lost history, and
* reading only what has changed keeps it interactive.
*
* The cached metrics are refreshed inside `ingestProject`, so a successful
* call here both populates Recent Work and updates the headline numbers.
*/
export async function refreshCommitsAction(input: {
projectId: string;
clientSlug: string;
projectSlug: string;
}): Promise<{ created: number; skipped: number; pagesRead: number }> {
await requireOwner();
const pb = await getAdminPb();
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(input.projectId);
if (!project.repo_owner || !project.repo_name) {
throw new Error("Link a repository to this project first");
}
if (!(await repoExists(project.repo_owner, project.repo_name))) {
throw new Error(
`Forgejo has no repository ${project.repo_owner}/${project.repo_name}, or it is not readable`
);
}
const result = await ingestProject(project, { stopAfterKnownPages: 1 });
revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/dashboard`);
return result;
}

View file

@ -11,6 +11,7 @@ import { MonthBars } from "@/components/dashboard/month-bars";
import { Methodology } from "@/components/dashboard/methodology";
import { RecentActivity } from "@/components/dashboard/recent-activity";
import { RecomputeButton } from "@/components/dashboard/recompute-button";
import { RefreshCommitsButton } from "@/components/dashboard/refresh-commits-button";
import { getSession } from "@/lib/auth/session";
import { getServerPb } from "@/lib/pb/server";
import { loadBoard } from "@/lib/data/board";
@ -53,11 +54,18 @@ export default async function DashboardPage({
<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}
/>
<span className="flex items-center gap-2">
<RefreshCommitsButton
projectId={board.project.id}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
<RecomputeButton
projectId={board.project.id}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
</span>
)}
</TopBar>

View file

@ -0,0 +1,71 @@
"use client";
import { useState, useTransition } from "react";
import { CloudDownload } from "lucide-react";
import { refreshCommitsAction } from "@/app/actions/ingest";
/**
* "Fetch whatever is new from Forgejo" — sibling to {@link RecomputeButton}.
*
* Recompute reads cached commits and re-aggregates them into the metrics
* table; this one actually talks to Forgejo. It is the same code path as the
* hourly cron, scoped to one project and stopped at the first known page so it
* stays interactive on the dashboard.
*/
export function RefreshCommitsButton({
projectId,
clientSlug,
projectSlug,
}: {
projectId: string;
clientSlug: string;
projectSlug: string;
}) {
const [pending, startTransition] = useTransition();
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
return (
<span className="flex items-center gap-2">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
setStatus(null);
setError(null);
try {
const result = await refreshCommitsAction({
projectId,
clientSlug,
projectSlug,
});
if (result.created === 0) {
setStatus("Already up to date");
} else {
setStatus(
`${result.created} new commit${result.created === 1 ? "" : "s"}`
);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Refresh failed");
}
})
}
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"
>
<CloudDownload size={13} strokeWidth={2} />
{pending ? "Refreshing…" : "Refresh commits"}
</button>
{status && !error && (
<span className="text-xs text-text-muted">{status}</span>
)}
{error && (
<span role="alert" className="text-xs text-accent">
{error}
</span>
)}
</span>
);
}