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
100 changes: 98 additions & 2 deletions src/app/(app)/maintainer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import {
} from '@/app/actions/maintainer';
import type { MaintainerInstall } from '@/lib/maintainer/detect';
import type { MaintainerPrRow } from '@/lib/maintainer/queue';
import type { MaintainerAnalyticsTrends } from '@/lib/maintainer/analytics';
import {
emptyMaintainerDayOverDayStats,
type MaintainerAnalyticsTrends,
type MaintainerDayOverDayMetric,
} from '@/lib/maintainer/analytics';
import { isOk } from '@/lib/result';
import RefreshButton from './refresh-button';
import InviteContributorButton from './invite-contributor-button';
Expand Down Expand Up @@ -97,7 +101,12 @@ export default async function MaintainerPage({
const trendsRes = await getMaintainerAnalyticsTrends({ installationId: activeInstallId });
const analyticsTrends: MaintainerAnalyticsTrends = isOk(trendsRes)
? trendsRes.data
: { weekly: [], levelDistribution: [], avgReviewTimeHours: null };
: {
weekly: [],
levelDistribution: [],
avgReviewTimeHours: null,
dayOverDay: emptyMaintainerDayOverDayStats(),
};
const repoHealthRes = await getRepoHealthOverview({ installationId: activeInstallId });
const repoHealthRows: RepoHealthRow[] = isOk(repoHealthRes) ? repoHealthRes.data : [];

Expand Down Expand Up @@ -210,6 +219,37 @@ export default async function MaintainerPage({
{activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')})
</p>
<QueueSettings settings={settings} />
<section className="mb-8 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatTile
label="PRs Opened"
value={formatCount(analyticsTrends.dayOverDay.openedPrs.current)}
detail="today"
delta={analyticsTrends.dayOverDay.openedPrs}
/>
<StatTile
label="PRs Merged"
value={formatCount(analyticsTrends.dayOverDay.mergedPrs.current)}
detail="today"
delta={analyticsTrends.dayOverDay.mergedPrs}
/>
<StatTile
label="Mentor Reviews"
value={formatCount(analyticsTrends.dayOverDay.mentorReviews.current)}
detail="today"
delta={analyticsTrends.dayOverDay.mentorReviews}
/>
<StatTile
label="Average Review Time"
value={
analyticsTrends.avgReviewTimeHours !== null
? `${analyticsTrends.avgReviewTimeHours.toFixed(1)}h`
: '-'
}
detail="all verified PRs"
delta={analyticsTrends.dayOverDay.avgReviewTimeHours}
deltaUnit="h"
/>
</section>
<AnalyticsTrends data={analyticsTrends} />
{promotionEligible.length > 0 && (
<section className="mb-8 rounded-2xl border border-emerald-900/60 bg-emerald-950/20 p-5">
Expand Down Expand Up @@ -493,6 +533,62 @@ export default async function MaintainerPage({
);
}

function StatTile({
label,
value,
detail,
delta,
deltaUnit,
}: {
label: string;
value: string;
detail: string;
delta: MaintainerDayOverDayMetric;
deltaUnit?: 'h';
}) {
return (
<section className="rounded-2xl border border-zinc-800 bg-zinc-900 p-5">
<div className="flex items-start justify-between gap-3">
<h2 className="text-sm font-semibold text-white">{label}</h2>
<DeltaBadge metric={delta} unit={deltaUnit} />
</div>
<div className="mt-4 flex items-baseline gap-2">
<span className="text-4xl font-bold text-white">{value}</span>
<span className="text-xs text-zinc-500">{detail}</span>
</div>
</section>
);
}

function DeltaBadge({ metric, unit }: { metric: MaintainerDayOverDayMetric; unit?: 'h' }) {
const label = formatDelta(metric, unit);
const tone =
metric.direction === 'up'
? 'border-emerald-800/70 bg-emerald-950/60 text-emerald-300'
: metric.direction === 'down'
? 'border-red-800/70 bg-red-950/60 text-red-300'
: 'border-zinc-700 bg-zinc-800/70 text-zinc-400';

return (
<span className={`rounded-full border px-2 py-0.5 text-xs font-medium ${tone}`}>{label}</span>
);
}

function formatCount(value: number | null): string {
return value === null ? '-' : value.toLocaleString();
}

function formatDelta(metric: MaintainerDayOverDayMetric, unit?: 'h'): string {
if (metric.delta === null) {
return metric.current && metric.current > 0 ? 'new today' : 'no data';
}
if (metric.delta === 0) return 'no change';

const sign = metric.delta > 0 ? '+' : '';
const value = unit === 'h' ? `${sign}${metric.delta.toFixed(1)}h` : `${sign}${metric.delta}`;
return `${value} vs yesterday`;
}

function FilterPill({ label, href, active }: { label: string; href: string; active: boolean }) {
return (
<Link
Expand Down
59 changes: 49 additions & 10 deletions src/app/actions/maintainer/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { profiles, xpEvents, pullRequests } from '@/lib/db/schema';
import { eq, inArray, sum, desc, and, count } from 'drizzle-orm';
import { cacheGet, cacheSet } from '@/lib/cache';
import type { MaintainerAnalyticsTrends } from '@/lib/maintainer/analytics';
import {
buildDayOverDayStats,
buildMaintainerAnalyticsTrends,
emptyMaintainerDayOverDayStats,
} from '@/lib/maintainer/analytics';
import {
comparePrRows,
validateFilters,
Expand Down Expand Up @@ -182,57 +187,91 @@ export async function getMaintainerAnalyticsTrends(args: {

const repos = await listMaintainerRepos(user.id, args.installationId);
if (repos.length === 0) {
return ok({ weekly: [], levelDistribution: [], avgReviewTimeHours: null });
return ok({
weekly: [],
levelDistribution: [],
avgReviewTimeHours: null,
dayOverDay: emptyMaintainerDayOverDayStats(),
});
}

const cacheKey = `maint:analytics-trends:${user.id}:${args.installationId}`;
const cached = await cacheGet<MaintainerAnalyticsTrends>(cacheKey);
if (cached) return ok(cached);
if (cached?.dayOverDay) return ok(cached);

const { data, error } = await service.rpc('maintainer_analytics_trends', {
repo_names: repos,
});

if (error) return err('query_failed', error.message);

// Fetch average review time from pull_requests
const yesterdayStart = new Date();
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
Comment on lines +208 to +209

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setDate uses local server time but buildDayOverDayStats uses UTC day boundaries - they can disagree near UTC midnight. use Date.UTC(...) instead to match.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh i see the dates are conflicting for the time zone ..

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GhanshyamJha05 are u working on this??


// Fetch review stats and day-over-day deltas from timestamped PR rows.
const { data: prs } = await service
.from('pull_requests')
.select('github_created_at, mentor_review_at')
.select('github_created_at, merged_at, mentor_review_at')
.in('repo_full_name', repos)
.eq('mentor_verified', true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this filter means openedPrs and mergedPrs will only count already-verified PRs - nearly always 0, since PRs aren't verified at open time. need a second query without this filter for the opened/merged counts, keeping mentor_verified = true only for the review-time calculation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should i remove the merged for true check or add another similar block without check

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a second query without the filter for opened/merged counts. keep the existing query (with mentor_verified = true) only for calculating review time. removing the filter entirely would break the review-time calculation.

.not('mentor_review_at', 'is', null);
.not('github_created_at', 'is', null)
.gte('github_created_at', yesterdayStart.toISOString());

let avgReviewTimeHours = null;
if (prs && prs.length > 0) {
const reviewRows = (
(prs ?? []) as {
github_created_at: string;
merged_at: string | null;
mentor_review_at: string | null;
}[]
).filter((pr) => pr.mentor_review_at);

if (reviewRows.length > 0) {
const totalSeconds = (
prs as { github_created_at: string; mentor_review_at: string | null }[]
reviewRows as { github_created_at: string; mentor_review_at: string | null }[]
).reduce((sum: number, pr) => {
const created = new Date(pr.github_created_at).getTime();
const reviewed = new Date(pr.mentor_review_at!).getTime();
return sum + (reviewed - created) / 1000;
}, 0);
avgReviewTimeHours = totalSeconds / prs.length / 3600;
avgReviewTimeHours = totalSeconds / reviewRows.length / 3600;
}

const trends = normaliseAnalyticsTrends(data, avgReviewTimeHours);
const dayOverDay = buildDayOverDayStats(
new Date(),
(
(prs ?? []) as {
github_created_at: string | null;
merged_at: string | null;
mentor_review_at: string | null;
}[]
).map((pr) => ({
githubCreatedAt: pr.github_created_at,
mergedAt: pr.merged_at,
mentorReviewAt: pr.mentor_review_at,
})),
);

const trends = normaliseAnalyticsTrends(data, avgReviewTimeHours, dayOverDay);
await cacheSet(cacheKey, trends, 30 * 60);
return ok(trends);
}

function normaliseAnalyticsTrends(
value: unknown,
avgReviewTimeHours: number | null,
dayOverDay = emptyMaintainerDayOverDayStats(),
): MaintainerAnalyticsTrends {
if (!value || typeof value !== 'object') {
return { weekly: [], levelDistribution: [], avgReviewTimeHours: null };
return { weekly: [], levelDistribution: [], avgReviewTimeHours: null, dayOverDay };
}

const data = value as Partial<MaintainerAnalyticsTrends>;
return {
weekly: Array.isArray(data.weekly) ? data.weekly : [],
levelDistribution: Array.isArray(data.levelDistribution) ? data.levelDistribution : [],
avgReviewTimeHours,
dayOverDay,
};
}

Expand Down
107 changes: 106 additions & 1 deletion src/lib/maintainer/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildMaintainerAnalyticsTrends } from './analytics';
import { buildDayOverDayStats, buildMaintainerAnalyticsTrends } from './analytics';

describe('buildMaintainerAnalyticsTrends', () => {
it('groups merged PRs and completed XP into the last twelve UTC weeks', () => {
Expand Down Expand Up @@ -78,4 +78,109 @@ describe('buildMaintainerAnalyticsTrends', () => {

expect(trends.avgReviewTimeHours).toBe(1.8);
});

it('builds day-over-day deltas from UTC PR timestamps', () => {
const stats = buildDayOverDayStats(new Date('2026-05-21T12:00:00.000Z'), [
{
githubCreatedAt: '2026-05-21T02:00:00.000Z',
mergedAt: '2026-05-21T03:00:00.000Z',
mentorReviewAt: '2026-05-21T06:00:00.000Z',
},
{
githubCreatedAt: '2026-05-21T08:00:00.000Z',
mergedAt: '2026-05-21T09:00:00.000Z',
mentorReviewAt: '2026-05-21T10:00:00.000Z',
},
{
githubCreatedAt: '2026-05-20T04:00:00.000Z',
mergedAt: '2026-05-20T05:00:00.000Z',
mentorReviewAt: '2026-05-20T06:00:00.000Z',
},
]);

expect(stats.openedPrs).toMatchObject({
current: 2,
previous: 1,
delta: 1,
direction: 'up',
});
expect(stats.avgReviewTimeHours).toMatchObject({
current: 3,
previous: 2,
delta: 1,
direction: 'up',
});
});

it('builds day-over-day deltas from UTC PR timestamps', () => {
const trends = buildMaintainerAnalyticsTrends({
now: new Date('2026-05-21T12:00:00.000Z'),
mergedPullRequests: [
{
githubCreatedAt: '2026-05-21T02:00:00.000Z',
mergedAt: '2026-05-21T03:00:00.000Z',
mentorReviewAt: '2026-05-21T06:00:00.000Z',
},
{
githubCreatedAt: '2026-05-21T08:00:00.000Z',
mergedAt: '2026-05-21T09:00:00.000Z',
mentorReviewAt: '2026-05-21T10:00:00.000Z',
},
{
githubCreatedAt: '2026-05-20T04:00:00.000Z',
mergedAt: '2026-05-20T05:00:00.000Z',
mentorReviewAt: '2026-05-20T06:00:00.000Z',
},
],
completedRecommendations: [],
contributorProfiles: [],
levelUps: [],
});

expect(trends.dayOverDay.openedPrs).toMatchObject({
current: 2,
previous: 1,
delta: 1,
direction: 'up',
});
expect(trends.dayOverDay.mergedPrs).toMatchObject({
current: 2,
previous: 1,
delta: 1,
});
expect(trends.dayOverDay.mentorReviews).toMatchObject({
current: 2,
previous: 1,
delta: 1,
});
expect(trends.dayOverDay.avgReviewTimeHours).toMatchObject({
current: 3,
previous: 2,
delta: 1,
direction: 'up',
});
});

it('marks average review time delta as flat when either day has no reviews', () => {
const trends = buildMaintainerAnalyticsTrends({
now: new Date('2026-05-21T12:00:00.000Z'),
mergedPullRequests: [
{
githubCreatedAt: '2026-05-21T02:00:00.000Z',
mergedAt: null,
mentorReviewAt: '2026-05-21T06:00:00.000Z',
},
],
completedRecommendations: [],
contributorProfiles: [],
levelUps: [],
});

expect(trends.dayOverDay.avgReviewTimeHours).toMatchObject({
current: 4,
previous: null,
delta: null,
direction: 'flat',
});
});
});
Loading
Loading