From 4c119efb2f42b28c06c07b4652118db3cb9bf95e Mon Sep 17 00:00:00 2001 From: GhanshyamJha Date: Wed, 1 Jul 2026 11:30:09 +0530 Subject: [PATCH 1/2] feat: add maintainer day-over-day stat deltas --- src/app/(app)/maintainer/page.tsx | 100 +++++++++++++++++++- src/app/actions/maintainer/analytics.ts | 58 +++++++++--- src/lib/maintainer/analytics.test.ts | 72 +++++++++++++++ src/lib/maintainer/analytics.ts | 117 +++++++++++++++++++++++- 4 files changed, 333 insertions(+), 14 deletions(-) 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 +204,52 @@ export async function getMaintainerAnalyticsTrends(args: { if (error) return err('query_failed', error.message); - // Fetch average review time from pull_requests + // 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); 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 = buildMaintainerAnalyticsTrends({ + now: new Date(), + mergedPullRequests: ( + (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, + })), + completedRecommendations: [], + contributorProfiles: [], + levelUps: [], + }).dayOverDay; + + const trends = normaliseAnalyticsTrends(data, avgReviewTimeHours, dayOverDay); await cacheSet(cacheKey, trends, 30 * 60); return ok(trends); } @@ -223,9 +257,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 +268,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..06c532a7 100644 --- a/src/lib/maintainer/analytics.test.ts +++ b/src/lib/maintainer/analytics.test.ts @@ -78,4 +78,76 @@ describe('buildMaintainerAnalyticsTrends', () => { expect(trends.avgReviewTimeHours).toBe(1.8); }); + + 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..e3ef34f8 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( }); } +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); } From 9f3820a15f293277deeaa36db6efef57391c7f0a Mon Sep 17 00:00:00 2001 From: GhanshyamJha Date: Thu, 2 Jul 2026 17:28:29 +0530 Subject: [PATCH 2/2] fix: scope maintainer analytics to recent PRs --- src/app/actions/maintainer/analytics.ts | 19 ++++++++------ src/lib/maintainer/analytics.test.ts | 35 ++++++++++++++++++++++++- src/lib/maintainer/analytics.ts | 2 +- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/app/actions/maintainer/analytics.ts b/src/app/actions/maintainer/analytics.ts index 164badda..eea6730f 100644 --- a/src/app/actions/maintainer/analytics.ts +++ b/src/app/actions/maintainer/analytics.ts @@ -10,6 +10,7 @@ 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'; @@ -204,12 +205,17 @@ export async function getMaintainerAnalyticsTrends(args: { if (error) return err('query_failed', error.message); + 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, merged_at, mentor_review_at') .in('repo_full_name', repos) - .not('github_created_at', 'is', null); + .eq('mentor_verified', true) + .not('github_created_at', 'is', null) + .gte('github_created_at', yesterdayStart.toISOString()); let avgReviewTimeHours = null; const reviewRows = ( @@ -231,9 +237,9 @@ export async function getMaintainerAnalyticsTrends(args: { avgReviewTimeHours = totalSeconds / reviewRows.length / 3600; } - const dayOverDay = buildMaintainerAnalyticsTrends({ - now: new Date(), - mergedPullRequests: ( + const dayOverDay = buildDayOverDayStats( + new Date(), + ( (prs ?? []) as { github_created_at: string | null; merged_at: string | null; @@ -244,10 +250,7 @@ export async function getMaintainerAnalyticsTrends(args: { mergedAt: pr.merged_at, mentorReviewAt: pr.mentor_review_at, })), - completedRecommendations: [], - contributorProfiles: [], - levelUps: [], - }).dayOverDay; + ); const trends = normaliseAnalyticsTrends(data, avgReviewTimeHours, dayOverDay); await cacheSet(cacheKey, trends, 30 * 60); diff --git a/src/lib/maintainer/analytics.test.ts b/src/lib/maintainer/analytics.test.ts index 06c532a7..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', () => { @@ -79,6 +79,39 @@ 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'), diff --git a/src/lib/maintainer/analytics.ts b/src/lib/maintainer/analytics.ts index e3ef34f8..2352e61b 100644 --- a/src/lib/maintainer/analytics.ts +++ b/src/lib/maintainer/analytics.ts @@ -185,7 +185,7 @@ function buildLevelDistribution( }); } -function buildDayOverDayStats( +export function buildDayOverDayStats( now: Date, pullRequests: AnalyticsMergedPullRequest[], ): MaintainerDayOverDayStats {