diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx
index 297d53a3..574431f5 100644
--- a/src/app/(app)/maintainer/page.tsx
+++ b/src/app/(app)/maintainer/page.tsx
@@ -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';
@@ -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 : [];
@@ -210,6 +219,37 @@ export default async function MaintainerPage({
{activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')})
+
{promotionEligible.length > 0 && (
@@ -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 (
+
+
+
{label}
+
+
+
+ {value}
+ {detail}
+
+
+ );
+}
+
+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 (
+ {label}
+ );
+}
+
+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 (
(cacheKey);
- if (cached) return ok(cached);
+ if (cached?.dayOverDay) return ok(cached);
const { data, error } = await service.rpc('maintainer_analytics_trends', {
repo_names: repos,
@@ -195,27 +205,54 @@ export async function getMaintainerAnalyticsTrends(args: {
if (error) return err('query_failed', error.message);
- // Fetch average review time from pull_requests
+ const yesterdayStart = new Date();
+ yesterdayStart.setDate(yesterdayStart.getDate() - 1);
+
+ // 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)
- .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);
}
@@ -223,9 +260,10 @@ export async function getMaintainerAnalyticsTrends(args: {
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;
@@ -233,6 +271,7 @@ function normaliseAnalyticsTrends(
weekly: Array.isArray(data.weekly) ? data.weekly : [],
levelDistribution: Array.isArray(data.levelDistribution) ? data.levelDistribution : [],
avgReviewTimeHours,
+ dayOverDay,
};
}
diff --git a/src/lib/maintainer/analytics.test.ts b/src/lib/maintainer/analytics.test.ts
index c847f386..373326c1 100644
--- a/src/lib/maintainer/analytics.test.ts
+++ b/src/lib/maintainer/analytics.test.ts
@@ -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', () => {
@@ -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',
+ });
+ });
});
diff --git a/src/lib/maintainer/analytics.ts b/src/lib/maintainer/analytics.ts
index 1fe9a9ae..2352e61b 100644
--- a/src/lib/maintainer/analytics.ts
+++ b/src/lib/maintainer/analytics.ts
@@ -18,10 +18,27 @@ export type MaintainerAnalyticsTrends = {
weekly: WeeklyMaintainerTrend[];
levelDistribution: LevelDistributionTrend[];
avgReviewTimeHours: number | null;
+ dayOverDay: MaintainerDayOverDayStats;
+};
+
+export type MaintainerDayOverDayMetric = {
+ current: number | null;
+ previous: number | null;
+ delta: number | null;
+ direction: 'up' | 'down' | 'flat';
+};
+
+export type MaintainerDayOverDayStats = {
+ openedPrs: MaintainerDayOverDayMetric;
+ mergedPrs: MaintainerDayOverDayMetric;
+ mentorReviews: MaintainerDayOverDayMetric;
+ avgReviewTimeHours: MaintainerDayOverDayMetric;
};
export type AnalyticsMergedPullRequest = {
+ githubCreatedAt?: string | null;
mergedAt: string | null;
+ mentorReviewAt?: string | null;
};
export type AnalyticsCompletedRecommendation = {
@@ -63,7 +80,22 @@ export function buildMaintainerAnalyticsTrends(args: {
args.levelUps,
);
- return { weekly, levelDistribution, avgReviewTimeHours: args.avgReviewTimeHours ?? null };
+ return {
+ weekly,
+ levelDistribution,
+ avgReviewTimeHours: args.avgReviewTimeHours ?? null,
+ dayOverDay: buildDayOverDayStats(args.now, args.mergedPullRequests),
+ };
+}
+
+export function emptyMaintainerDayOverDayStats(): MaintainerDayOverDayStats {
+ const emptyMetric = makeDayOverDayMetric(null, null);
+ return {
+ openedPrs: emptyMetric,
+ mergedPrs: emptyMetric,
+ mentorReviews: emptyMetric,
+ avgReviewTimeHours: emptyMetric,
+ };
}
function buildWeeklyTrends(
@@ -153,6 +185,53 @@ function buildLevelDistribution(
});
}
+export function buildDayOverDayStats(
+ now: Date,
+ pullRequests: AnalyticsMergedPullRequest[],
+): MaintainerDayOverDayStats {
+ const todayStart = startOfUtcDay(now);
+ const tomorrowStart = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
+ const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
+
+ const todayReviewDurations: number[] = [];
+ const yesterdayReviewDurations: number[] = [];
+
+ let openedToday = 0;
+ let openedYesterday = 0;
+ let mergedToday = 0;
+ let mergedYesterday = 0;
+ let reviewedToday = 0;
+ let reviewedYesterday = 0;
+
+ for (const pr of pullRequests) {
+ if (isInUtcRange(pr.githubCreatedAt, todayStart, tomorrowStart)) openedToday += 1;
+ else if (isInUtcRange(pr.githubCreatedAt, yesterdayStart, todayStart)) openedYesterday += 1;
+
+ if (isInUtcRange(pr.mergedAt, todayStart, tomorrowStart)) mergedToday += 1;
+ else if (isInUtcRange(pr.mergedAt, yesterdayStart, todayStart)) mergedYesterday += 1;
+
+ if (isInUtcRange(pr.mentorReviewAt, todayStart, tomorrowStart)) {
+ reviewedToday += 1;
+ const duration = reviewDurationHours(pr);
+ if (duration !== null) todayReviewDurations.push(duration);
+ } else if (isInUtcRange(pr.mentorReviewAt, yesterdayStart, todayStart)) {
+ reviewedYesterday += 1;
+ const duration = reviewDurationHours(pr);
+ if (duration !== null) yesterdayReviewDurations.push(duration);
+ }
+ }
+
+ return {
+ openedPrs: makeDayOverDayMetric(openedToday, openedYesterday),
+ mergedPrs: makeDayOverDayMetric(mergedToday, mergedYesterday),
+ mentorReviews: makeDayOverDayMetric(reviewedToday, reviewedYesterday),
+ avgReviewTimeHours: makeDayOverDayMetric(
+ averageOrNull(todayReviewDurations),
+ averageOrNull(yesterdayReviewDurations),
+ ),
+ };
+}
+
function levelAtSnapshot(currentLevel: number, userLevelUps: AnalyticsLevelUp[], snapshotAt: Date) {
let level = currentLevel;
const snapshotTime = snapshotAt.getTime();
@@ -173,6 +252,42 @@ function startOfUtcWeek(date: Date): Date {
return start;
}
+function startOfUtcDay(date: Date): Date {
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+}
+
+function isInUtcRange(value: string | null | undefined, start: Date, end: Date): boolean {
+ if (!value) return false;
+ const time = Date.parse(value);
+ return Number.isFinite(time) && time >= start.getTime() && time < end.getTime();
+}
+
+function reviewDurationHours(pr: AnalyticsMergedPullRequest): number | null {
+ if (!pr.githubCreatedAt || !pr.mentorReviewAt) return null;
+ const created = Date.parse(pr.githubCreatedAt);
+ const reviewed = Date.parse(pr.mentorReviewAt);
+ if (!Number.isFinite(created) || !Number.isFinite(reviewed) || reviewed < created) return null;
+ return (reviewed - created) / (60 * 60 * 1000);
+}
+
+function averageOrNull(values: number[]): number | null {
+ if (values.length === 0) return null;
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
+}
+
+function makeDayOverDayMetric(
+ current: number | null,
+ previous: number | null,
+): MaintainerDayOverDayMetric {
+ const delta = current !== null && previous !== null ? current - previous : null;
+ return {
+ current,
+ previous,
+ delta,
+ direction: delta === null || delta === 0 ? 'flat' : delta > 0 ? 'up' : 'down',
+ };
+}
+
function endOfUtcMonth(monthStart: Date): Date {
return new Date(Date.UTC(monthStart.getUTCFullYear(), monthStart.getUTCMonth() + 1, 1) - 1);
}