From 4df282349f891db20a2cb77edea50b98ec0f06d4 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Thu, 21 May 2026 12:48:36 +0530 Subject: [PATCH] feat: add maintainer analytics trends --- src/app/(app)/maintainer/analytics-trends.tsx | 113 +++++++++++ src/app/(app)/maintainer/page.tsx | 8 + src/app/actions/maintainer.ts | 72 +++++++ src/lib/maintainer/analytics.test.ts | 68 +++++++ src/lib/maintainer/analytics.ts | 192 ++++++++++++++++++ .../0012_maintainer_analytics_trends.sql | 103 ++++++++++ 6 files changed, 556 insertions(+) create mode 100644 src/app/(app)/maintainer/analytics-trends.tsx create mode 100644 src/lib/maintainer/analytics.test.ts create mode 100644 src/lib/maintainer/analytics.ts create mode 100644 supabase/migrations/0012_maintainer_analytics_trends.sql diff --git a/src/app/(app)/maintainer/analytics-trends.tsx b/src/app/(app)/maintainer/analytics-trends.tsx new file mode 100644 index 0000000..1181057 --- /dev/null +++ b/src/app/(app)/maintainer/analytics-trends.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { MaintainerAnalyticsTrends } from '@/lib/maintainer/analytics'; + +export default function AnalyticsTrends({ data }: { data: MaintainerAnalyticsTrends }) { + const hasWeeklyData = data.weekly.some((row) => row.mergedPrs > 0 || row.xpDistributed > 0); + const hasLevelData = data.levelDistribution.some( + (row) => row.l0 > 0 || row.l1 > 0 || row.l2 > 0 || row.l3Plus > 0, + ); + + return ( +
+
+
+

Weekly Merge Rate

+ 12 weeks +
+ {hasWeeklyData ? ( +
+ + + + + + + + + + + +
+ ) : ( + + )} +
+ +
+
+

Level Distribution

+ 6 months +
+ {hasLevelData ? ( +
+ + + + + + + + + + + + + +
+ ) : ( + + )} +
+
+ ); +} + +function EmptyChart({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index 4e5987c..a575f25 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -5,11 +5,13 @@ import { isUserMaintainer } from '@/lib/maintainer/detect'; import { getMaintainerInstalls, getMaintainerPrQueue, + getMaintainerAnalyticsTrends, getRepoHealthOverview, getStaleIssues, getTopContributors, type MaintainerInstall, type MaintainerPrRow, + type MaintainerAnalyticsTrends, type RepoHealthRow, type StaleIssueRow, type ContributorRow, @@ -17,6 +19,7 @@ import { import { isOk } from '@/lib/result'; import RefreshButton from './refresh-button'; import CiStatusBadge from './ci-status-badge'; +import AnalyticsTrends from './analytics-trends'; export const dynamic = 'force-dynamic'; @@ -74,6 +77,10 @@ export default async function MaintainerPage({ filters, }); const rows: MaintainerPrRow[] = isOk(queueRes) ? queueRes.data.rows : []; + const trendsRes = await getMaintainerAnalyticsTrends({ installationId: activeInstallId }); + const analyticsTrends: MaintainerAnalyticsTrends = isOk(trendsRes) + ? trendsRes.data + : { weekly: [], levelDistribution: [] }; const repoHealthRes = await getRepoHealthOverview(); const repoHealthRows: RepoHealthRow[] = isOk(repoHealthRes) ? repoHealthRes.data : []; @@ -159,6 +166,7 @@ export default async function MaintainerPage({

{activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')})

+

Repository Health

diff --git a/src/app/actions/maintainer.ts b/src/app/actions/maintainer.ts index de0204c..e36f2b5 100644 --- a/src/app/actions/maintainer.ts +++ b/src/app/actions/maintainer.ts @@ -26,6 +26,7 @@ import { getInstallOctokit } from '@/lib/github/app'; import { cacheGet, cacheSet } from '@/lib/cache'; import { classifyTriage, type IssueTriageBucket } from '@/lib/maintainer/issue-triage'; +import type { MaintainerAnalyticsTrends } from '@/lib/maintainer/analytics'; export type { MaintainerInstall, MaintainerPrRow }; @@ -65,6 +66,8 @@ export type ContributorRow = { level: number; }; +export type { MaintainerAnalyticsTrends }; + const ISSUE_BUCKETS = new Set([ 'needs-triage', 'in-progress', @@ -821,3 +824,72 @@ export async function getTopContributors(): Promise> { })), ); } + +export async function getMaintainerAnalyticsTrends(args: { + installationId: number; +}): Promise> { + const sb = getServerSupabase(); + + if (!sb) { + return err('not_configured', 'auth not configured'); + } + + const service = getServiceSupabase(); + + if (!service) { + return err('not_configured', 'service role missing'); + } + + const { + data: { user }, + } = await sb.auth.getUser(); + + if (!user) { + return err('not_authenticated', 'sign in first'); + } + + const limited = await rateLimit({ + namespace: 'maintainer:analytics', + key: user.id, + limit: 30, + windowSec: 60, + }); + if (!limited.ok) return err('rate_limited', 'slow down', true); + + if (!(await isUserMaintainer(user.id))) { + return err('not_authorised', 'not a maintainer'); + } + + const repos = await listMaintainerRepos(user.id, args.installationId); + + if (repos.length === 0) { + return ok({ weekly: [], levelDistribution: [] }); + } + + const cacheKey = `maint:analytics-trends:${user.id}:${args.installationId}`; + const cached = await cacheGet(cacheKey); + if (cached) return ok(cached); + + const { data, error } = await service.rpc('maintainer_analytics_trends', { + repo_names: repos, + }); + + if (error) return err('query_failed', error.message); + + const trends = normaliseAnalyticsTrends(data); + + await cacheSet(cacheKey, trends, 30 * 60); + return ok(trends); +} + +function normaliseAnalyticsTrends(value: unknown): MaintainerAnalyticsTrends { + if (!value || typeof value !== 'object') { + return { weekly: [], levelDistribution: [] }; + } + + const data = value as Partial; + return { + weekly: Array.isArray(data.weekly) ? data.weekly : [], + levelDistribution: Array.isArray(data.levelDistribution) ? data.levelDistribution : [], + }; +} diff --git a/src/lib/maintainer/analytics.test.ts b/src/lib/maintainer/analytics.test.ts new file mode 100644 index 0000000..d3f1ee0 --- /dev/null +++ b/src/lib/maintainer/analytics.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { buildMaintainerAnalyticsTrends } from './analytics'; + +describe('buildMaintainerAnalyticsTrends', () => { + it('groups merged PRs and completed XP into the last twelve UTC weeks', () => { + const trends = buildMaintainerAnalyticsTrends({ + now: new Date('2026-05-21T12:00:00.000Z'), + mergedPullRequests: [ + { mergedAt: '2026-05-18T08:00:00.000Z' }, + { mergedAt: '2026-05-20T08:00:00.000Z' }, + { mergedAt: '2026-05-11T08:00:00.000Z' }, + ], + completedRecommendations: [ + { completedAt: '2026-05-20T09:00:00.000Z', xpReward: 150 }, + { completedAt: '2026-05-12T09:00:00.000Z', xpReward: 80 }, + ], + contributorProfiles: [], + levelUps: [], + }); + + expect(trends.weekly.at(-1)).toMatchObject({ + weekStart: '2026-05-18', + mergedPrs: 2, + xpDistributed: 150, + }); + expect(trends.weekly.at(-2)).toMatchObject({ + weekStart: '2026-05-11', + mergedPrs: 1, + xpDistributed: 80, + }); + }); + + it('reconstructs monthly level snapshots from current levels and level-up events', () => { + const trends = buildMaintainerAnalyticsTrends({ + now: new Date('2026-05-21T12:00:00.000Z'), + mergedPullRequests: [], + completedRecommendations: [], + contributorProfiles: [ + { id: 'u1', level: 3, createdAt: '2026-01-01T00:00:00.000Z' }, + { id: 'u2', level: 1, createdAt: '2026-04-10T00:00:00.000Z' }, + ], + levelUps: [ + { + userId: 'u1', + fromLevel: 2, + toLevel: 3, + occurredAt: '2026-05-05T00:00:00.000Z', + }, + { + userId: 'u1', + fromLevel: 1, + toLevel: 2, + occurredAt: '2026-03-05T00:00:00.000Z', + }, + ], + }); + + expect(trends.levelDistribution.find((row) => row.monthStart === '2026-03-01')).toMatchObject({ + l2: 1, + l3Plus: 0, + }); + expect(trends.levelDistribution.at(-1)).toMatchObject({ + monthStart: '2026-05-01', + l1: 1, + l3Plus: 1, + }); + }); +}); diff --git a/src/lib/maintainer/analytics.ts b/src/lib/maintainer/analytics.ts new file mode 100644 index 0000000..4a555e2 --- /dev/null +++ b/src/lib/maintainer/analytics.ts @@ -0,0 +1,192 @@ +export type WeeklyMaintainerTrend = { + weekStart: string; + label: string; + mergedPrs: number; + xpDistributed: number; +}; + +export type LevelDistributionTrend = { + monthStart: string; + label: string; + l0: number; + l1: number; + l2: number; + l3Plus: number; +}; + +export type MaintainerAnalyticsTrends = { + weekly: WeeklyMaintainerTrend[]; + levelDistribution: LevelDistributionTrend[]; +}; + +export type AnalyticsMergedPullRequest = { + mergedAt: string | null; +}; + +export type AnalyticsCompletedRecommendation = { + completedAt: string | null; + xpReward: number | null; +}; + +export type AnalyticsContributorProfile = { + id: string; + level: number | null; + createdAt: string | null; +}; + +export type AnalyticsLevelUp = { + userId: string; + fromLevel: number; + toLevel: number; + occurredAt: string; +}; + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +export function buildMaintainerAnalyticsTrends(args: { + now: Date; + mergedPullRequests: AnalyticsMergedPullRequest[]; + completedRecommendations: AnalyticsCompletedRecommendation[]; + contributorProfiles: AnalyticsContributorProfile[]; + levelUps: AnalyticsLevelUp[]; +}): MaintainerAnalyticsTrends { + const weekly = buildWeeklyTrends( + args.now, + args.mergedPullRequests, + args.completedRecommendations, + ); + const levelDistribution = buildLevelDistribution( + args.now, + args.contributorProfiles, + args.levelUps, + ); + + return { weekly, levelDistribution }; +} + +function buildWeeklyTrends( + now: Date, + mergedPullRequests: AnalyticsMergedPullRequest[], + completedRecommendations: AnalyticsCompletedRecommendation[], +): WeeklyMaintainerTrend[] { + const currentWeekStart = startOfUtcWeek(now); + const weekStarts = Array.from({ length: 12 }, (_, index) => { + return new Date(currentWeekStart.getTime() - (11 - index) * WEEK_MS); + }); + const rows = weekStarts.map((weekStart) => ({ + weekStart: isoDate(weekStart), + label: shortDate(weekStart), + mergedPrs: 0, + xpDistributed: 0, + })); + const rowByWeek = new Map(rows.map((row) => [row.weekStart, row])); + + for (const pr of mergedPullRequests) { + if (!pr.mergedAt) continue; + const weekKey = isoDate(startOfUtcWeek(new Date(pr.mergedAt))); + const row = rowByWeek.get(weekKey); + if (row) row.mergedPrs += 1; + } + + for (const rec of completedRecommendations) { + if (!rec.completedAt) continue; + const weekKey = isoDate(startOfUtcWeek(new Date(rec.completedAt))); + const row = rowByWeek.get(weekKey); + if (row) row.xpDistributed += rec.xpReward ?? 0; + } + + return rows; +} + +function buildLevelDistribution( + now: Date, + contributorProfiles: AnalyticsContributorProfile[], + levelUps: AnalyticsLevelUp[], +): LevelDistributionTrend[] { + const monthStarts = Array.from({ length: 6 }, (_, index) => { + const month = now.getUTCMonth() - (5 - index); + return new Date(Date.UTC(now.getUTCFullYear(), month, 1)); + }); + const levelUpsByUser = new Map(); + + for (const levelUp of levelUps) { + const userLevelUps = levelUpsByUser.get(levelUp.userId) ?? []; + userLevelUps.push(levelUp); + levelUpsByUser.set(levelUp.userId, userLevelUps); + } + + for (const userLevelUps of levelUpsByUser.values()) { + userLevelUps.sort((a, b) => Date.parse(b.occurredAt) - Date.parse(a.occurredAt)); + } + + return monthStarts.map((monthStart) => { + const monthEnd = endOfUtcMonth(monthStart); + const snapshotAt = monthEnd.getTime() > now.getTime() ? now : monthEnd; + const row: LevelDistributionTrend = { + monthStart: isoDate(monthStart), + label: monthLabel(monthStart), + l0: 0, + l1: 0, + l2: 0, + l3Plus: 0, + }; + + for (const profile of contributorProfiles) { + if (profile.createdAt && Date.parse(profile.createdAt) > snapshotAt.getTime()) { + continue; + } + + const level = levelAtSnapshot( + profile.level ?? 0, + levelUpsByUser.get(profile.id) ?? [], + snapshotAt, + ); + if (level <= 0) row.l0 += 1; + else if (level === 1) row.l1 += 1; + else if (level === 2) row.l2 += 1; + else row.l3Plus += 1; + } + + return row; + }); +} + +function levelAtSnapshot(currentLevel: number, userLevelUps: AnalyticsLevelUp[], snapshotAt: Date) { + let level = currentLevel; + const snapshotTime = snapshotAt.getTime(); + + for (const levelUp of userLevelUps) { + if (Date.parse(levelUp.occurredAt) > snapshotTime) { + level = levelUp.fromLevel; + } + } + + return level; +} + +function startOfUtcWeek(date: Date): Date { + const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const daysSinceMonday = (start.getUTCDay() + 6) % 7; + start.setUTCDate(start.getUTCDate() - daysSinceMonday); + return start; +} + +function endOfUtcMonth(monthStart: Date): Date { + return new Date(Date.UTC(monthStart.getUTCFullYear(), monthStart.getUTCMonth() + 1, 1) - 1); +} + +function isoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function shortDate(date: Date): string { + return new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', timeZone: 'UTC' }).format( + date, + ); +} + +function monthLabel(date: Date): string { + return new Intl.DateTimeFormat('en', { month: 'short', year: 'numeric', timeZone: 'UTC' }).format( + date, + ); +} diff --git a/supabase/migrations/0012_maintainer_analytics_trends.sql b/supabase/migrations/0012_maintainer_analytics_trends.sql new file mode 100644 index 0000000..bb5531a --- /dev/null +++ b/supabase/migrations/0012_maintainer_analytics_trends.sql @@ -0,0 +1,103 @@ +create or replace function maintainer_analytics_trends(repo_names text[], as_of timestamptz default now()) +returns jsonb +language sql +stable +as $$ + with bounds as ( + select + date_trunc('week', as_of)::timestamptz as current_week, + (date_trunc('week', as_of) - interval '11 weeks')::timestamptz as first_week, + date_trunc('month', as_of)::timestamptz as current_month, + (date_trunc('month', as_of) - interval '5 months')::timestamptz as first_month + ), + week_series as ( + select generate_series(bounds.first_week, bounds.current_week, interval '1 week') as week_start + from bounds + ), + weekly_merges as ( + select date_trunc('week', merged_at)::timestamptz as week_start, count(*)::integer as merged_prs + from pull_requests, bounds + where repo_full_name = any(repo_names) + and state = 'merged' + and merged_at is not null + and merged_at >= bounds.first_week + group by 1 + ), + weekly_xp as ( + select + date_trunc('week', r.completed_at)::timestamptz as week_start, + coalesce(sum(r.xp_reward), 0)::integer as xp_distributed + from recommendations r + join issues i on i.id = r.issue_id, bounds + where i.repo_full_name = any(repo_names) + and r.status = 'completed' + and r.completed_at is not null + and r.completed_at >= bounds.first_week + group by 1 + ), + weekly as ( + select jsonb_agg( + jsonb_build_object( + 'weekStart', to_char(ws.week_start::date, 'YYYY-MM-DD'), + 'label', to_char(ws.week_start, 'Mon FMDD'), + 'mergedPrs', coalesce(wm.merged_prs, 0), + 'xpDistributed', coalesce(wx.xp_distributed, 0) + ) + order by ws.week_start + ) as data + from week_series ws + left join weekly_merges wm on wm.week_start = ws.week_start + left join weekly_xp wx on wx.week_start = ws.week_start + ), + contributor_ids as ( + select distinct author_user_id as user_id + from pull_requests + where repo_full_name = any(repo_names) + and author_user_id is not null + ), + month_series as ( + select generate_series(bounds.first_month, bounds.current_month, interval '1 month') as month_start + from bounds + ), + level_snapshots as ( + select + ms.month_start, + least(ms.month_start + interval '1 month' - interval '1 second', as_of) as snapshot_at, + p.id, + coalesce( + ( + select lu.from_level + from level_ups lu + where lu.user_id = p.id + and lu.occurred_at > least(ms.month_start + interval '1 month' - interval '1 second', as_of) + order by lu.occurred_at asc + limit 1 + ), + p.level, + 0 + ) as level + from month_series ms + join contributor_ids ci on true + join profiles p on p.id = ci.user_id + where p.created_at <= least(ms.month_start + interval '1 month' - interval '1 second', as_of) + ), + level_distribution as ( + select jsonb_agg( + jsonb_build_object( + 'monthStart', to_char(ms.month_start::date, 'YYYY-MM-DD'), + 'label', to_char(ms.month_start, 'Mon YYYY'), + 'l0', coalesce(count(ls.id) filter (where ls.level <= 0), 0), + 'l1', coalesce(count(ls.id) filter (where ls.level = 1), 0), + 'l2', coalesce(count(ls.id) filter (where ls.level = 2), 0), + 'l3Plus', coalesce(count(ls.id) filter (where ls.level >= 3), 0) + ) + order by ms.month_start + ) as data + from month_series ms + left join level_snapshots ls on ls.month_start = ms.month_start + ) + select jsonb_build_object( + 'weekly', coalesce((select data from weekly), '[]'::jsonb), + 'levelDistribution', coalesce((select data from level_distribution), '[]'::jsonb) + ); +$$;