From aea03e4d8c4b29a8dc4934a8b51ed97ef4fe24a9 Mon Sep 17 00:00:00 2001 From: Rodrigo Alves da Silva Matos Date: Thu, 13 Aug 2026 18:03:54 -0300 Subject: [PATCH] feat(platform): reconstruct real history for /me/ai-usage trend chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPersonalAIUsage kept only the newest metrics row per repo, so the trend chart was bounded by that single push's own analysis window (commonly ~90d via DEFAULT_WINDOW_DAYS) with no accumulation across pushes over time — the exact "doesn't keep history" gap flagged for this page. Fetch up to HISTORY_DEPTH (12) rows per repo instead of one, and rebuild the weekly trend from each repo's full fetched history, deduping per-repo-per-week with the newest push winning when pushes' analysis windows overlap. The per-week aggregation is extracted into a pure buildUsageTrend() so it's unit-testable without a DB, consistent with how isHyperEngineer was pulled out of org-summary.ts. Co-Authored-By: Claude Sonnet 5 --- platform/lib/queries/personal-ai-usage.ts | 144 ++++++++++++++-------- platform/tests/personal-ai-usage.test.ts | 142 +++++++++++++++++++++ 2 files changed, 232 insertions(+), 54 deletions(-) create mode 100644 platform/tests/personal-ai-usage.test.ts diff --git a/platform/lib/queries/personal-ai-usage.ts b/platform/lib/queries/personal-ai-usage.ts index 14f1856..5d656d6 100644 --- a/platform/lib/queries/personal-ai-usage.ts +++ b/platform/lib/queries/personal-ai-usage.ts @@ -9,6 +9,12 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { DEFAULT_WINDOW_DAYS } from "@/lib/queries/temporal"; import type { ReportMetrics } from "@/types/metrics"; +// How many historical pushes per repo to pull when reconstructing the trend +// chart. Each push's weekly array already covers DEFAULT_WINDOW_DAYS, so a +// handful of pushes goes a long way — this is a cap on ingestion cadence +// (typically one push per CI run), not on calendar days of history. +const HISTORY_DEPTH = 12; + export interface PerRepoUsage { organizationSlug: string; organizationName: string; @@ -45,7 +51,7 @@ interface OrgInput { name: string; } -interface MetricRow { +export interface MetricRow { repository_id: string; payload: ReportMetrics | null; created_at: string; @@ -92,6 +98,74 @@ function pickUserAuthor( return null; } +// Weekly AI commit share aggregated across each repo's full fetched history, +// not just its latest payload — a single push's weekly array only covers +// that push's own analysis window, so relying on it alone caps the chart at +// ~DEFAULT_WINDOW_DAYS of visible history no matter how long the user has +// been active. Bucket by ACTUAL commit week +// (author_velocity.authors[].weekly.week_start), not by metrics ingestion +// timestamp — otherwise a first-time push of N repos all on the same day +// collapses into one bucket and the chart shows "insufficient data" even +// though months of history are sitting in the payload. ai_commits per week +// is emitted by iris >= 1.0.2; older payloads contribute commit counts but +// no AI share for those weeks. +export function buildUsageTrend( + rowsPerRepo: Map, + emailCandidates: Set, + nameCandidates: Set, +): UsageTrendPoint[] { + type WeekBucket = { + commits: number; + aiCommits: number; + repoIds: Set; + hasAiData: boolean; + }; + const weekly = new Map(); + + for (const [repoId, rows] of rowsPerRepo) { + // Overlapping pushes can report the same week differently as commit + // history is amended/rebased; rows are newest-first, so the first value + // seen per week wins and older pushes' values for that same week are + // skipped. + const seenWeeks = new Set(); + for (const row of rows) { + const match = pickUserAuthor( + row.payload, + emailCandidates, + nameCandidates, + ); + if (!match?.author.weekly) continue; + for (const w of match.author.weekly) { + if (seenWeeks.has(w.week_start)) continue; + seenWeeks.add(w.week_start); + + const bucket: WeekBucket = weekly.get(w.week_start) ?? { + commits: 0, + aiCommits: 0, + repoIds: new Set(), + hasAiData: false, + }; + bucket.commits += w.commits; + if (typeof w.ai_commits === "number") { + bucket.aiCommits += w.ai_commits; + bucket.hasAiData = true; + } + bucket.repoIds.add(repoId); + weekly.set(w.week_start, bucket); + } + } + } + + return [...weekly.entries()] + .map(([date, b]) => ({ + date, + aiCommitPct: + b.hasAiData && b.commits > 0 ? (b.aiCommits / b.commits) * 100 : null, + repos: b.repoIds.size, + })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + export async function getPersonalAIUsage( supabase: SupabaseClient, user: { name: string | null; email: string | null }, @@ -133,24 +207,27 @@ export async function getPersonalAIUsage( const repos = (repoRows ?? []) as RepoRow[]; const repoIndex = new Map(repos.map((r) => [r.id, r])); - // Fetch metrics across all of the user's orgs. Cap by a reasonable history. - // Filter by window_days so multi-window ingestion (issue #80) doesn't pull - // older AI footprints from a different analysis window into the same view. + // Fetch metrics across all of the user's orgs. Multiple rows per repo are + // kept (not just the latest) so the trend below can reconstruct real + // history across pushes instead of being limited to one payload's own + // analysis window. Filter by window_days so multi-window ingestion (issue + // #80) doesn't pull older AI footprints from a different analysis window + // into the same view. const { data: metricRows } = await supabase .from("metrics") .select("repository_id, payload, created_at, organization_id") .in("organization_id", orgIds) .eq("window_days", DEFAULT_WINDOW_DAYS) .order("created_at", { ascending: false }) - .limit(orgs.length * 50); + .limit(repos.length * HISTORY_DEPTH); const metrics = (metricRows ?? []) as MetricRow[]; - // Latest payload per repo - const latestPerRepo = new Map(); + // All rows per repo, newest first (source query is already DESC-ordered). + const rowsPerRepo = new Map(); for (const m of metrics) { - if (!latestPerRepo.has(m.repository_id)) { - latestPerRepo.set(m.repository_id, m); - } + const rows = rowsPerRepo.get(m.repository_id); + if (rows) rows.push(m); + else rowsPerRepo.set(m.repository_id, [m]); } const perRepo: PerRepoUsage[] = []; @@ -158,7 +235,8 @@ export async function getPersonalAIUsage( let aiCount = 0; let maxHv = 0; - for (const [repoId, row] of latestPerRepo) { + for (const [repoId, rows] of rowsPerRepo) { + const row = rows[0]; // newest row — summary table shows current snapshot only. const match = pickUserAuthor(row.payload, emailCandidates, nameCandidates); if (!match) continue; const repo = repoIndex.get(repoId); @@ -184,49 +262,7 @@ export async function getPersonalAIUsage( maxHv = match.author.high_velocity_weeks; } - // Trend: weekly AI commit share aggregated from each repo's latest payload. - // Bucket by ACTUAL commit week (author_velocity.authors[].weekly.week_start), - // not by metrics ingestion timestamp — otherwise a first-time push of N repos - // all on the same day collapses into one bucket and the chart shows - // "insufficient data" even though months of history are sitting in the - // payload. ai_commits per week is emitted by iris >= 1.0.2; older payloads - // contribute commit counts but no AI share for those weeks. - type WeekBucket = { - commits: number; - aiCommits: number; - repoIds: Set; - hasAiData: boolean; - }; - const weekly = new Map(); - - for (const [repoId, row] of latestPerRepo) { - const match = pickUserAuthor(row.payload, emailCandidates, nameCandidates); - if (!match?.author.weekly) continue; - for (const w of match.author.weekly) { - const bucket: WeekBucket = weekly.get(w.week_start) ?? { - commits: 0, - aiCommits: 0, - repoIds: new Set(), - hasAiData: false, - }; - bucket.commits += w.commits; - if (typeof w.ai_commits === "number") { - bucket.aiCommits += w.ai_commits; - bucket.hasAiData = true; - } - bucket.repoIds.add(repoId); - weekly.set(w.week_start, bucket); - } - } - - const trend: UsageTrendPoint[] = [...weekly.entries()] - .map(([date, b]) => ({ - date, - aiCommitPct: - b.hasAiData && b.commits > 0 ? (b.aiCommits / b.commits) * 100 : null, - repos: b.repoIds.size, - })) - .sort((a, b) => a.date.localeCompare(b.date)); + const trend = buildUsageTrend(rowsPerRepo, emailCandidates, nameCandidates); perRepo.sort((a, b) => b.aiCommitPct - a.aiCommitPct); diff --git a/platform/tests/personal-ai-usage.test.ts b/platform/tests/personal-ai-usage.test.ts new file mode 100644 index 0000000..6ddd221 --- /dev/null +++ b/platform/tests/personal-ai-usage.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { + buildUsageTrend, + type MetricRow, +} from "@/lib/queries/personal-ai-usage"; +import type { ReportMetrics } from "@/types/metrics"; + +const EMAIL = new Set(["dev@example.com"]); +const NAME = new Set(); + +function row( + createdAt: string, + weekly: Array<{ week_start: string; commits: number; ai_commits?: number }>, +): MetricRow { + const payload: ReportMetrics = { + commits_total: 0, + commits_revert: 0, + revert_rate: 0, + churn_events: 0, + churn_lines_affected: 0, + files_touched: 0, + files_stabilized: 0, + stabilization_ratio: 0, + author_velocity: { + authors: [ + { + name: "Dev", + email: "dev@example.com", + high_velocity_weeks: 0, + ai_commit_pct: 0, + weekly: weekly.map((w) => ({ + week_start: w.week_start, + commits: w.commits, + lines_added: 0, + lines_removed: 0, + ai_commits: w.ai_commits, + })), + }, + ], + }, + } as ReportMetrics; + + return { + repository_id: "repo-1", + payload, + created_at: createdAt, + organization_id: "org-1", + }; +} + +describe("buildUsageTrend", () => { + it("merges weeks across multiple historical rows for the same repo", () => { + // Two non-overlapping pushes, each covering its own analysis window — + // this is exactly what a single-latest-row trend would miss. + const rowsPerRepo = new Map([ + [ + "repo-1", + [ + row("2026-08-01T00:00:00Z", [ + { week_start: "2026-07-27", commits: 10, ai_commits: 4 }, + ]), + row("2026-06-01T00:00:00Z", [ + { week_start: "2026-05-25", commits: 8, ai_commits: 2 }, + ]), + ], + ], + ]); + + const trend = buildUsageTrend(rowsPerRepo, EMAIL, NAME); + + expect(trend.map((t) => t.date)).toEqual(["2026-05-25", "2026-07-27"]); + expect(trend[0].aiCommitPct).toBeCloseTo(25); + expect(trend[1].aiCommitPct).toBeCloseTo(40); + }); + + it("prefers the newest push's value when overlapping pushes report the same week", () => { + const rowsPerRepo = new Map([ + [ + "repo-1", + [ + // Newest first (as the DESC-ordered query returns them). + row("2026-08-01T00:00:00Z", [ + { week_start: "2026-07-27", commits: 10, ai_commits: 9 }, + ]), + row("2026-07-15T00:00:00Z", [ + { week_start: "2026-07-27", commits: 3, ai_commits: 0 }, + ]), + ], + ], + ]); + + const trend = buildUsageTrend(rowsPerRepo, EMAIL, NAME); + + expect(trend).toHaveLength(1); + expect(trend[0].aiCommitPct).toBeCloseTo(90); + }); + + it("merges weeks across different repos into the same bucket", () => { + const rowsPerRepo = new Map([ + [ + "repo-1", + [ + row("2026-08-01T00:00:00Z", [ + { week_start: "2026-07-27", commits: 10, ai_commits: 5 }, + ]), + ], + ], + [ + "repo-2", + [ + row("2026-08-01T00:00:00Z", [ + { week_start: "2026-07-27", commits: 10, ai_commits: 5 }, + ]), + ], + ], + ]); + + const trend = buildUsageTrend(rowsPerRepo, EMAIL, NAME); + + expect(trend).toHaveLength(1); + expect(trend[0].repos).toBe(2); + expect(trend[0].aiCommitPct).toBeCloseTo(50); + }); + + it("returns null aiCommitPct for weeks with commit counts but no AI data", () => { + const rowsPerRepo = new Map([ + [ + "repo-1", + [ + row("2026-08-01T00:00:00Z", [ + { week_start: "2026-07-27", commits: 10 }, + ]), + ], + ], + ]); + + const trend = buildUsageTrend(rowsPerRepo, EMAIL, NAME); + + expect(trend[0].aiCommitPct).toBeNull(); + }); +});