Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/app/(app)/maintainer/analytics-trends.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="mb-8 grid gap-6 xl:grid-cols-2">
<div className="rounded-2xl border border-zinc-800 bg-zinc-900 p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-white">Weekly Merge Rate</h2>
<span className="text-xs text-zinc-500">12 weeks</span>
</div>
{hasWeeklyData ? (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.weekly} margin={{ left: -24, right: 8, top: 8, bottom: 0 }}>
<CartesianGrid stroke="#27272a" vertical={false} />
<XAxis dataKey="label" tick={{ fill: '#a1a1aa', fontSize: 12 }} tickLine={false} />
<YAxis tick={{ fill: '#a1a1aa', fontSize: 12 }} tickLine={false} />
<Tooltip
cursor={{ fill: 'rgba(63,63,70,0.35)' }}
contentStyle={{
background: '#18181b',
border: '1px solid #3f3f46',
borderRadius: 8,
color: '#fafafa',
}}
/>
<Legend wrapperStyle={{ color: '#d4d4d8', fontSize: 12 }} />
<Bar dataKey="mergedPrs" name="Merged PRs" fill="#34d399" radius={[4, 4, 0, 0]} />
<Bar
dataKey="xpDistributed"
name="XP distributed"
fill="#60a5fa"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<EmptyChart label="No merged PR or XP activity in this window." />
)}
</div>

<div className="rounded-2xl border border-zinc-800 bg-zinc-900 p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-white">Level Distribution</h2>
<span className="text-xs text-zinc-500">6 months</span>
</div>
{hasLevelData ? (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data.levelDistribution}
margin={{ left: -24, right: 8, top: 8, bottom: 0 }}
>
<CartesianGrid stroke="#27272a" vertical={false} />
<XAxis dataKey="label" tick={{ fill: '#a1a1aa', fontSize: 12 }} tickLine={false} />
<YAxis tick={{ fill: '#a1a1aa', fontSize: 12 }} tickLine={false} />
<Tooltip
contentStyle={{
background: '#18181b',
border: '1px solid #3f3f46',
borderRadius: 8,
color: '#fafafa',
}}
/>
<Legend wrapperStyle={{ color: '#d4d4d8', fontSize: 12 }} />
<Area dataKey="l0" name="L0" stackId="levels" stroke="#f87171" fill="#7f1d1d" />
<Area dataKey="l1" name="L1" stackId="levels" stroke="#fbbf24" fill="#713f12" />
<Area dataKey="l2" name="L2" stackId="levels" stroke="#38bdf8" fill="#075985" />
<Area
dataKey="l3Plus"
name="L3+"
stackId="levels"
stroke="#a78bfa"
fill="#4c1d95"
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<EmptyChart label="No contributor levels available for these repositories." />
)}
</div>
</section>
);
}

function EmptyChart({ label }: { label: string }) {
return (
<div className="flex h-72 items-center justify-center rounded-lg border border-dashed border-zinc-800 text-sm text-zinc-500">
{label}
</div>
);
}
8 changes: 8 additions & 0 deletions src/app/(app)/maintainer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,21 @@ 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,
} from '@/app/actions/maintainer';
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';

Expand Down Expand Up @@ -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 : [];

Expand Down Expand Up @@ -159,6 +166,7 @@ export default async function MaintainerPage({
<p className="mb-4 text-xs text-zinc-500">
{activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')})
</p>
<AnalyticsTrends data={analyticsTrends} />
<div className="mb-8 grid gap-6 lg:grid-cols-3">
<section className="rounded-2xl border border-zinc-800 bg-zinc-900 p-5">
<h2 className="mb-4 text-sm font-semibold text-white">Repository Health</h2>
Expand Down
72 changes: 72 additions & 0 deletions src/app/actions/maintainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -65,6 +66,8 @@ export type ContributorRow = {
level: number;
};

export type { MaintainerAnalyticsTrends };

const ISSUE_BUCKETS = new Set<IssueTriageBucket>([
'needs-triage',
'in-progress',
Expand Down Expand Up @@ -821,3 +824,72 @@ export async function getTopContributors(): Promise<Result<ContributorRow[]>> {
})),
);
}

export async function getMaintainerAnalyticsTrends(args: {
installationId: number;
}): Promise<Result<MaintainerAnalyticsTrends>> {
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<MaintainerAnalyticsTrends>(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<MaintainerAnalyticsTrends>;
return {
weekly: Array.isArray(data.weekly) ? data.weekly : [],
levelDistribution: Array.isArray(data.levelDistribution) ? data.levelDistribution : [],
};
}
68 changes: 68 additions & 0 deletions src/lib/maintainer/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Loading