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)
+ );
+$$;