@@ -145,6 +149,8 @@ export default async function MaintainerPage({
{activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')})
+ {analytics &&
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) => (
+
+ ))}
+
+
+
+
+
+
XP distributed per week
+ completed recommendations
+
+
+ {analytics.weekly.map((point) => (
+
+ ))}
+
+
+
+
+
+
+
Contributor level distribution
+ monthly cohort view
+
+
+ {analytics.levelDistribution.map((point) => (
+
+ ))}
+
+
+
+ );
+}
+
+function MetricCard({ label, value }: { label: string; value: number | string }) {
+ return (
+
+ );
+}
+
+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;
+}