From cc20cf4bfd5c9b9a34348d323839c9f52eec3f22 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 13:51:03 +0200 Subject: [PATCH] feat(metrics): cached project metrics recomputed on ingest --- scripts/setup-remote.ts | 56 ++++++++++++++++++++++++++++ src/app/actions/metrics.ts | 27 ++++++++++++++ src/lib/git/ingest.ts | 4 ++ src/lib/metrics/store.ts | 76 ++++++++++++++++++++++++++++++++++++++ src/lib/pb/types.ts | 10 +++++ 5 files changed, 173 insertions(+) create mode 100644 src/app/actions/metrics.ts create mode 100644 src/lib/metrics/store.ts diff --git a/scripts/setup-remote.ts b/scripts/setup-remote.ts index 7f564f8..f7921f8 100644 --- a/scripts/setup-remote.ts +++ b/scripts/setup-remote.ts @@ -140,6 +140,24 @@ async function ensureProjectIngestFields(pb: PocketBase): Promise { const additions = [ { name: "webhook_secret", type: "text", required: false, max: 80 }, { name: "last_ingest_at", type: "date", required: false }, + { + name: "session_gap_minutes", + type: "number", + required: false, + onlyInt: true, + }, + { + name: "pre_session_minutes", + type: "number", + required: false, + onlyInt: true, + }, + { + name: "post_session_minutes", + type: "number", + required: false, + onlyInt: true, + }, ]; const fields = [...(projects.fields as unknown[])]; @@ -712,6 +730,44 @@ async function main(): Promise { deleteRule: null, }); + // 10. project_metrics — a computed cache, rebuilt on ingest. The dashboard + // reads this rather than aggregating hundreds of commit rows per page view. + await getOrCreateCollection(pb, { + name: "project_metrics", + type: "base", + listRule: AUTHED, + viewRule: AUTHED, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { + name: "project", + type: "relation", + required: true, + collectionId: (await pb.collections.getOne("projects")).id, + cascadeDelete: true, + minSelect: 0, + maxSelect: 1, + }, + { name: "computed_at", type: "date", required: true }, + { name: "payload", type: "json", required: true, maxSize: 4_000_000 }, + { name: "created", type: "autodate", onCreate: true, onUpdate: false }, + { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_metrics_project ON project_metrics (project)", + ], + }); + + await applyRules(pb, "project_metrics", { + listRule: scopedRead("project.client"), + viewRule: scopedRead("project.client"), + createRule: null, + updateRule: null, + deleteRule: null, + }); + // Add ingest-related fields to projects (only if missing — the projects // collection already exists from earlier migrations). await ensureProjectIngestFields(pb); diff --git a/src/app/actions/metrics.ts b/src/app/actions/metrics.ts new file mode 100644 index 0000000..d133f8b --- /dev/null +++ b/src/app/actions/metrics.ts @@ -0,0 +1,27 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { requireSession } from "@/lib/auth/session"; +import { recomputeMetrics } from "@/lib/metrics/store"; + +/** + * Recompute on demand. + * + * Needed because the constants can change without any commit arriving, and + * because a client asking about a stale figure should be answerable in one + * click rather than by waiting for the next push. + */ +export async function recomputeMetricsAction(input: { + projectId: string; + clientSlug: string; + projectSlug: string; +}): Promise<{ commits: number }> { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + + const metrics = await recomputeMetrics(input.projectId); + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/dashboard`); + return { commits: metrics.totalCommits }; +} \ No newline at end of file diff --git a/src/lib/git/ingest.ts b/src/lib/git/ingest.ts index 17f4bf8..02b6bcf 100644 --- a/src/lib/git/ingest.ts +++ b/src/lib/git/ingest.ts @@ -174,6 +174,10 @@ export async function ingestProject( const { relinkProject } = await import("@/lib/linking/engine"); await relinkProject(project.id); + // And the dashboard figures must follow. + const { recomputeMetrics } = await import("@/lib/metrics/store"); + await recomputeMetrics(project.id); + return { created, skipped, pagesRead: page }; } diff --git a/src/lib/metrics/store.ts b/src/lib/metrics/store.ts new file mode 100644 index 0000000..abb5227 --- /dev/null +++ b/src/lib/metrics/store.ts @@ -0,0 +1,76 @@ +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import { computeProjectMetrics, type ProjectMetrics } from "@/lib/metrics/compute"; +import { + DEFAULT_SESSION_CONSTANTS, + type SessionConstants, +} from "@/lib/metrics/sessions"; +import type { CommitRecord, ProjectMetricsRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * A project's tuning constants, falling back to the defaults. + * + * Zero and undefined both mean "not set". A project must never compute with a + * gap of zero minutes — every commit would become its own session and the + * estimate would be padding multiplied by commit count. + */ +export function constantsFor(project: Partial): SessionConstants { + return { + sessionGapMinutes: + project.session_gap_minutes || DEFAULT_SESSION_CONSTANTS.sessionGapMinutes, + preSessionMinutes: + project.pre_session_minutes || DEFAULT_SESSION_CONSTANTS.preSessionMinutes, + postSessionMinutes: + project.post_session_minutes || DEFAULT_SESSION_CONSTANTS.postSessionMinutes, + }; +} + +/** + * Recompute and cache a project's metrics. + * + * The constants used are stored inside the payload by computeProjectMetrics, so + * a figure the client is looking at can always be explained by the settings + * that produced it rather than by whatever they are when someone later asks. + */ +export async function recomputeMetrics(projectId: string): Promise { + const pb = await getAdminPb(); + + const project = await pb + .collection("projects") + .getOne(projectId); + + const commits = await pb.collection("commits").getFullList({ + filter: `project = "${projectId}"`, + fields: "type,scope,authored_at,is_merge", + }); + + const metrics = computeProjectMetrics( + commits.map((c) => ({ + type: c.type ?? "other", + scope: c.scope ?? "", + authoredAt: c.authored_at, + isMerge: Boolean(c.is_merge), + })), + constantsFor(project) + ); + + const payload = { + project: projectId, + computed_at: new Date().toISOString(), + payload: metrics, + }; + + const existing = await pb + .collection("project_metrics") + .getFirstListItem(`project = "${projectId}"`) + .catch(() => null); + + if (existing) { + await pb.collection("project_metrics").update(existing.id, payload); + } else { + await pb.collection("project_metrics").create(payload); + } + + return metrics; +} \ No newline at end of file diff --git a/src/lib/pb/types.ts b/src/lib/pb/types.ts index 82edf0e..2d16d0d 100644 --- a/src/lib/pb/types.ts +++ b/src/lib/pb/types.ts @@ -52,6 +52,9 @@ export interface ProjectRecord extends BaseRecord { /** Server-only. Must never appear in a browser payload. */ webhook_secret: string; last_ingest_at: string; + session_gap_minutes: number; + pre_session_minutes: number; + post_session_minutes: number; } export interface ColumnRecord extends BaseRecord { @@ -96,6 +99,13 @@ export interface CommentRecord extends BaseRecord { export type CommitLinkSource = "explicit" | "scope" | "manual" | "none"; +export interface ProjectMetricsRecord extends BaseRecord { + project: string; + computed_at: string; + /** A serialised ProjectMetrics from @/lib/metrics/compute. */ + payload: unknown; +} + export interface CommitRecord extends BaseRecord { project: string; sha: string;