diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index 4325a1a..05af922 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -3,11 +3,13 @@ import { redirect } from 'next/navigation'; import { getServerSupabase } from '@/lib/supabase/server'; import { isUserMaintainer } from '@/lib/maintainer/detect'; import { + getMaintainerAnalytics, getMaintainerInstalls, getMaintainerPrQueue, type MaintainerInstall, type MaintainerPrRow, } from '@/app/actions/maintainer'; +import type { MaintainerAnalytics } from '@/lib/maintainer/analytics'; import { isOk } from '@/lib/result'; import RefreshButton from './refresh-button'; @@ -67,6 +69,8 @@ export default async function MaintainerPage({ filters, }); const rows: MaintainerPrRow[] = isOk(queueRes) ? queueRes.data.rows : []; + const analyticsRes = await getMaintainerAnalytics({ installationId: activeInstallId }); + const analytics: MaintainerAnalytics | null = isOk(analyticsRes) ? analyticsRes.data : null; return (
@@ -145,6 +149,8 @@ export default async function MaintainerPage({ {activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')})

+ {analytics && } + {rows.length === 0 ? (
No PRs match your filters. Try widening state or running a refresh. @@ -205,6 +211,134 @@ export default async function MaintainerPage({ ); } +function AnalyticsPanel({ + analytics, + installationId, +}: { + analytics: MaintainerAnalytics; + installationId: number; +}) { + const maxMerged = Math.max(1, ...analytics.weekly.map((point) => point.mergedPrs)); + const maxXp = Math.max(1, ...analytics.weekly.map((point) => point.xpDistributed)); + + return ( +
+
+
+

Analytics trends

+

+ Cached for 30 minutes · generated {relativeTime(analytics.generatedAt)} +

+
+ + Review issue health → + +
+ +
+ + + + +
+ +
+
+
+

Weekly merge rate

+ last 12 weeks +
+
+ {analytics.weekly.map((point) => ( +
+
+ {point.label} +
+ ))} +
+
+ +
+
+

XP distributed per week

+ completed recommendations +
+
+ {analytics.weekly.map((point) => ( +
+
+ {point.label} +
+ ))} +
+
+
+ +
+
+

Contributor level distribution

+ monthly cohort view +
+
+ {analytics.levelDistribution.map((point) => ( + + ))} +
+
+
+ ); +} + +function MetricCard({ label, value }: { label: string; value: number | string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function StackedLevelRow({ point }: { point: MaintainerAnalytics['levelDistribution'][number] }) { + const total = point.l0 + point.l1 + point.l2 + point.l3Plus; + const segments = [ + { label: 'L0', value: point.l0, color: 'bg-zinc-600' }, + { label: 'L1', value: point.l1, color: 'bg-emerald-500' }, + { label: 'L2', value: point.l2, color: 'bg-sky-500' }, + { label: 'L3+', value: point.l3Plus, color: 'bg-purple-500' }, + ]; + + return ( +
+ {point.label} +
+ {segments.map((segment) => ( + 0 ? (segment.value / total) * 100 : 0}%` }} + title={`${segment.label}: ${segment.value}`} + /> + ))} +
+ {total} users +
+ ); +} + function FilterPill({ label, href, active }: { label: string; href: string; active: boolean }) { return ( ([ ]); const PAGE_SIZE = 25; +const ANALYTICS_TTL_S = 30 * 60; export async function getMaintainerInstalls(): Promise> { const sb = getServerSupabase(); @@ -334,6 +343,116 @@ export async function getMaintainerIssueQueue(args: { return ok({ rows: pageRows, hasMore: filtered.length > PAGE_SIZE }); } +export async function getMaintainerAnalytics(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: 'maint: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(buildMaintainerAnalytics({ prs: [], recommendations: [], profiles: [] })); + } + + const cacheKey = `maint:analytics:${user.id}:${args.installationId}`; + const cached = await cacheGet(cacheKey); + if (cached) return ok(cached); + + const since = new Date(Date.now() - 12 * 7 * 24 * 60 * 60 * 1000).toISOString(); + + type RawPrAnalyticsRow = { + id: number; + state: 'open' | 'closed' | 'merged'; + author_user_id: string | null; + github_created_at: string; + github_updated_at: string; + merged_at: string | null; + }; + + const { data: prRows } = await service + .from('pull_requests') + .select('id, state, author_user_id, github_created_at, github_updated_at, merged_at') + .in('repo_full_name', repos) + .or(`github_updated_at.gte.${since},merged_at.gte.${since}`); + + type RawRecommendationRow = { + xp_reward: number; + completed_at: string | null; + issues?: { repo_full_name?: string | null } | { repo_full_name?: string | null }[] | null; + }; + + const { data: recRows } = await service + .from('recommendations') + .select('xp_reward, completed_at, issues!inner(repo_full_name)') + .eq('status', 'completed') + .gte('completed_at', since) + .in('issues.repo_full_name', repos); + + const authorIds = Array.from( + new Set( + ((prRows ?? []) as RawPrAnalyticsRow[]) + .map((row) => row.author_user_id) + .filter((id): id is string => !!id), + ), + ); + + type RawProfileRow = { + id: string; + level: number | null; + created_at: string; + }; + + const { data: profileRows } = + authorIds.length > 0 + ? await service.from('profiles').select('id, level, created_at').in('id', authorIds) + : { data: [] }; + + const prs: PrAnalyticsRow[] = ((prRows ?? []) as RawPrAnalyticsRow[]).map((row) => ({ + id: row.id, + state: row.state, + authorUserId: row.author_user_id, + githubCreatedAt: row.github_created_at, + githubUpdatedAt: row.github_updated_at, + mergedAt: row.merged_at, + })); + + const recommendations: RecommendationAnalyticsRow[] = ( + (recRows ?? []) as RawRecommendationRow[] + ).map((row) => ({ + xpReward: row.xp_reward ?? 0, + completedAt: row.completed_at, + })); + + const profiles: ProfileAnalyticsRow[] = ((profileRows ?? []) as RawProfileRow[]).map((row) => ({ + id: row.id, + level: row.level, + createdAt: row.created_at, + })); + + const analytics = buildMaintainerAnalytics({ prs, recommendations, profiles }); + await cacheSet(cacheKey, analytics, ANALYTICS_TTL_S); + return ok(analytics); +} + export async function refreshMaintainerBackfill( installationId: number, ): Promise> { diff --git a/src/lib/maintainer/analytics.test.ts b/src/lib/maintainer/analytics.test.ts new file mode 100644 index 0000000..adb62e2 --- /dev/null +++ b/src/lib/maintainer/analytics.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { buildMaintainerAnalytics } from './analytics'; + +const now = new Date('2026-05-18T12:00:00Z'); + +describe('buildMaintainerAnalytics', () => { + it('groups merged PRs and completed XP into weekly buckets', () => { + const analytics = buildMaintainerAnalytics({ + now, + prs: [ + { + id: 1, + state: 'merged', + authorUserId: 'u1', + githubCreatedAt: '2026-05-10T00:00:00Z', + githubUpdatedAt: '2026-05-14T00:00:00Z', + mergedAt: '2026-05-14T00:00:00Z', + }, + { + id: 2, + state: 'merged', + authorUserId: 'u2', + githubCreatedAt: '2026-05-15T00:00:00Z', + githubUpdatedAt: '2026-05-16T00:00:00Z', + mergedAt: '2026-05-16T00:00:00Z', + }, + { + id: 3, + state: 'open', + authorUserId: 'u3', + githubCreatedAt: '2026-05-16T00:00:00Z', + githubUpdatedAt: '2026-05-17T00:00:00Z', + mergedAt: null, + }, + ], + recommendations: [ + { xpReward: 40, completedAt: '2026-05-14T00:00:00Z' }, + { xpReward: 60, completedAt: '2026-05-16T00:00:00Z' }, + ], + profiles: [], + }); + + expect(analytics.totals.mergedPrs12w).toBe(2); + expect(analytics.totals.xpDistributed12w).toBe(100); + expect(analytics.totals.activeContributors12w).toBe(3); + expect(analytics.weekly.at(-1)).toMatchObject({ mergedPrs: 2, xpDistributed: 100 }); + }); + + it('ignores records outside the 12 week window', () => { + const analytics = buildMaintainerAnalytics({ + now, + prs: [ + { + id: 1, + state: 'merged', + authorUserId: 'old', + githubCreatedAt: '2025-01-01T00:00:00Z', + githubUpdatedAt: '2025-01-01T00:00:00Z', + mergedAt: '2025-01-01T00:00:00Z', + }, + ], + recommendations: [{ xpReward: 999, completedAt: '2025-01-01T00:00:00Z' }], + profiles: [], + }); + + expect(analytics.totals).toMatchObject({ + mergedPrs12w: 0, + xpDistributed12w: 0, + activeContributors12w: 0, + mergeRatePerWeek: 0, + }); + }); + + it('builds monthly contributor level distribution from profile cohorts', () => { + const analytics = buildMaintainerAnalytics({ + now, + prs: [], + recommendations: [], + profiles: [ + { id: 'a', level: 0, createdAt: '2026-01-10T00:00:00Z' }, + { id: 'b', level: 1, createdAt: '2026-03-10T00:00:00Z' }, + { id: 'c', level: 2, createdAt: '2026-04-10T00:00:00Z' }, + { id: 'd', level: 4, createdAt: '2026-05-10T00:00:00Z' }, + ], + }); + + expect(analytics.levelDistribution).toHaveLength(6); + expect(analytics.levelDistribution.at(-1)).toMatchObject({ + l0: 1, + l1: 1, + l2: 1, + l3Plus: 1, + }); + }); +}); diff --git a/src/lib/maintainer/analytics.ts b/src/lib/maintainer/analytics.ts new file mode 100644 index 0000000..8eb9f44 --- /dev/null +++ b/src/lib/maintainer/analytics.ts @@ -0,0 +1,169 @@ +export type PrAnalyticsRow = { + id: number; + state: 'open' | 'closed' | 'merged'; + authorUserId: string | null; + githubCreatedAt: string; + githubUpdatedAt: string; + mergedAt: string | null; +}; + +export type RecommendationAnalyticsRow = { + xpReward: number; + completedAt: string | null; +}; + +export type ProfileAnalyticsRow = { + id: string; + level: number | null; + createdAt: string; +}; + +export type WeeklyAnalyticsPoint = { + label: string; + start: string; + mergedPrs: number; + xpDistributed: number; +}; + +export type LevelDistributionPoint = { + label: string; + monthStart: string; + l0: number; + l1: number; + l2: number; + l3Plus: number; +}; + +export type MaintainerAnalytics = { + generatedAt: string; + totals: { + mergedPrs12w: number; + xpDistributed12w: number; + activeContributors12w: number; + mergeRatePerWeek: number; + }; + weekly: WeeklyAnalyticsPoint[]; + levelDistribution: LevelDistributionPoint[]; +}; + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +export function buildMaintainerAnalytics(args: { + prs: PrAnalyticsRow[]; + recommendations: RecommendationAnalyticsRow[]; + profiles: ProfileAnalyticsRow[]; + now?: Date; +}): MaintainerAnalytics { + const now = args.now ?? new Date(); + const weekStarts = buildWeekStarts(now, 12); + const weekly = weekStarts.map((start) => ({ + label: shortDate(start), + start: start.toISOString(), + mergedPrs: 0, + xpDistributed: 0, + })); + + const firstWeek = weekStarts[0]!; + const activeContributorIds = new Set(); + + for (const pr of args.prs) { + const updated = parseDate(pr.githubUpdatedAt); + if (pr.authorUserId && updated && updated >= firstWeek && updated <= now) { + activeContributorIds.add(pr.authorUserId); + } + + if (pr.state !== 'merged' || !pr.mergedAt) continue; + const merged = parseDate(pr.mergedAt); + const idx = merged ? weekIndex(merged, weekStarts) : -1; + if (idx >= 0) weekly[idx]!.mergedPrs += 1; + } + + for (const rec of args.recommendations) { + if (!rec.completedAt) continue; + const completed = parseDate(rec.completedAt); + const idx = completed ? weekIndex(completed, weekStarts) : -1; + if (idx >= 0) weekly[idx]!.xpDistributed += rec.xpReward; + } + + const monthStarts = buildMonthStarts(now, 6); + const levelDistribution = monthStarts.map((monthStart) => { + const monthEnd = endOfMonth(monthStart); + const point: LevelDistributionPoint = { + label: monthStart.toLocaleDateString('en', { month: 'short' }), + monthStart: monthStart.toISOString(), + l0: 0, + l1: 0, + l2: 0, + l3Plus: 0, + }; + + for (const profile of args.profiles) { + const createdAt = parseDate(profile.createdAt); + if (!createdAt || createdAt > monthEnd) continue; + const level = profile.level ?? 0; + if (level <= 0) point.l0 += 1; + else if (level === 1) point.l1 += 1; + else if (level === 2) point.l2 += 1; + else point.l3Plus += 1; + } + + return point; + }); + + const mergedPrs12w = weekly.reduce((sum, point) => sum + point.mergedPrs, 0); + const xpDistributed12w = weekly.reduce((sum, point) => sum + point.xpDistributed, 0); + + return { + generatedAt: now.toISOString(), + totals: { + mergedPrs12w, + xpDistributed12w, + activeContributors12w: activeContributorIds.size, + mergeRatePerWeek: roundOne(mergedPrs12w / weekly.length), + }, + weekly, + levelDistribution, + }; +} + +function buildWeekStarts(now: Date, count: number): Date[] { + const start = startOfUtcDay(new Date(now.getTime() - (count - 1) * WEEK_MS)); + return Array.from({ length: count }, (_, index) => new Date(start.getTime() + index * WEEK_MS)); +} + +function buildMonthStarts(now: Date, count: number): Date[] { + const out: Date[] = []; + for (let offset = count - 1; offset >= 0; offset -= 1) { + out.push(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - offset, 1))); + } + return out; +} + +function weekIndex(value: Date, weekStarts: Date[]): number { + const first = weekStarts[0]!; + const last = new Date(weekStarts[weekStarts.length - 1]!.getTime() + WEEK_MS); + if (value < first || value >= last) return -1; + return Math.floor((value.getTime() - first.getTime()) / WEEK_MS); +} + +function startOfUtcDay(value: Date): Date { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); +} + +function endOfMonth(value: Date): Date { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth() + 1, 0, 23, 59, 59, 999)); +} + +function shortDate(value: Date): string { + return value.toLocaleDateString('en', { month: 'short', day: 'numeric' }); +} + +function parseDate(value: string | null): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function roundOne(value: number): number { + return Math.round(value * 10) / 10; +}