diff --git a/src/app/api/metrics/insights/route.ts b/src/app/api/metrics/insights/route.ts new file mode 100644 index 00000000..f428dbe2 --- /dev/null +++ b/src/app/api/metrics/insights/route.ts @@ -0,0 +1,320 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { + getAccountToken, + getAllAccounts, + mergeMetrics, +} from "@/lib/github-accounts"; +import { GITHUB_API } from "@/lib/github"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { + buildDeveloperPersonaResponse, + mergeSignals, + type DeveloperSignals, +} from "@/lib/developer-persona"; + +export const dynamic = "force-dynamic"; + +interface CommitSearchItem { + sha: string; + commit: { + author: { date: string }; + }; + repository: { + full_name: string; + }; +} + +interface PullRequestSearchItem { + created_at: string; + pull_request?: { merged_at: string | null }; +} + +interface CommitDetailsResponse { + stats?: { + additions?: number; + deletions?: number; + }; +} + +function emptySignals(): DeveloperSignals { + return { + commitCountsByDate: {}, + timeBlocks: { + morning: 0, + afternoon: 0, + evening: 0, + night: 0, + }, + prsOpened: 0, + prsMerged: 0, + prMergeTotalHours: 0, + prMergeSampleSize: 0, + additions: 0, + deletions: 0, + }; +} + +async function fetchAccountSignals( + token: string, + githubLogin: string, + cacheContext: { bypass: boolean; userId: string } +): Promise { + const key = metricsCacheKey(cacheContext.userId, "insights", { + githubLogin, + }); + + return withMetricsCache( + { + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS.prs, + }, + async () => { + const since90 = new Date(); + since90.setDate(since90.getDate() - 90); + const since90Str = since90.toISOString().slice(0, 10); + + const commitsRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${since90Str}&per_page=100&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!commitsRes.ok) { + throw new Error("GitHub API error"); + } + + const commitsData = (await commitsRes.json()) as { + items: CommitSearchItem[]; + }; + + const signals = emptySignals(); + const recentCommitRefs: Array<{ repo: string; sha: string }> = []; + + for (const item of commitsData.items ?? []) { + const commitDate = new Date(item.commit.author.date); + + if (Number.isNaN(commitDate.getTime())) { + continue; + } + + const dateKey = item.commit.author.date.slice(0, 10); + signals.commitCountsByDate[dateKey] = + (signals.commitCountsByDate[dateKey] ?? 0) + 1; + + const hour = commitDate.getUTCHours(); + if (hour >= 5 && hour < 10) { + signals.timeBlocks.morning += 1; + } else if (hour >= 10 && hour < 18) { + signals.timeBlocks.afternoon += 1; + } else if (hour >= 18 && hour < 22) { + signals.timeBlocks.evening += 1; + } else { + signals.timeBlocks.night += 1; + } + + if (recentCommitRefs.length < 10) { + recentCommitRefs.push({ + repo: item.repository.full_name, + sha: item.sha, + }); + } + } + + const recentCommitDetails = await Promise.all( + recentCommitRefs.map(async (commitRef) => { + const response = await fetch( + `${GITHUB_API}/repos/${commitRef.repo}/commits/${commitRef.sha}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!response.ok) { + return null; + } + + return (await response.json()) as CommitDetailsResponse; + }) + ); + + for (const commitDetails of recentCommitDetails) { + if (!commitDetails?.stats) { + continue; + } + + signals.additions += commitDetails.stats.additions ?? 0; + signals.deletions += commitDetails.stats.deletions ?? 0; + } + + const prsRes = await fetch( + `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${since90Str}&per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!prsRes.ok) { + throw new Error("GitHub API error"); + } + + const prsData = (await prsRes.json()) as { + items: PullRequestSearchItem[]; + }; + + for (const item of prsData.items ?? []) { + const createdAt = new Date(item.created_at); + + if (Number.isNaN(createdAt.getTime())) { + continue; + } + + signals.prsOpened += 1; + + if (item.pull_request?.merged_at) { + const mergedAt = new Date(item.pull_request.merged_at); + + if (!Number.isNaN(mergedAt.getTime()) && mergedAt >= createdAt) { + signals.prsMerged += 1; + signals.prMergeTotalHours += + (mergedAt.getTime() - createdAt.getTime()) / 3600000; + signals.prMergeSampleSize += 1; + } + } + } + + return signals; + } + ); +} + +function mergeAccountResults(results: PromiseSettledResult[]) { + return mergeMetrics(results, mergeSignals); +} + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session?.accessToken || !session.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const accountId = req.nextUrl.searchParams.get("accountId"); + const bypass = isMetricsCacheBypassed(req); + + if (!accountId) { + try { + const result = await fetchAccountSignals(session.accessToken, session.githubLogin, { + bypass, + userId: session.githubId ?? session.githubLogin, + }); + + return Response.json(buildDeveloperPersonaResponse(result)); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + if (!session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + + if (!userRow) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (accountId === "combined") { + const accounts = await getAllAccounts( + { + token: session.accessToken, + githubId: session.githubId, + githubLogin: session.githubLogin, + }, + userRow.id + ); + + const results = await Promise.allSettled( + accounts.map((account) => + fetchAccountSignals(account.token, account.githubLogin, { + bypass, + userId: account.githubId, + }) + ) + ); + + const merged = mergeAccountResults(results); + + if (!merged) { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + + return Response.json(buildDeveloperPersonaResponse(merged)); + } + + if (accountId === session.githubId) { + try { + const result = await fetchAccountSignals(session.accessToken, session.githubLogin, { + bypass, + userId: session.githubId, + }); + + return Response.json(buildDeveloperPersonaResponse(result)); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + const token = await getAccountToken(userRow.id, accountId); + + if (!token) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + + const { data: accountRow } = await supabaseAdmin + .from("user_github_accounts") + .select("github_login") + .eq("user_id", userRow.id) + .eq("github_id", accountId) + .single(); + + if (!accountRow?.github_login) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + + try { + const result = await fetchAccountSignals(token, accountRow.github_login, { + bypass, + userId: accountId, + }); + + return Response.json(buildDeveloperPersonaResponse(result)); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } +} + + diff --git a/src/components/DeveloperPersona.tsx b/src/components/DeveloperPersona.tsx new file mode 100644 index 00000000..035a8a36 --- /dev/null +++ b/src/components/DeveloperPersona.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useAccount } from "@/components/AccountContext"; + +interface PersonaData { + persona: { + key: + | "night_owl" + | "early_bird" + | "refactorer" + | "marathoner" + | "speed_runner" + | "balanced_builder"; + title: string; + emoji: string; + description: string; + gradient: string; + }; + insights: Array<{ + title: string; + description: string; + }>; +} + +const personaStyles: Record = { + night_owl: { + badge: "border-[var(--accent)]/20 bg-[var(--accent)]/10 text-[var(--foreground)]", + icon: "border-[var(--accent)]/20 bg-[var(--background)]/60 text-[var(--foreground)]", + orb: "from-[var(--accent)]/12 via-[var(--background)]/10 to-transparent", + aura: "bg-gradient-to-br from-[var(--accent)]/14 via-[var(--accent)]/6 to-transparent", + border: "motion-safe:hover:border-[var(--accent)]/30", + shadow: "motion-safe:hover:shadow-[0_18px_42px_color-mix(in_srgb,var(--accent)_20%,transparent)]", + shine: "via-[var(--accent)]/12", + accent: "motion-safe:group-hover:shadow-[0_0_18px_color-mix(in_srgb,var(--accent)_26%,transparent)]", + sparkle: "shadow-[0_0_18px_color-mix(in_srgb,var(--accent)_12%,transparent)]", + }, + early_bird: { + badge: "border-[var(--success)]/20 bg-[var(--success)]/10 text-[var(--foreground)]", + icon: "border-[var(--success)]/20 bg-[var(--background)]/60 text-[var(--foreground)]", + orb: "from-[var(--success)]/12 via-[var(--background)]/10 to-transparent", + aura: "bg-gradient-to-br from-[var(--success)]/14 via-[var(--success)]/6 to-transparent", + border: "motion-safe:hover:border-[var(--success)]/28", + shadow: "motion-safe:hover:shadow-[0_18px_42px_color-mix(in_srgb,var(--success)_20%,transparent)]", + shine: "via-[var(--success)]/12", + accent: "motion-safe:group-hover:shadow-[0_0_18px_color-mix(in_srgb,var(--success)_26%,transparent)]", + sparkle: "shadow-[0_0_18px_color-mix(in_srgb,var(--success)_12%,transparent)]", + }, + refactorer: { + badge: "border-[var(--success)]/20 bg-[var(--background)]/60 text-[var(--foreground)]", + icon: "border-[var(--success)]/20 bg-[var(--background)]/60 text-[var(--foreground)]", + orb: "from-[var(--success)]/10 via-[var(--accent)]/8 to-transparent", + aura: "bg-gradient-to-br from-[var(--success)]/12 via-[var(--accent)]/6 to-transparent", + border: "motion-safe:hover:border-[var(--success)]/28", + shadow: "motion-safe:hover:shadow-[0_18px_42px_color-mix(in_srgb,var(--success)_18%,transparent)]", + shine: "via-[var(--success)]/12", + accent: "motion-safe:group-hover:shadow-[0_0_18px_color-mix(in_srgb,var(--success)_24%,transparent)]", + sparkle: "shadow-[0_0_18px_color-mix(in_srgb,var(--success)_10%,transparent)]", + }, + marathoner: { + badge: "border-[var(--accent)]/20 bg-[var(--background)]/60 text-[var(--foreground)]", + icon: "border-[var(--accent)]/20 bg-[var(--background)]/60 text-[var(--foreground)]", + orb: "from-[var(--accent)]/10 via-[var(--muted)]/8 to-transparent", + aura: "bg-gradient-to-br from-[var(--accent)]/12 via-[var(--muted)]/6 to-transparent", + border: "motion-safe:hover:border-[var(--accent)]/28", + shadow: "motion-safe:hover:shadow-[0_18px_42px_color-mix(in_srgb,var(--accent)_18%,transparent)]", + shine: "via-[var(--accent)]/12", + accent: "motion-safe:group-hover:shadow-[0_0_18px_color-mix(in_srgb,var(--accent)_24%,transparent)]", + sparkle: "shadow-[0_0_18px_color-mix(in_srgb,var(--accent)_10%,transparent)]", + }, + speed_runner: { + badge: "border-[var(--destructive)]/20 bg-[var(--destructive)]/10 text-[var(--foreground)]", + icon: "border-[var(--destructive)]/20 bg-[var(--background)]/60 text-[var(--foreground)]", + orb: "from-[var(--destructive)]/12 via-[var(--background)]/10 to-transparent", + aura: "bg-gradient-to-br from-[var(--destructive)]/14 via-[var(--accent)]/6 to-transparent", + border: "motion-safe:hover:border-[var(--destructive)]/28", + shadow: "motion-safe:hover:shadow-[0_18px_42px_color-mix(in_srgb,var(--destructive)_18%,transparent)]", + shine: "via-[var(--destructive)]/12", + accent: "motion-safe:group-hover:shadow-[0_0_18px_color-mix(in_srgb,var(--destructive)_24%,transparent)]", + sparkle: "shadow-[0_0_18px_color-mix(in_srgb,var(--destructive)_10%,transparent)]", + }, + balanced_builder: { + badge: "border-[var(--muted-foreground)]/20 bg-[var(--background)]/60 text-[var(--foreground)]", + icon: "border-[var(--muted-foreground)]/20 bg-[var(--background)]/60 text-[var(--foreground)]", + orb: "from-[var(--muted-foreground)]/10 via-[var(--background)]/10 to-transparent", + aura: "bg-gradient-to-br from-[var(--muted-foreground)]/10 via-[var(--muted)]/8 to-transparent", + border: "motion-safe:hover:border-[var(--muted-foreground)]/28", + shadow: "motion-safe:hover:shadow-[0_18px_42px_color-mix(in_srgb,var(--muted-foreground)_14%,transparent)]", + shine: "via-[var(--muted-foreground)]/10", + accent: "motion-safe:group-hover:shadow-[0_0_18px_color-mix(in_srgb,var(--muted-foreground)_20%,transparent)]", + sparkle: "shadow-[0_0_18px_color-mix(in_srgb,var(--muted-foreground)_10%,transparent)]", + }, +}; + +export default function DeveloperPersona() { + const { selectedAccount } = useAccount(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadPersona = useCallback( + async (signal?: AbortSignal) => { + setLoading(true); + setError(null); + + try { + const url = + selectedAccount !== null + ? `/api/metrics/insights?accountId=${encodeURIComponent(selectedAccount)}` + : "/api/metrics/insights"; + + const response = await fetch(url, signal ? { signal } : undefined); + + if (!response.ok) { + throw new Error("API error"); + } + + const result = (await response.json()) as PersonaData; + setData(result); + } catch (fetchError) { + if ((fetchError as Error).name === "AbortError") { + return; + } + + setError( + "We couldn't load your developer persona right now. Please try again in a moment." + ); + } finally { + setLoading(false); + } + }, + [selectedAccount] + ); + + useEffect(() => { + const controller = new AbortController(); + void loadPersona(controller.signal); + + return () => controller.abort(); + }, [loadPersona]); + + const theme = useMemo( + () => personaStyles[data?.persona.key ?? "balanced_builder"], + [data?.persona.key] + ); + + return ( +
+
+ ); +} diff --git a/src/lib/developer-persona.ts b/src/lib/developer-persona.ts new file mode 100644 index 00000000..8f7dc5f9 --- /dev/null +++ b/src/lib/developer-persona.ts @@ -0,0 +1,414 @@ +export type PersonaKey = + | "night_owl" + | "early_bird" + | "refactorer" + | "marathoner" + | "speed_runner" + | "balanced_builder"; + +export interface PersonaProfile { + key: PersonaKey; + title: string; + emoji: string; + description: string; + gradient: string; +} + +export interface SmartInsight { + title: string; + description: string; +} + +export interface TimeBlocks { + morning: number; + afternoon: number; + evening: number; + night: number; +} + +export interface DeveloperSignals { + commitCountsByDate: Record; + timeBlocks: TimeBlocks; + prsOpened: number; + prsMerged: number; + prMergeTotalHours: number; + prMergeSampleSize: number; + additions: number; + deletions: number; +} + +export interface PersonaResponse { + persona: PersonaProfile; + insights: SmartInsight[]; +} + +const PERSONA_PROFILES: Record = { + night_owl: { + key: "night_owl", + title: "Night Owl", + emoji: "🌙", + description: "Most of your commits happen late at night.", + gradient: "from-[var(--accent)]/12 via-[var(--background)]/88 to-[var(--background)]", + }, + early_bird: { + key: "early_bird", + title: "Early Bird", + emoji: "☀️", + description: "Your most productive hours start before the workday.", + gradient: "from-[var(--success)]/12 via-[var(--background)]/88 to-[var(--background)]", + }, + refactorer: { + key: "refactorer", + title: "Refactorer", + emoji: "🛠️", + description: "You are cleaning up more code than you are adding.", + gradient: "from-[var(--success)]/10 via-[var(--accent)]/8 to-[var(--background)]", + }, + marathoner: { + key: "marathoner", + title: "Marathoner", + emoji: "🏃", + description: "You keep long coding streaks going without slowing down.", + gradient: "from-[var(--accent)]/12 via-[var(--muted)]/10 to-[var(--background)]", + }, + speed_runner: { + key: "speed_runner", + title: "Speed Runner", + emoji: "⚡", + description: "Your pull requests are moving from open to merge very quickly.", + gradient: "from-[var(--destructive)]/12 via-[var(--accent)]/8 to-[var(--background)]", + }, + balanced_builder: { + key: "balanced_builder", + title: "Balanced Builder", + emoji: "🧭", + description: "Your activity is spread out evenly across the day.", + gradient: "from-[var(--muted-foreground)]/10 via-[var(--background)]/88 to-[var(--background)]", + }, +}; + +function sumTimeBlocks(blocks: TimeBlocks): number { + return blocks.morning + blocks.afternoon + blocks.evening + blocks.night; +} + +function toDateKey(isoDate: string): string { + return isoDate.slice(0, 10); +} + +function getUtcWeekStart(date: Date): Date { + const result = new Date(date); + const dayOfWeek = result.getUTCDay(); + const daysSinceMonday = (dayOfWeek + 6) % 7; + + result.setUTCDate(result.getUTCDate() - daysSinceMonday); + result.setUTCHours(0, 0, 0, 0); + + return result; +} + +function calculateStreaks(commitCountsByDate: Record) { + const commitDays = Object.keys(commitCountsByDate).sort(); + + if (commitDays.length === 0) { + return { + currentStreak: 0, + longestStreak: 0, + totalActiveDays: 0, + currentWeekCommits: 0, + previousWeekCommits: 0, + activeDaysThisWeek: 0, + }; + } + + let longestStreak = 1; + let currentRun = 1; + const runs: { end: string; length: number }[] = []; + + for (let i = 1; i < commitDays.length; i += 1) { + const previousDate = new Date(commitDays[i - 1]).getTime(); + const currentDate = new Date(commitDays[i]).getTime(); + const diffDays = (currentDate - previousDate) / 86400000; + + if (diffDays === 1) { + currentRun += 1; + longestStreak = Math.max(longestStreak, currentRun); + continue; + } + + runs.push({ end: commitDays[i - 1], length: currentRun }); + currentRun = 1; + } + + runs.push({ end: commitDays[commitDays.length - 1], length: currentRun }); + + const today = toDateKey(new Date().toISOString()); + const yesterday = toDateKey(new Date(Date.now() - 86400000).toISOString()); + const latestRun = runs[runs.length - 1]; + const currentStreak = + latestRun.end === today || latestRun.end === yesterday ? latestRun.length : 0; + + const currentWeekStart = getUtcWeekStart(new Date()); + const previousWeekStart = new Date(currentWeekStart.getTime() - 7 * 86400000); + const previousWeekEnd = new Date(currentWeekStart.getTime() - 1); + + let currentWeekCommits = 0; + let previousWeekCommits = 0; + let activeDaysThisWeek = 0; + + for (const [date, count] of Object.entries(commitCountsByDate)) { + const commitDate = new Date(date); + + if (commitDate >= currentWeekStart) { + currentWeekCommits += count; + activeDaysThisWeek += 1; + continue; + } + + if (commitDate >= previousWeekStart && commitDate <= previousWeekEnd) { + previousWeekCommits += count; + } + } + + return { + currentStreak, + longestStreak, + totalActiveDays: commitDays.length, + currentWeekCommits, + previousWeekCommits, + activeDaysThisWeek, + }; +} + +function formatDurationHours(hours: number): string { + if (!Number.isFinite(hours) || hours <= 0) { + return "0h"; + } + + if (hours < 1) { + return `${Math.round(hours * 60)}m`; + } + + if (hours < 24) { + return `${Math.round(hours * 10) / 10}h`; + } + + return `${Math.round((hours / 24) * 10) / 10}d`; +} + +function choosePersonaCandidate( + candidates: Array<{ key: PersonaKey; score: number; eligible: boolean }>, + fallback: PersonaKey +): PersonaKey { + const eligibleCandidates = candidates.filter((candidate) => candidate.eligible); + + if (eligibleCandidates.length > 0) { + return eligibleCandidates.sort((a, b) => b.score - a.score)[0].key; + } + + const scoredCandidates = candidates.filter((candidate) => candidate.score > 0); + + if (scoredCandidates.length > 0) { + return scoredCandidates.sort((a, b) => b.score - a.score)[0].key; + } + + return fallback; +} + +function addInsight( + insights: Array, + insight: SmartInsight, + score: number +) { + insights.push({ ...insight, score }); +} + +function buildSmartInsightCandidates( + signals: DeveloperSignals, + summary: ReturnType, + persona: PersonaKey +): SmartInsight[] { + const insights: Array = []; + const totalChurn = signals.additions + signals.deletions; + const timeBlockTotal = sumTimeBlocks(signals.timeBlocks); + const nightRatio = timeBlockTotal > 0 ? signals.timeBlocks.night / timeBlockTotal : 0; + const morningRatio = timeBlockTotal > 0 ? signals.timeBlocks.morning / timeBlockTotal : 0; + + if (summary.currentStreak >= 3 || summary.activeDaysThisWeek >= 4) { + addInsight( + insights, + { + title: "Consistent Contributor", + description: `You committed code on ${summary.activeDaysThisWeek} day${summary.activeDaysThisWeek === 1 ? "" : "s"} this week and are on a ${summary.currentStreak}-day streak.`, + }, + summary.currentStreak + summary.activeDaysThisWeek + ); + } + + if (summary.currentWeekCommits > summary.previousWeekCommits && summary.previousWeekCommits > 0) { + addInsight( + insights, + { + title: "Momentum Builder", + description: `You shipped ${summary.currentWeekCommits - summary.previousWeekCommits} more commits than last week (${summary.currentWeekCommits} vs ${summary.previousWeekCommits}).`, + }, + summary.currentWeekCommits - summary.previousWeekCommits + ); + } + + if (totalChurn >= 25 && signals.deletions > signals.additions) { + addInsight( + insights, + { + title: "Refactoring Powerhouse", + description: `You deleted ${signals.deletions - signals.additions} more lines than you added across your recent commits.`, + }, + (signals.deletions - signals.additions) / totalChurn + ); + } + + if (signals.prMergeSampleSize >= 2 && signals.prMergeTotalHours > 0) { + const averageHours = signals.prMergeTotalHours / signals.prMergeSampleSize; + + if (averageHours < 8) { + addInsight( + insights, + { + title: "PR Champion", + description: `Your merged PRs are averaging ${formatDurationHours(averageHours)} from open to merge.`, + }, + 8 / averageHours + ); + } + } + + if (persona === "night_owl" && nightRatio >= 0.4) { + addInsight( + insights, + { + title: "Late-Night Focus", + description: `Nearly ${Math.round(nightRatio * 100)}% of your commits land between 10PM and 4AM.`, + }, + nightRatio + ); + } + + if (persona === "early_bird" && morningRatio >= 0.4) { + addInsight( + insights, + { + title: "Early Session", + description: `Nearly ${Math.round(morningRatio * 100)}% of your commits land between 5AM and 10AM.`, + }, + morningRatio + ); + } + + if (insights.length === 0 && summary.totalActiveDays > 0) { + addInsight( + insights, + { + title: "Steady Cadence", + description: `You spread ${Object.values(signals.commitCountsByDate).reduce((sum, count) => sum + count, 0)} commits across ${summary.totalActiveDays} active days.`, + }, + 0.1 + ); + } + + return insights + .sort((a, b) => b.score - a.score) + .slice(0, 3) + .map(({ score: _score, ...insight }) => insight); +} + +export function buildDeveloperPersonaResponse( + signals: DeveloperSignals +): PersonaResponse { + const summary = calculateStreaks(signals.commitCountsByDate); + const totalCommits = Object.values(signals.commitCountsByDate).reduce( + (sum, count) => sum + count, + 0 + ); + const timeBlockTotal = sumTimeBlocks(signals.timeBlocks); + const nightRatio = timeBlockTotal > 0 ? signals.timeBlocks.night / timeBlockTotal : 0; + const morningRatio = timeBlockTotal > 0 ? signals.timeBlocks.morning / timeBlockTotal : 0; + const churnTotal = signals.additions + signals.deletions; + const deletionRatio = churnTotal > 0 ? signals.deletions / churnTotal : 0; + const averagePrMergeHours = + signals.prMergeSampleSize > 0 + ? signals.prMergeTotalHours / signals.prMergeSampleSize + : null; + + const personaKey = choosePersonaCandidate( + [ + { + key: "night_owl", + score: nightRatio, + eligible: totalCommits >= 5 && nightRatio >= 0.6, + }, + { + key: "early_bird", + score: morningRatio, + eligible: totalCommits >= 5 && morningRatio >= 0.6, + }, + { + key: "refactorer", + score: deletionRatio, + eligible: churnTotal >= 25 && signals.deletions > signals.additions, + }, + { + key: "marathoner", + score: Math.min(1, summary.currentStreak / 14 + summary.longestStreak / 30), + eligible: summary.currentStreak >= 7 || summary.longestStreak >= 14, + }, + { + key: "speed_runner", + score: averagePrMergeHours === null ? 0 : Math.max(0, (12 - averagePrMergeHours) / 12), + eligible: + averagePrMergeHours !== null && averagePrMergeHours < 4 && signals.prMergeSampleSize >= 2, + }, + ], + "balanced_builder" + ); + + const persona = PERSONA_PROFILES[personaKey]; + const insights = buildSmartInsightCandidates(signals, summary, personaKey); + + return { + persona, + insights, + }; +} + +export function mergeCommitCounts( + a: Record, + b: Record +): Record { + const merged = { ...a }; + + for (const [date, count] of Object.entries(b)) { + merged[date] = (merged[date] ?? 0) + count; + } + + return merged; +} + +export function mergeSignals(a: DeveloperSignals, b: DeveloperSignals): DeveloperSignals { + return { + commitCountsByDate: mergeCommitCounts(a.commitCountsByDate, b.commitCountsByDate), + timeBlocks: { + morning: a.timeBlocks.morning + b.timeBlocks.morning, + afternoon: a.timeBlocks.afternoon + b.timeBlocks.afternoon, + evening: a.timeBlocks.evening + b.timeBlocks.evening, + night: a.timeBlocks.night + b.timeBlocks.night, + }, + prsOpened: a.prsOpened + b.prsOpened, + prsMerged: a.prsMerged + b.prsMerged, + prMergeTotalHours: a.prMergeTotalHours + b.prMergeTotalHours, + prMergeSampleSize: a.prMergeSampleSize + b.prMergeSampleSize, + additions: a.additions + b.additions, + deletions: a.deletions + b.deletions, + }; +} + + + diff --git a/src/lib/metrics-cache.ts b/src/lib/metrics-cache.ts index d55a22a3..9738430e 100644 --- a/src/lib/metrics-cache.ts +++ b/src/lib/metrics-cache.ts @@ -8,6 +8,7 @@ export const METRICS_CACHE_TTL_SECONDS = { "inactive-repos": 10 * 60, prs: 10 * 60, "pr-review-time": 10 * 60, + insights: 10 * 60, streak: 2 * 60, streak_freeze: 2 * 60, activity: 5 * 60,