From 6ee30c5b8eee7f3671c77e903c553f2175e99648 Mon Sep 17 00:00:00 2001 From: Mallya Date: Mon, 18 May 2026 01:24:20 +0530 Subject: [PATCH 01/24] feat(toprepos): add sortable columns with asc/desc toggle (#226) --- src/components/TopRepos.tsx | 56 +++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx index 3e93be7c..cf934133 100644 --- a/src/components/TopRepos.tsx +++ b/src/components/TopRepos.tsx @@ -20,6 +20,8 @@ export default function TopRepos() { const [minutesAgo, setMinutesAgo] = useState(0); const [healthScores, setHealthScores] = useState>({}); const [healthLoading, setHealthLoading] = useState(true); + const [sortColumn, setSortColumn] = useState<"commits" | "name">("commits"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const fetchRepos = useCallback(() => { setLoading(true); @@ -70,8 +72,30 @@ export default function TopRepos() { fetchRepos(); fetchHealthScores(); }, [fetchRepos, fetchHealthScores, selectedAccount]); + // toggle sort: same column flips direction, new column resets to desc + const handleSort = (column: "commits" | "name") => { + if (sortColumn === column) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortColumn(column); + setSortDirection("desc"); + } + }; + // sort repos based on selected column and direction before rendering + const sortedRepos = [...repos].sort((a, b) => { + if (sortColumn === "name") { + const nameA = (a.name.split("/")[1] ?? a.name).toLowerCase(); + const nameB = (b.name.split("/")[1] ?? b.name).toLowerCase(); + return sortDirection === "asc" + ? nameA.localeCompare(nameB) + : nameB.localeCompare(nameA); + } + return sortDirection === "asc" + ? a.commits - b.commits + : b.commits - a.commits; + }); - const maxCommits = repos[0]?.commits ?? 1; + const maxCommits = sortedRepos[0]?.commits ?? 1; return (
@@ -106,10 +130,37 @@ export default function TopRepos() {
) : repos.length === 0 ? ( +

No commits in the last {days} days.

) : ( + /* column headers — clicking sorts the list */ + <> +
+ + +
+ )} {lastUpdated && (

From 60929a933b11a2aab4f1eb672ff8cf4719c1d9a3 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Mon, 18 May 2026 13:22:04 +0530 Subject: [PATCH 02/24] Add metrics API rate limiting middleware --- src/middleware.ts | 199 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/middleware.ts diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 00000000..615e699f --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,199 @@ +import { getToken } from "next-auth/jwt"; +import { NextRequest, NextResponse } from "next/server"; + +const WINDOW_SECONDS = 60; +const AUTHENTICATED_LIMIT = 60; +const ANONYMOUS_LIMIT = 10; +const memoryBuckets = new Map(); + +type RateLimitResult = { + allowed: boolean; + limit: number; + remaining: number; + reset: number; +}; + +function getIp(req: NextRequest) { + return ( + req.ip ?? + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown" + ); +} + +function buildHeaders(result: RateLimitResult) { + const headers = new Headers(); + headers.set("X-RateLimit-Limit", String(result.limit)); + headers.set("X-RateLimit-Remaining", String(result.remaining)); + headers.set("X-RateLimit-Reset", String(result.reset)); + + if (!result.allowed) { + headers.set( + "Retry-After", + String(Math.max(result.reset - Math.floor(Date.now() / 1000), 1)) + ); + } + + return headers; +} + +function pruneMemoryBuckets(now: number) { + if (memoryBuckets.size < 500) { + return; + } + + const cutoff = now - WINDOW_SECONDS * 1000; + for (const [key, values] of Array.from(memoryBuckets.entries())) { + const active = values.filter((timestamp: number) => timestamp > cutoff); + if (active.length === 0) { + memoryBuckets.delete(key); + } else { + memoryBuckets.set(key, active); + } + } +} + +function checkMemoryLimit( + key: string, + limit: number, + now: number +): RateLimitResult { + pruneMemoryBuckets(now); + + const cutoff = now - WINDOW_SECONDS * 1000; + const active = (memoryBuckets.get(key) ?? []).filter( + (timestamp) => timestamp > cutoff + ); + const reset = Math.ceil( + ((active[0] ?? now) + WINDOW_SECONDS * 1000) / 1000 + ); + + if (active.length >= limit) { + memoryBuckets.set(key, active); + return { + allowed: false, + limit, + remaining: 0, + reset, + }; + } + + active.push(now); + memoryBuckets.set(key, active); + + return { + allowed: true, + limit, + remaining: Math.max(limit - active.length, 0), + reset, + }; +} + +async function checkUpstashLimit( + key: string, + limit: number, + now: number +): Promise { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + + if (!url || !token) { + return null; + } + + const cutoff = now - WINDOW_SECONDS * 1000; + const reset = Math.ceil((now + WINDOW_SECONDS * 1000) / 1000); + + try { + const response = await fetch(`${url}/pipeline`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([ + ["ZREMRANGEBYSCORE", key, 0, cutoff], + ["ZCARD", key], + ]), + cache: "no-store", + }); + + if (!response.ok) { + return null; + } + + const pipeline = (await response.json()) as Array<{ result?: number }>; + const previousCount = Number(pipeline[1]?.result ?? 0); + + if (previousCount >= limit) { + return { + allowed: false, + limit, + remaining: 0, + reset, + }; + } + + await fetch(`${url}/pipeline`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify([ + ["ZADD", key, now, `${now}:${Math.random().toString(36).slice(2)}`], + ["EXPIRE", key, WINDOW_SECONDS], + ]), + cache: "no-store", + }); + + return { + allowed: true, + limit, + remaining: Math.max(limit - previousCount - 1, 0), + reset, + }; + } catch { + return null; + } +} + +async function checkRateLimit(identifier: string, limit: number) { + const now = Date.now(); + const key = `metrics-rate-limit:${identifier}`; + return ( + (await checkUpstashLimit(key, limit, now)) ?? + checkMemoryLimit(key, limit, now) + ); +} + +export async function middleware(req: NextRequest) { + const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }); + const githubId = typeof token?.githubId === "string" ? token.githubId : null; + const identifier = githubId ? `user:${githubId}` : `ip:${getIp(req)}`; + const limit = githubId ? AUTHENTICATED_LIMIT : ANONYMOUS_LIMIT; + const result = await checkRateLimit(identifier, limit); + const headers = buildHeaders(result); + + if (!result.allowed) { + console.warn("metrics_rate_limit_hit", { + identifier, + path: req.nextUrl.pathname, + limit, + }); + + return NextResponse.json( + { error: "Too many metrics requests. Please retry shortly." }, + { status: 429, headers } + ); + } + + const response = NextResponse.next(); + headers.forEach((value, key) => response.headers.set(key, value)); + return response; +} + +export const config = { + matcher: "/api/metrics/:path*", +}; From 15e2bc6794adbb3f551f11d9cb15213eb56eff1b Mon Sep 17 00:00:00 2001 From: Pranesh Jha Date: Sat, 16 May 2026 14:50:58 +0530 Subject: [PATCH 03/24] Remove grid-cols-4 for medium screens --- src/components/PRMetrics.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 990d13f5..89ec3102 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -52,7 +52,7 @@ export default function PRMetrics() {

PR Analytics

{loading ? ( -
+
{[1, 2, 3, 4].map((i) => (
) : ( -
+
{stats.map((stat) => (
Date: Sun, 17 May 2026 10:50:45 +0530 Subject: [PATCH 04/24] Fix overflowing text at pr analytics --- src/components/PRMetrics.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 89ec3102..8202decf 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -52,7 +52,7 @@ export default function PRMetrics() {

PR Analytics

{loading ? ( -
+
{[1, 2, 3, 4].map((i) => (
) : ( -
+
{stats.map((stat) => (
-
+
{stat.value}
-
{stat.label}
+
{stat.label}
))}
From f7094dbeaf6bb4f0c6ab843d8fc8c4a82cf3376f Mon Sep 17 00:00:00 2001 From: Pranesh Jha Date: Sun, 17 May 2026 11:30:47 +0530 Subject: [PATCH 05/24] Introduce helper function getCSSVariable to render tooltip styles --- src/components/PRBreakdownChart.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/PRBreakdownChart.tsx b/src/components/PRBreakdownChart.tsx index c063b3bf..b097761c 100644 --- a/src/components/PRBreakdownChart.tsx +++ b/src/components/PRBreakdownChart.tsx @@ -22,6 +22,13 @@ export default function PRBreakdownChart() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const getCSSVariable = (varName: string): string => { + if (typeof window === "undefined") return "#000"; + return getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + }; + const fetchBreakdown = () => { setLoading(true); setError(null); @@ -99,9 +106,9 @@ export default function PRBreakdownChart() { Date: Sun, 17 May 2026 12:34:58 +0530 Subject: [PATCH 06/24] feat: add accessible keyboard shortcuts modal --- src/components/ContributionGraph.tsx | 8 ++ src/components/DashboardHeader.tsx | 2 + src/components/KeyboardShortcuts.tsx | 68 +++++++++++++++ src/components/ShortcutsModal.tsx | 124 +++++++++++++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 src/components/KeyboardShortcuts.tsx create mode 100644 src/components/ShortcutsModal.tsx diff --git a/src/components/ContributionGraph.tsx b/src/components/ContributionGraph.tsx index cda13878..6ea0c76f 100644 --- a/src/components/ContributionGraph.tsx +++ b/src/components/ContributionGraph.tsx @@ -200,6 +200,14 @@ export default function ContributionGraph() { }; }, []); + useEffect(() => { + const handleToggleChart = () => { + setChartType((prev) => (prev === "bar" ? "line" : "bar")); + }; + window.addEventListener("toggleChart", handleToggleChart); + return () => window.removeEventListener("toggleChart", handleToggleChart); + }, []); + useEffect(() => { if (!lastUpdated) return; const interval = setInterval(() => { diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx index f226d028..effb33e5 100644 --- a/src/components/DashboardHeader.tsx +++ b/src/components/DashboardHeader.tsx @@ -6,6 +6,7 @@ import AccountToggle from "@/components/AccountToggle"; import SignOutButton from "@/components/SignOutButton"; import ThemeToggle from "@/components/ThemeToggle"; import UserAvatar from "@/components/UserAvatar"; +import KeyboardShortcuts from "@/components/KeyboardShortcuts"; export default function DashboardHeader() { const { data: session } = useSession(); @@ -59,6 +60,7 @@ export default function DashboardHeader() { Share Profile )} + diff --git a/src/components/KeyboardShortcuts.tsx b/src/components/KeyboardShortcuts.tsx new file mode 100644 index 00000000..eca7d437 --- /dev/null +++ b/src/components/KeyboardShortcuts.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTheme } from "@/components/ThemeContext"; +import ShortcutsModal from "./ShortcutsModal"; + +export default function KeyboardShortcuts() { + const [isOpen, setIsOpen] = useState(false); + const { toggleTheme } = useTheme(); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const activeElement = document.activeElement; + if (activeElement) { + const tagName = activeElement.tagName.toLowerCase(); + if (tagName === "input" || tagName === "textarea" || tagName === "select") return; + if (activeElement.getAttribute("contenteditable") === "true") return; + } + + if (e.key === "?") { + setIsOpen(true); + e.preventDefault(); + return; + } + + if (e.key.toLowerCase() === "d") { + toggleTheme(); + e.preventDefault(); + return; + } + + if (e.key.toLowerCase() === "b") { + window.dispatchEvent(new Event("toggleChart")); + e.preventDefault(); + return; + } + + if (e.key.toLowerCase() === "r") { + window.location.reload(); + e.preventDefault(); + return; + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [toggleTheme]); + + return ( + <> + + + setIsOpen(false)} /> + + ); +} diff --git a/src/components/ShortcutsModal.tsx b/src/components/ShortcutsModal.tsx new file mode 100644 index 00000000..51c9a271 --- /dev/null +++ b/src/components/ShortcutsModal.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface ShortcutsModalProps { + isOpen: boolean; + onClose: () => void; +} + +interface ShortcutItem { + key: string; + action: string; +} + +const SHORTCUTS: ShortcutItem[] = [ + { key: "D", action: "Toggle theme" }, + { key: "B", action: "Toggle chart" }, + { key: "R", action: "Reload data" }, + { key: "?", action: "Show shortcuts" }, +]; + +export default function ShortcutsModal({ isOpen, onClose }: ShortcutsModalProps) { + const modalRef = useRef(null); + const closeBtnRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + if (closeBtnRef.current) { + closeBtnRef.current.focus(); + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + + if (e.key === "Tab") { + if (!modalRef.current) return; + + const focusableElements = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 0) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + lastElement.focus(); + e.preventDefault(); + } + } else { + if (document.activeElement === lastElement) { + firstElement.focus(); + e.preventDefault(); + } + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+ + +
+ +
+ {SHORTCUTS.map((item) => ( +
+ {item.action} + + {item.key} + +
+ ))} +
+ +
+ +
+
+
+ ); +} From aa9cb534de8ebf2730df8c9b27e3473cf4ed9020 Mon Sep 17 00:00:00 2001 From: devendra-w Date: Mon, 18 May 2026 11:44:24 +0530 Subject: [PATCH 07/24] feat: enable RLS on all Supabase tables and document security model --- .../migrations/20260517000000_enable_rls.sql | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 supabase/migrations/20260517000000_enable_rls.sql diff --git a/supabase/migrations/20260517000000_enable_rls.sql b/supabase/migrations/20260517000000_enable_rls.sql new file mode 100644 index 00000000..533ddaa2 --- /dev/null +++ b/supabase/migrations/20260517000000_enable_rls.sql @@ -0,0 +1,64 @@ +-- Migration: Enable Row Level Security on all tables +-- Created: 2026-05-17 +-- Description: Enables RLS and adds policies so users can only access their own data. +-- supabaseAdmin (service role key) bypasses RLS automatically for server-side ops. + +-- ============================================================ +-- USERS TABLE +-- ============================================================ +alter table users enable row level security; + +-- Users can only read their own row +create policy "users_select_own" + on users for select + using (id = auth.uid()::text); + +-- Users can only update their own row +create policy "users_update_own" + on users for update + using (id = auth.uid()::text); + +-- ============================================================ +-- GOALS TABLE +-- ============================================================ +alter table goals enable row level security; + +-- Users can only read their own goals +create policy "goals_select_own" + on goals for select + using (user_id = auth.uid()::text); + +-- Users can only insert goals for themselves +create policy "goals_insert_own" + on goals for insert + with check (user_id = auth.uid()::text); + +-- Users can only update their own goals +create policy "goals_update_own" + on goals for update + using (user_id = auth.uid()::text); + +-- Users can only delete their own goals +create policy "goals_delete_own" + on goals for delete + using (user_id = auth.uid()::text); + +-- ============================================================ +-- METRIC_SNAPSHOTS TABLE +-- ============================================================ +alter table metric_snapshots enable row level security; + +-- Users can only read their own snapshots +create policy "metric_snapshots_select_own" + on metric_snapshots for select + using (user_id = auth.uid()::text); + +-- Users can only insert their own snapshots +create policy "metric_snapshots_insert_own" + on metric_snapshots for insert + with check (user_id = auth.uid()::text); + +-- Users can only delete their own snapshots +create policy "metric_snapshots_delete_own" + on metric_snapshots for delete + using (user_id = auth.uid()::text); \ No newline at end of file From dc062f741bcc51bfe3899d03b5b604c18ce6a651 Mon Sep 17 00:00:00 2001 From: devendra-w Date: Mon, 18 May 2026 12:06:50 +0530 Subject: [PATCH 08/24] docs: add RLS security model to SECURITY.md --- SECURITY.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index 78cbedf3..dc991cdb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,38 @@ Out of scope: - Social engineering attacks - Rate limiting / denial of service on free-tier Vercel/Supabase +## Row Level Security (RLS) + +DevTrack uses Supabase with Row Level Security enabled on all tables to ensure users can only access their own data. + +### Protected Tables + +| Table | RLS Enabled | Policies | +|-------|-------------|----------| +| `users` | ✅ | SELECT, UPDATE own row only | +| `goals` | ✅ | SELECT, INSERT, UPDATE, DELETE own rows only | +| `metric_snapshots` | ✅ | SELECT, INSERT, DELETE own rows only | + +### How It Works + +- All RLS policies use `auth.uid()` to match against the `id` or `user_id` column +- Users can only read, write, or delete their **own** rows +- `supabaseAdmin` (service role key) bypasses RLS automatically for trusted server-side operations — it is **never** exposed to the client +- The anon key has no access to any table by default + +### Migration + +RLS policies are defined in: + ## Disclosure Policy Once a fix is released, we will publish a summary in the [GitHub Security Advisories](https://github.com/Priyanshu-byte-coder/devtrack/security/advisories) page. Credit will be given to the reporter unless they prefer to remain anonymous. + +To apply locally: +```bash +supabase db push +``` + +### Security Principle + +All client-facing queries use the anon key with RLS enforcement. Server-side API routes use `supabaseAdmin` only when elevated privileges are required (e.g. creating a user on first login). From eabcc404d85707d9981e5a0a4a84d41d8116248d Mon Sep 17 00:00:00 2001 From: Mohd Saif Ansari Date: Sun, 17 May 2026 12:53:19 +0530 Subject: [PATCH 09/24] feat: add monthly active-day trend comparison --- src/components/StreakTracker.tsx | 82 ++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/components/StreakTracker.tsx b/src/components/StreakTracker.tsx index 141e3227..2be2710e 100644 --- a/src/components/StreakTracker.tsx +++ b/src/components/StreakTracker.tsx @@ -173,6 +173,7 @@ export default function StreakTracker() { const badge = MILESTONES.find((m) => (data?.current ?? 0) >= m.days); const activeDayData = calculateActiveDayInsights(contributionData?.data); + const monthlyTrend = calculateMonthlyTrend(contributionData); const stats = data ? [ @@ -283,6 +284,16 @@ export default function StreakTracker() {
))}
+ {monthlyTrend.isValid && ( +
+ + This month: {monthlyTrend.thisMonth} active days + + + ({monthlyTrend.text}) + +
+ )} {badge && (
{badge.emoji} @@ -593,3 +604,74 @@ function calculateActiveDayInsights(data: Record | undefined | n return { insights, peakDay, isValid: true }; } + +interface MonthlyTrendResult { + isValid: boolean; + thisMonth: number; + lastMonth: number; + text: string; + colorClass: string; +} + +function calculateMonthlyTrend(contrib: ContributionData | undefined | null): MonthlyTrendResult { + if (!contrib || !contrib.data) { + return { isValid: false, thisMonth: 0, lastMonth: 0, text: "", colorClass: "" }; + } + + if (contrib.days < 30) { + return { isValid: false, thisMonth: 0, lastMonth: 0, text: "", colorClass: "" }; + } + + const data = contrib.data; + + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth(); + + const prevDate = new Date(currentYear, currentMonth - 1, 1); + const prevYear = prevDate.getFullYear(); + const prevMonth = prevDate.getMonth(); + + let thisMonth = 0; + let lastMonth = 0; + + for (const [dateStr, count] of Object.entries(data)) { + if (count > 0) { + const parts = dateStr.split("-").map(Number); + if (parts.length === 3 && !isNaN(parts[0]) && !isNaN(parts[1]) && !isNaN(parts[2])) { + const d = new Date(parts[0], parts[1] - 1, parts[2]); + if (!isNaN(d.getTime())) { + if (d.getFullYear() === currentYear && d.getMonth() === currentMonth) { + thisMonth++; + } else if (d.getFullYear() === prevYear && d.getMonth() === prevMonth) { + lastMonth++; + } + } + } + } + } + + let text = ""; + let colorClass = ""; + + if (lastMonth === 0) { + text = "First month tracked!"; + colorClass = "text-[var(--accent)] font-medium"; + } else { + const deltaCalc = ((thisMonth - lastMonth) / lastMonth) * 100; + const formatted = deltaCalc.toFixed(0); + + if (deltaCalc > 0) { + text = `↑${formatted}% vs last month`; + colorClass = "text-green-500 font-medium"; + } else if (deltaCalc < 0) { + text = `↓${Math.abs(deltaCalc).toFixed(0)}% vs last month`; + colorClass = "text-red-500 font-medium"; + } else { + text = `=0% vs last month`; + colorClass = "text-[var(--muted-foreground)] font-medium"; + } + } + + return { isValid: true, thisMonth, lastMonth, text, colorClass }; +} From 0c9273e2241a81385266228d18b4142adf544064 Mon Sep 17 00:00:00 2001 From: indresh404 Date: Tue, 19 May 2026 11:03:54 +0530 Subject: [PATCH 10/24] added area toggle and its function --- src/components/ContributionGraph.tsx | 62 ++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/src/components/ContributionGraph.tsx b/src/components/ContributionGraph.tsx index 6ea0c76f..867e51c1 100644 --- a/src/components/ContributionGraph.tsx +++ b/src/components/ContributionGraph.tsx @@ -7,6 +7,8 @@ import { Bar, LineChart, Line, + AreaChart, + Area, XAxis, YAxis, CartesianGrid, @@ -26,7 +28,7 @@ interface GraphPoint { friend: number; } -type ViewMode = "bar" | "line"; +type ViewMode = "bar" | "line" | "area"; const RANGES = [ { label: "7d", days: 7 }, @@ -37,6 +39,7 @@ const RANGES = [ const charts: { key: ViewMode; label: string }[] = [ { key: "bar", label: "Bar" }, { key: "line", label: "Line" }, + { key: "area", label: "Area" }, ]; function mergeContributionData( @@ -370,7 +373,7 @@ export default function ContributionGraph() { /> )} - ) : ( + ) : chartType === "line" ? ( )} + ) : ( + + + + + + {hasFriendData && ( + + )} + {compareMode && hasFriendData ? ( + <> + + + + ) : ( + + )} + )}
@@ -444,4 +500,4 @@ export default function ContributionGraph() { )}
); -} +} \ No newline at end of file From 8d348df594b2b918871dbbbc88fd79dc222cb454 Mon Sep 17 00:00:00 2001 From: Sanchit Rishi Date: Sun, 17 May 2026 13:41:28 +0530 Subject: [PATCH 11/24] feat: weekly digest summary card (closes #99) --- src/app/api/metrics/weekly-summary/route.ts | 351 +++++++++++--------- src/app/dashboard/page.tsx | 6 +- src/components/WeeklySummaryCard.tsx | 269 +++++++-------- 3 files changed, 307 insertions(+), 319 deletions(-) diff --git a/src/app/api/metrics/weekly-summary/route.ts b/src/app/api/metrics/weekly-summary/route.ts index a53a8eb3..b13fd5b4 100644 --- a/src/app/api/metrics/weekly-summary/route.ts +++ b/src/app/api/metrics/weekly-summary/route.ts @@ -1,197 +1,222 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import type { NextRequest } from "next/server"; -import { getLastWeekRange, getThisWeekRange } from "@/lib/dateUtils"; +import { GITHUB_API } from "@/lib/github"; export const dynamic = "force-dynamic"; -const GITHUB_API = "https://api.github.com"; - -interface CommitSearchResponse { - total_count: number; - items: Array<{ - repository: { full_name: string }; - commit: { author: { date: string } }; - }>; +function toDateStr(d: Date): string { + return d.toISOString().slice(0, 10); } -interface PullRequestSearchResponse { - total_count: number; - items: Array>; +function dateDiffDays(a: string, b: string): number { + return ( + (new Date(b).getTime() - new Date(a).getTime()) / (1000 * 60 * 60 * 24) + ); } -async function fetchGitHubJson( - url: string, - accessToken: string, - accept: string = "application/vnd.github+json" -): Promise { - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: accept, - }, - cache: "no-store", - }); +function getCurrentWeekStartUtc(): Date { + const now = new Date(); + const currentWeekStart = new Date(now); + const dayOfWeek = currentWeekStart.getUTCDay(); + const daysSinceMonday = (dayOfWeek + 6) % 7; - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); - } + currentWeekStart.setUTCDate(currentWeekStart.getUTCDate() - daysSinceMonday); + currentWeekStart.setUTCHours(0, 0, 0, 0); - return (await response.json()) as T; + return currentWeekStart; } -async function fetchCommitSearch( - githubLogin: string, - accessToken: string, - start: string, - end: string -): Promise { - const query = encodeURIComponent( - `author:${githubLogin} committer-date:${start}..${end}` - ); +function calculateCurrentStreak(activeDates: Set): number { + const commitDays = Array.from(activeDates).sort(); - // TODO: paginate for high-activity users - return fetchGitHubJson( - `${GITHUB_API}/search/commits?q=${query}&per_page=100`, - accessToken, - "application/vnd.github+json" - ); -} + if (commitDays.length === 0) { + return 0; + } -async function fetchPullRequestsOpenedThisWeek( - githubLogin: string, - accessToken: string, - startDate: string, - endDate: string -): Promise { - const query = encodeURIComponent( - `type:pr author:${githubLogin} created:${startDate}..${endDate}` - ); - const data = await fetchGitHubJson( - `${GITHUB_API}/search/issues?q=${query}&per_page=100`, - accessToken - ); + let currentRun = 1; + const runs: { end: string; length: number }[] = []; + + for (let i = 1; i < commitDays.length; i++) { + const diff = dateDiffDays(commitDays[i - 1], commitDays[i]); + if (diff === 1) { + currentRun++; + } else { + runs.push({ end: commitDays[i - 1], length: currentRun }); + currentRun = 1; + } + } + + runs.push({ + end: commitDays[commitDays.length - 1], + length: currentRun, + }); + + const today = toDateStr(new Date()); + const yesterday = toDateStr(new Date(Date.now() - 86400000)); + const lastRun = runs[runs.length - 1]; - return data.items.length; + return lastRun.end === today || lastRun.end === yesterday + ? lastRun.length + : 0; } -async function fetchPullRequestsMergedThisWeek( +async function fetchActiveDates( githubLogin: string, - accessToken: string, - startDate: string, - endDate: string -): Promise { - const query = encodeURIComponent( - `type:pr author:${githubLogin} is:merged merged:${startDate}..${endDate}` - ); - const data = await fetchGitHubJson( - `${GITHUB_API}/search/issues?q=${query}&per_page=100`, - accessToken + token: string +): Promise> { + const since = new Date(); + since.setDate(since.getDate() - 90); + const sinceStr = since.toISOString().slice(0, 10); + + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } ); - return data.total_count; -} + if (!searchRes.ok) { + throw new Error("GitHub API error"); + } -function deriveMostActiveRepo(items: CommitSearchResponse["items"]): string | null { - const counts: Record = {}; - for (const item of items) { - const name = item.repository.full_name; - counts[name] = (counts[name] ?? 0) + 1; + const data = (await searchRes.json()) as { + items: Array<{ commit: { author: { date: string } } }>; + }; + + const activeDates = new Set(); + for (const item of data.items) { + activeDates.add(item.commit.author.date.slice(0, 10)); } - const entries = Object.entries(counts); - if (entries.length === 0) return null; - return entries.reduce((a, b) => (b[1] > a[1] ? b : a))[0]; + + return activeDates; } -export async function GET(req: NextRequest) { +export async function GET() { const session = await getServerSession(authOptions); if (!session?.accessToken || !session.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const thisWeekRange = getThisWeekRange(); - const lastWeekRange = getLastWeekRange(); - const thisWeekStartDate = thisWeekRange.start.slice(0, 10); - const thisWeekEndDate = thisWeekRange.end.slice(0, 10); - - const results = await Promise.allSettled([ - fetchCommitSearch( - session.githubLogin, - session.accessToken, - thisWeekRange.start, - thisWeekRange.end - ), - fetchCommitSearch( + try { + const currentWeekStart = getCurrentWeekStartUtc(); + const prevWeekStart = new Date(currentWeekStart.getTime() - 7 * 86400000); + const prevWeekEnd = new Date(currentWeekStart.getTime() - 1); + const fourteenDaysAgo = new Date(Date.now() - 14 * 86400000); + const fourteenDaysAgoStr = toDateStr(fourteenDaysAgo); + + const commitsRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${fourteenDaysAgoStr}&per_page=100`, + { + headers: { + Authorization: `Bearer ${session.accessToken}`, + Accept: "application/vnd.github.cloak-preview+json", + }, + cache: "no-store", + } + ); + + if (!commitsRes.ok) { + throw new Error("GitHub API error"); + } + + const commitsData = (await commitsRes.json()) as { + items: Array<{ + commit: { author: { date: string } }; + repository: { full_name: string }; + }>; + }; + + let commitsThisWeek = 0; + let commitsPrevWeek = 0; + const activeDaysThisWeek = new Set(); + const repoCounts = new Map(); + + for (const item of commitsData.items) { + const commitDate = new Date(item.commit.author.date); + + if (commitDate >= currentWeekStart) { + commitsThisWeek++; + activeDaysThisWeek.add(item.commit.author.date.slice(0, 10)); + + const repoName = item.repository.full_name; + repoCounts.set(repoName, (repoCounts.get(repoName) ?? 0) + 1); + } else if (commitDate >= prevWeekStart && commitDate <= prevWeekEnd) { + commitsPrevWeek++; + } + } + + let topRepo: string | null = null; + let topRepoCount = 0; + Array.from(repoCounts.entries()).forEach(([repoName, count]) => { + if (count > topRepoCount) { + topRepo = repoName; + topRepoCount = count; + } + }); + + const prsRes = await fetch( + `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${fourteenDaysAgoStr}&per_page=100`, + { + headers: { + Authorization: `Bearer ${session.accessToken}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!prsRes.ok) { + throw new Error("GitHub API error"); + } + + const prsData = (await prsRes.json()) as { + items: Array<{ + created_at: string; + state: string; + }>; + }; + + let prsOpenedThisWeek = 0; + let prsMergedThisWeek = 0; + + for (const item of prsData.items) { + const createdAt = new Date(item.created_at); + if (createdAt >= currentWeekStart) { + prsOpenedThisWeek++; + if (item.state === "closed") { + prsMergedThisWeek++; + } + } + } + + const streakDates = await fetchActiveDates( session.githubLogin, - session.accessToken, - lastWeekRange.start, - lastWeekRange.end - ), - fetchPullRequestsOpenedThisWeek( - session.githubLogin, - session.accessToken, - thisWeekStartDate, - thisWeekEndDate - ), - fetchPullRequestsMergedThisWeek( - session.githubLogin, - session.accessToken, - thisWeekStartDate, - thisWeekEndDate - ), - fetch(`${process.env.NEXTAUTH_URL ?? "http://localhost:3000"}/api/metrics/streak`, { - headers: { cookie: req.headers.get("cookie") ?? "" }, - cache: "no-store", - }).then((r) => (r.ok ? r.json() : Promise.reject(r.status))), - ]); - - const fulfilledCount = results.filter( - (result) => result.status === "fulfilled" - ).length; - - if (fulfilledCount === 0) { + session.accessToken + ); + const currentStreak = calculateCurrentStreak(streakDates); + const commitDelta = commitsThisWeek - commitsPrevWeek; + + return Response.json({ + commits: { + current: commitsThisWeek, + previous: commitsPrevWeek, + delta: commitDelta, + trend: + commitDelta > 0 ? "up" : commitDelta < 0 ? "down" : "same", + }, + prs: { + opened: prsOpenedThisWeek, + merged: prsMergedThisWeek, + }, + activeDays: activeDaysThisWeek.size, + streak: currentStreak, + topRepo, + }); + } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } - - const currentWeekCommits = - results[0].status === "fulfilled" ? results[0].value.total_count : null; - const lastWeekCommits = - results[1].status === "fulfilled" ? results[1].value.total_count : null; - const openedPRs = results[2].status === "fulfilled" ? results[2].value : null; - const mergedPRs = results[3].status === "fulfilled" ? results[3].value : null; - const mostActiveRepo = - results[0].status === "fulfilled" - ? deriveMostActiveRepo(results[0].value.items) - : null; - const activeDayCommitData = - results[0].status === "fulfilled" ? results[0].value.items : null; - - const activeDays = activeDayCommitData - ? new Set(activeDayCommitData.map((item) => item.commit.author.date.slice(0, 10))) - .size - : null; - const streak = - results[4].status === "fulfilled" - ? (results[4].value as { current: number }).current - : null; - - return Response.json({ - commits: { - current: currentWeekCommits, - last: lastWeekCommits, - delta: - currentWeekCommits !== null && lastWeekCommits !== null - ? currentWeekCommits - lastWeekCommits - : null, - }, - pullRequests: { - opened: openedPRs, - merged: mergedPRs, - }, - activeDays, - streak, - mostActiveRepo, - weekStart: thisWeekRange.start, - generatedAt: new Date().toISOString(), - }); } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9e4bdb66..f5c13d89 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -34,7 +34,9 @@ export default async function DashboardPage() {
- +
+ +
@@ -80,4 +82,4 @@ export default async function DashboardPage() {
); -} \ No newline at end of file +} diff --git a/src/components/WeeklySummaryCard.tsx b/src/components/WeeklySummaryCard.tsx index 4e063acc..f09fadf7 100644 --- a/src/components/WeeklySummaryCard.tsx +++ b/src/components/WeeklySummaryCard.tsx @@ -3,180 +3,141 @@ import { useEffect, useState } from "react"; interface WeeklySummaryData { - commits: { current: number | null; last: number | null; delta: number | null }; - pullRequests: { opened: number | null; merged: number | null }; - activeDays: number | null; - streak: number | null; - mostActiveRepo: string | null; - weekStart: string; - generatedAt: string; -} - -function DeltaBadge({ delta }: { delta: number | null }) { - if (delta === null) { - return ( - - — - - ); - } - - if (delta > 0) { - return ↑ {delta}; - } - - if (delta < 0) { - return ( - - ↓ {Math.abs(delta)} - - ); - } - - return ( - - 0 - - ); -} - -function formatRepoName(repo: string | null): string { - if (!repo) return "—"; - return repo.split("/")[1] ?? repo; + commits: { + current: number; + previous: number; + delta: number; + trend: "up" | "down" | "same"; + }; + prs: { + opened: number; + merged: number; + }; + activeDays: number; + streak: number; + topRepo: string | null; } export default function WeeklySummaryCard() { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(false); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [isCollapsed, setIsCollapsed] = useState(false); - const [isHydrated, setIsHydrated] = useState(false); useEffect(() => { - const stored = window.localStorage.getItem("weekly-summary-collapsed"); - if (stored !== null) { - setIsCollapsed(stored === "true"); - } - setIsHydrated(true); - }, []); + setLoading(true); + setError(null); - useEffect(() => { fetch("/api/metrics/weekly-summary") - .then((response) => { - if (!response.ok) { - throw new Error("Failed to fetch weekly summary"); - } - - return response.json(); - }) - .then((summary: WeeklySummaryData) => { - setData(summary); - setError(false); - }) - .catch(() => { - setError(true); + .then((r) => { + if (!r.ok) throw new Error("API error"); + return r.json(); }) - .finally(() => { - setIsLoading(false); - }); + .then((data: WeeklySummaryData) => setSummary(data)) + .catch(() => + setError( + "We couldn't load your weekly summary right now. Please try again in a moment." + ) + ) + .finally(() => setLoading(false)); }, []); - const toggleCollapsed = () => { - const nextValue = !isCollapsed; - setIsCollapsed(nextValue); - window.localStorage.setItem( - "weekly-summary-collapsed", - String(nextValue) - ); - }; - - const showCollapsed = isHydrated && !isLoading && isCollapsed; - return ( -
- +
+
+

+ This Week +

+ +
- {showCollapsed ? null : isLoading ? ( -
- {Array.from({ length: 6 }).map((_, index) => ( -
-
-
-
- ))} -
- ) : error ? ( -

- Unable to load weekly stats -

- ) : ( -
-
-
Commits
-
- {data?.commits.current ?? "—"} -
-
- -
+ {!isCollapsed && + (loading ? ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))}
-
-
PRs Open
-
- {data?.pullRequests.opened ?? "—"} -
+ ) : error ? ( +
+ {error}
-
-
PRs Merged
-
- {data?.pullRequests.merged ?? "—"} + ) : summary ? ( +
+
+ + Commits + +
+ + {summary.commits.current} + + {summary.commits.trend === "up" && ( + + + {summary.commits.delta} + + )} + {summary.commits.trend === "down" && ( + + - {Math.abs(summary.commits.delta)} + + )} + {summary.commits.trend === "same" && ( + + 0 + + )} +
-
-
-
Active Days
-
- {data?.activeDays !== null && data?.activeDays !== undefined - ? `${data.activeDays} / 7` - : "— / 7"} + +
+ PRs + + {summary.prs.opened} opened / {summary.prs.merged} merged +
-
-
-
Streak
-
- {data?.streak !== null && data?.streak !== undefined - ? `${data.streak} days` - : "—"} + +
+ + Active days + + + {summary.activeDays} / 7 days +
-
-
-
Top Repo
-
- {formatRepoName(data?.mostActiveRepo ?? null)} + +
+ + Streak + + + {summary.streak} day streak + +
+ +
+ + Top repo + + + {summary.topRepo ?? "-"} +
-
- )} + ) : null)}
); } From 773b7ea4e60bd73aca05039e67c61120df79c9f1 Mon Sep 17 00:00:00 2001 From: codedbydollys10 Date: Sun, 17 May 2026 21:02:18 +0530 Subject: [PATCH 12/24] Added colour-blind & default friendly heatmap theme --- src/app/dashboard/page.tsx | 9 +- src/app/dashboard/settings/page.tsx | 36 +++++ src/components/ContributionHeatmap.tsx | 69 ++++++--- src/components/StreakTracker.tsx | 29 ++-- src/hooks/useHeatmapTheme.ts | 192 +++++++++++++++++++++++++ tsconfig.json | 35 ++++- 6 files changed, 326 insertions(+), 44 deletions(-) create mode 100644 src/hooks/useHeatmapTheme.ts diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index f5c13d89..757e5da9 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -14,6 +14,7 @@ import StreakAtRiskBanner from "@/components/StreakAtRiskBanner"; import FriendComparison from "@/components/FriendComparison"; import WeeklySummaryCard from "@/components/WeeklySummaryCard"; import ExportButton from "@/components/ExportButton"; +import Link from "next/link"; import PersonalRecords from "@/components/PersonalRecords"; import { authOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; @@ -29,7 +30,13 @@ export default async function DashboardPage() { return (
-
+
+ + Settings +
diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 31a311f2..e1dd7401 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -3,6 +3,7 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useSession } from "next-auth/react"; import { redirect, useSearchParams } from "next/navigation"; +import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; interface UserSettings { id: string; @@ -121,6 +122,8 @@ function SettingsPageContent() { [searchParams] ); + const { theme, setTheme } = useHeatmapTheme(); + // Redirect to signin if not authenticated useEffect(() => { if (status === "unauthenticated") { @@ -354,6 +357,39 @@ function SettingsPageContent() {
)} +
+

+ Heatmap colour scheme +

+

+ Choose a colour scheme for the contribution and streak heatmaps. +

+
+ + +
+
+ {!settings.is_public && (

diff --git a/src/components/ContributionHeatmap.tsx b/src/components/ContributionHeatmap.tsx index a6a57e5f..a56358e4 100644 --- a/src/components/ContributionHeatmap.tsx +++ b/src/components/ContributionHeatmap.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import type { CSSProperties } from "react"; +import { useHeatmapTheme, getHeatmapCellStyle } from "@/hooks/useHeatmapTheme"; interface ContributionHeatmapProps { days?: number; @@ -33,17 +34,6 @@ function formatDateKey(date: Date) { return `${year}-${month}-${day}`; } -function getHeatmapCellStyle(count: number): CSSProperties { - if (count === 0) { - return { backgroundColor: "var(--control)" }; - } - - const opacity = count >= 10 ? 1 : count >= 6 ? 0.75 : count >= 3 ? 0.5 : 0.25; - - return { - backgroundColor: `color-mix(in srgb, var(--accent) ${opacity * 100}%, transparent)`, - }; -} function buildHeatmap(days: number, contributions: Record) { const endDate = new Date(); @@ -130,6 +120,7 @@ export default function ContributionHeatmap({ days = DEFAULT_DAYS }: Contributio return () => clearInterval(interval); }, [lastUpdated]); + const { themeConfig, theme, setTheme } = useHeatmapTheme(); const cells = useMemo(() => buildHeatmap(days, data), [days, data]); const weekCount = Math.ceil(cells.length / 7); const monthMarkers = useMemo(() => { @@ -162,18 +153,54 @@ export default function ContributionHeatmap({ days = DEFAULT_DAYS }: Contributio return (

-
+

Contribution Heatmap

Last {days} days of commit activity.

+
+ + +
+
Less -
- {[0, 1, 3, 6, 10].map((count) => ( - - ))} +
+ {[0, 1, 3, 6, 10].map((count) => { + const swatch = + count === 0 + ? themeConfig.missed + : count < 3 + ? themeConfig.levelOne + : count < 6 + ? themeConfig.levelTwo + : count < 10 + ? themeConfig.levelThree + : themeConfig.levelFour; + + return ( + + ); + })}
More
@@ -229,8 +256,14 @@ export default function ContributionHeatmap({ days = DEFAULT_DAYS }: Contributio title={isFuture ? "" : tooltip} aria-label={isFuture ? `${cell.dateKey}: future date` : tooltip} disabled={isFuture} - className={`group relative z-0 h-3 w-3 rounded-[3px] border border-[var(--border)] transition-transform hover:z-20 hover:scale-110 focus:z-20 focus:outline-none focus:ring-2 focus:ring-[var(--accent)] disabled:cursor-default disabled:opacity-30 ${cell.inRange ? "" : "opacity-35"}`} - style={{ gridRow: dayIndex + 2, gridColumn: weekIndex + 2, ...getHeatmapCellStyle(isFuture ? 0 : cell.count) }} + className={`group relative z-0 h-3 w-3 rounded-[3px] border transition-transform hover:z-20 hover:scale-110 focus:z-20 focus:outline-none focus:ring-2 focus:ring-[var(--heatmap-focus-ring)] disabled:cursor-default disabled:opacity-30 ${cell.inRange ? "" : "opacity-35"}`} + style={{ + gridRow: dayIndex + 2, + gridColumn: weekIndex + 2, + backgroundColor: isFuture ? "transparent" : (cell.count === 0 ? themeConfig.missed : cell.count < 3 ? themeConfig.levelOne : cell.count < 6 ? themeConfig.levelTwo : cell.count < 10 ? themeConfig.levelThree : themeConfig.levelFour), + borderColor: themeConfig.border, + ["--heatmap-focus-ring" as any]: themeConfig.accent, + }} > {!isFuture && ( today; const isToday = dayData.date.toDateString() === today.toDateString(); - - let bgColor = "bg-white dark:bg-transparent"; - let borderColor = "border border-[var(--border)]"; - - if (!isFuture) { - if (commitCount > 0) { - bgColor = "bg-green-500"; - borderColor = "border border-green-600"; - } else { - bgColor = "bg-gray-500"; - borderColor = "border border-gray-600"; - } - } + const cellStyle = isFuture + ? { backgroundColor: "transparent", borderColor: themeConfig.border } + : getCalendarStyle(commitCount); const tooltipText = !isFuture ? `${dayData.date.toLocaleDateString("en-US", { @@ -503,13 +495,14 @@ function StreakCalendar({ contributions, currentMonth, onMonthChange }: StreakCa return (
{!isFuture && ( - + {dayData.dayOfMonth} )} @@ -526,15 +519,15 @@ function StreakCalendar({ contributions, currentMonth, onMonthChange }: StreakCa
-
+
Committed
-
+
Missed
-
+
Future
diff --git a/src/hooks/useHeatmapTheme.ts b/src/hooks/useHeatmapTheme.ts new file mode 100644 index 00000000..e4d55509 --- /dev/null +++ b/src/hooks/useHeatmapTheme.ts @@ -0,0 +1,192 @@ +"use client"; + +import { CSSProperties, useCallback, useEffect, useMemo, useState } from "react"; + +export type HeatmapTheme = "default" | "colour-blind-friendly"; + +export interface HeatmapThemeConfig { + accent: string; + secondary: string; + missed: string; + border: string; + text: string; + levelOne: string; + levelTwo: string; + levelThree: string; + levelFour: string; +} + +const STORAGE_KEY = "heatmap-theme"; + +const themeConfigs: Record = { + default: { + accent: "rgba(16, 185, 129, 1)", + secondary: "rgba(79, 70, 229, 1)", + missed: "rgba(148, 163, 184, 0.15)", + border: "rgba(148, 163, 184, 0.35)", + text: "var(--card-foreground)", + levelOne: "rgba(16, 185, 129, 0.35)", + levelTwo: "rgba(16, 185, 129, 0.55)", + levelThree: "rgba(79, 70, 229, 0.75)", + levelFour: "rgba(79, 70, 229, 1)", + }, + "colour-blind-friendly": { + accent: "rgba(0, 114, 178, 1)", + secondary: "rgba(230, 159, 0, 1)", + missed: "rgba(148, 163, 184, 0.15)", + border: "rgba(148, 163, 184, 0.35)", + text: "var(--foreground)", + levelOne: "rgba(59, 130, 246, 0.35)", + levelTwo: "rgba(59, 130, 246, 0.55)", + levelThree: "rgba(249, 115, 22, 0.75)", + levelFour: "rgba(249, 115, 22, 1)", + }, +}; + +export function getHeatmapThemeConfig(theme: HeatmapTheme): HeatmapThemeConfig { + return themeConfigs[theme] ?? themeConfigs.default; +} + +export function getHeatmapCellStyle(count: number, config: HeatmapThemeConfig): CSSProperties { + if (count === 0) { + return { + backgroundColor: config.missed, + borderColor: config.border, + }; + } + + if (count < 3) { + return { + backgroundColor: config.levelOne, + borderColor: config.border, + }; + } + + if (count < 6) { + return { + backgroundColor: config.levelTwo, + borderColor: config.border, + }; + } + + if (count < 10) { + return { + backgroundColor: config.levelThree, + borderColor: config.border, + }; + } + + return { + backgroundColor: config.levelFour, + borderColor: config.border, + }; +} + +export function getCalendarCellStyle(count: number, config: HeatmapThemeConfig): CSSProperties { + if (count === 0) { + return { + backgroundColor: config.missed, + borderColor: config.border, + }; + } + + if (count < 3) { + return { + backgroundColor: config.levelOne, + borderColor: config.border, + }; + } + + if (count < 6) { + return { + backgroundColor: config.levelTwo, + borderColor: config.border, + }; + } + + if (count < 10) { + return { + backgroundColor: config.levelThree, + borderColor: config.border, + }; + } + + return { + backgroundColor: config.levelFour, + borderColor: config.border, + }; +} + +export function useHeatmapTheme() { + const [theme, _setTheme] = useState("default"); + + // Initialize from localStorage on mount + useEffect(() => { + if (typeof window === "undefined") return; + + const saved = window.localStorage.getItem(STORAGE_KEY) as HeatmapTheme | null; + if (saved === "colour-blind-friendly") { + _setTheme(saved); + return; + } + + _setTheme(saved ?? "default"); + }, []); + + // Broadcast and persist theme changes + const setTheme = (t: HeatmapTheme) => { + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(STORAGE_KEY, t); + } catch {} + // notify other listeners in this window + window.dispatchEvent(new CustomEvent("heatmap-theme-changed", { detail: t })); + } + + _setTheme(t); + }; + + // Listen for theme changes from other components/tabs + useEffect(() => { + if (typeof window === "undefined") return; + + const onStorage = (ev: StorageEvent) => { + if (ev.key === STORAGE_KEY && typeof ev.newValue === "string") { + _setTheme(ev.newValue as HeatmapTheme); + } + }; + + const onCustom = (ev: Event) => { + const detail = (ev as CustomEvent).detail as HeatmapTheme | undefined; + if (detail) _setTheme(detail); + }; + + window.addEventListener("storage", onStorage); + window.addEventListener("heatmap-theme-changed", onCustom as EventListener); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("heatmap-theme-changed", onCustom as EventListener); + }; + }, []); + + const themeConfig = useMemo(() => getHeatmapThemeConfig(theme), [theme]); + + const getHeatmapStyle = useCallback( + (count: number) => getHeatmapCellStyle(count, themeConfig), + [themeConfig] + ); + + const getCalendarStyle = useCallback( + (count: number) => getCalendarCellStyle(count, themeConfig), + [themeConfig] + ); + + return { + theme, + setTheme, + themeConfig, + getHeatmapStyle, + getCalendarStyle, + }; +} diff --git a/tsconfig.json b/tsconfig.json index fe9a3486..d0fd9315 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -10,13 +14,30 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, - "plugins": [{ "name": "next" }], + "plugins": [ + { + "name": "next" + } + ], "paths": { - "@/*": ["./src/*"] - } + "@/*": [ + "./src/*" + ] + }, + "target": "ES2017" }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "client", "server"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "client", + "server" + ] } From f3b29746dfd5f881775592156c0a983e01767b73 Mon Sep 17 00:00:00 2001 From: vedika76 Date: Mon, 18 May 2026 15:23:10 +0530 Subject: [PATCH 13/24] feat(issues): add mostActiveRepo, assigned issues, and caching to Issue Metrics widget --- src/app/api/metrics/issues/route.ts | 1 + src/components/IssueMetrics.tsx | 10 ++++++---- src/lib/github.ts | 15 ++++++++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/app/api/metrics/issues/route.ts b/src/app/api/metrics/issues/route.ts index 1f623836..a94b544f 100644 --- a/src/app/api/metrics/issues/route.ts +++ b/src/app/api/metrics/issues/route.ts @@ -3,6 +3,7 @@ import { authOptions } from "@/lib/auth"; import { fetchIssuesMetrics } from "@/lib/github"; export const dynamic = "force-dynamic"; +export const revalidate=300; export async function GET() { const session = await getServerSession(authOptions); diff --git a/src/components/IssueMetrics.tsx b/src/components/IssueMetrics.tsx index 9b6d0f2d..a06cb675 100644 --- a/src/components/IssueMetrics.tsx +++ b/src/components/IssueMetrics.tsx @@ -8,6 +8,7 @@ interface IssueData { currentlyOpen: number; avgCloseTimeDays: number; trend: number; + mostActiveRepo: string | null; } export default function IssueMetrics() { @@ -40,6 +41,7 @@ export default function IssueMetrics() { { label: "Issues Closed (30d)", value: metrics.closed }, { label: "Currently Open", value: metrics.currentlyOpen }, { label: "Avg Close Time", value: `${metrics.avgCloseTimeDays}d` }, + { label: "Most Active Repo", value: metrics.mostActiveRepo ?? "—" }, ] : []; @@ -59,8 +61,8 @@ export default function IssueMetrics() { Issue Analytics {loading ? ( -
- {[1, 2, 3, 4].map((i) => ( +
+ {[1, 2, 3, 4, 5].map((i) => (
) : ( -
+
{stats.map((stat, idx) => (
-
+
{stat.value}
diff --git a/src/lib/github.ts b/src/lib/github.ts index 707e796d..b15cf838 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -45,6 +45,7 @@ export interface GitHubIssueItem { state: string; created_at: string; closed_at: string | null; + repository_url: string; } export interface IssuesMetrics { @@ -53,6 +54,7 @@ export interface IssuesMetrics { currentlyOpen: number; avgCloseTimeDays: number; trend: number; + mostActiveRepo: string | null; } export async function fetchIssuesMetrics( @@ -106,11 +108,22 @@ export async function fetchIssuesMetrics( const thisMonthCount = thisMonthRes.ok ? ((await thisMonthRes.json()) as { total_count: number }).total_count : 0; const lastMonthCount = lastMonthRes.ok ? ((await lastMonthRes.json()) as { total_count: number }).total_count : 0; + const repoCounts: Record = {}; + for (const item of items) { + const repo = item.repository_url.split("/").pop() ?? ""; + repoCounts[repo] = (repoCounts[repo] ?? 0) + 1; + } + const mostActiveRepo = + Object.keys(repoCounts).length > 0 + ? Object.entries(repoCounts).sort((a, b) => b[1] - a[1])[0][0] + : null; + return { opened, closed, currentlyOpen, avgCloseTimeDays, trend: thisMonthCount - lastMonthCount, + mostActiveRepo, }; -} +} \ No newline at end of file From be9be13647f74de6b8d273424b37d1f7d37ebe98 Mon Sep 17 00:00:00 2001 From: saurabhhhcodes <157192462+saurabhhhcodes@users.noreply.github.com> Date: Tue, 19 May 2026 00:42:22 +0530 Subject: [PATCH 14/24] Add Playwright E2E smoke tests --- .github/workflows/e2e.yml | 49 +++++++++ e2e/dashboard-widgets.spec.js | 200 ++++++++++++++++++++++++++++++++++ e2e/landing.spec.js | 21 ++++ e2e/public-profile.spec.js | 13 +++ playwright.config.mjs | 44 ++++++++ 5 files changed, 327 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/dashboard-widgets.spec.js create mode 100644 e2e/landing.spec.js create mode 100644 e2e/public-profile.spec.js create mode 100644 playwright.config.mjs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..9be35f72 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,49 @@ +name: E2E + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + workflow_dispatch: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + playwright: + name: Playwright smoke tests + runs-on: ubuntu-latest + env: + NEXTAUTH_SECRET: playwright-placeholder-secret-that-is-long-enough + NEXTAUTH_URL: http://127.0.0.1:3000 + NEXT_PUBLIC_APP_URL: http://127.0.0.1:3000 + GITHUB_ID: playwright-github-id + GITHUB_SECRET: playwright-github-secret + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key + SUPABASE_SERVICE_ROLE_KEY: placeholder-service-role-key + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install app dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx -y @playwright/test@1.49.1 install --with-deps chromium + + - name: Run Playwright tests + run: npx -y @playwright/test@1.49.1 test + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js new file mode 100644 index 00000000..482cf58e --- /dev/null +++ b/e2e/dashboard-widgets.spec.js @@ -0,0 +1,200 @@ +import { expect, test } from "@playwright/test"; +import { encode } from "next-auth/jwt"; + +const authSecret = "playwright-placeholder-secret-that-is-long-enough"; + +test.beforeEach(async ({ page }) => { + await page.context().addCookies([ + { + name: "next-auth.session-token", + value: await encode({ + secret: authSecret, + token: { + name: "Playwright User", + email: "playwright@example.com", + sub: "12345", + githubLogin: "playwright-user", + githubId: "12345", + accessToken: "test-token", + }, + maxAge: 60 * 60, + }), + domain: "127.0.0.1", + path: "/", + httpOnly: true, + sameSite: "Lax", + secure: false, + expires: Math.floor(Date.now() / 1000) + 60 * 60, + }, + ]); + + await page.route("**/api/auth/session", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + user: { name: "Playwright User", email: "playwright@example.com" }, + githubLogin: "playwright-user", + githubId: "12345", + accessToken: "test-token", + expires: "2099-01-01T00:00:00.000Z", + }), + }); + }); + + await page.route("**/api/user/settings", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ is_public: true }), + }); + }); + + await page.route("**/api/metrics/contributions**", async (route) => { + const url = new URL(route.request().url()); + const days = Number(url.searchParams.get("days") ?? 30); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + data: { + "2026-05-16": days >= 7 ? 3 : 1, + "2026-05-17": 5, + "2026-05-18": 2, + }, + }), + }); + }); + + await page.route("**/api/goals", async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + contentType: "application/json", + status: 201, + body: JSON.stringify({ ok: true }), + }); + return; + } + + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + goals: [ + { + id: "goal-1", + title: "Make 10 commits", + target: 10, + current: 4, + unit: "commits", + recurrence: "weekly", + period_start: "2026-05-18", + }, + ], + }), + }); + }); + + const metricRoutes = [ + "**/api/metrics/prs**", + "**/api/metrics/pr-breakdown**", + "**/api/metrics/issues**", + "**/api/metrics/repos**", + "**/api/metrics/languages**", + "**/api/metrics/streak**", + "**/api/metrics/pinned-repos**", + "**/api/metrics/weekly-summary**", + "**/api/metrics/compare**", + "**/api/metrics/repo-health**", + "**/api/streak/freeze**", + "**/api/user/github-accounts**", + ]; + + for (const pattern of metricRoutes) { + await page.route(pattern, async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify(mockMetricResponse(route.request().url())), + }); + }); + } +}); + +test("dashboard widgets render with mocked metrics", async ({ page }) => { + await page.goto("/dashboard"); + + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Commit Activity" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Weekly Goals" })).toBeVisible(); + await expect(page.getByText("Make 10 commits")).toBeVisible(); +}); + +test("contribution graph range buttons request a new range", async ({ page }) => { + const contributionRequests = []; + page.on("request", (request) => { + if (request.url().includes("/api/metrics/contributions")) { + contributionRequests.push(request.url()); + } + }); + + await page.goto("/dashboard"); + await page.getByRole("button", { name: "Show 90-day range" }).click(); + + await expect.poll(() => contributionRequests.some((url) => url.includes("days=90"))).toBe(true); +}); + +test("goal form posts a new goal", async ({ page }) => { + const goalPosts = []; + page.on("request", (request) => { + if (request.url().endsWith("/api/goals") && request.method() === "POST") { + goalPosts.push(request.postDataJSON()); + } + }); + + await page.goto("/dashboard"); + await page.getByLabel("Goal title").fill("Ship one PR"); + await page.getByLabel("Target").fill("1"); + await page.getByLabel("Unit").fill("PR"); + await page.getByRole("button", { name: "Add goal" }).click(); + + await expect.poll(() => goalPosts).toHaveLength(1); + expect(goalPosts[0]).toMatchObject({ + title: "Ship one PR", + target: 1, + unit: "PR", + }); +}); + +function mockMetricResponse(url) { + if (url.includes("/api/metrics/prs")) { + return { open: 2, merged: 8, avgReviewHours: 6, mergeRate: "80%" }; + } + if (url.includes("/api/metrics/pr-breakdown")) { + return { merged: 8, open: 2, closed: 1 }; + } + if (url.includes("/api/metrics/issues")) { + return { opened: 4, closed: 3, open: 1 }; + } + if (url.includes("/api/metrics/repos") || url.includes("/api/metrics/pinned-repos")) { + return { repos: [{ name: "demo/repo", commits: 12, url: "https://github.com/demo/repo" }] }; + } + if (url.includes("/api/metrics/languages")) { + return { languages: [{ language: "TypeScript", count: 12 }] }; + } + if (url.includes("/api/metrics/streak")) { + return { current: 3, longest: 9, lastCommitDate: "2026-05-18", totalActiveDays: 12 }; + } + if (url.includes("/api/metrics/weekly-summary")) { + return { commits: 10, pullRequests: 3, mergedPullRequests: 2 }; + } + if (url.includes("/api/metrics/compare")) { + return { user: { commits: 10 }, friend: { commits: 8 } }; + } + if (url.includes("/api/metrics/repo-health")) { + return { repositories: [] }; + } + if (url.includes("/api/streak/freeze")) { + return { freezes: [] }; + } + if (url.includes("/api/user/github-accounts")) { + return { accounts: [] }; + } + return {}; +} diff --git a/e2e/landing.spec.js b/e2e/landing.spec.js new file mode 100644 index 00000000..db1fd5d8 --- /dev/null +++ b/e2e/landing.spec.js @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; + +test("landing page renders GitHub sign-in entrypoint", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "DevTrack" })).toBeVisible(); + await expect( + page.getByRole("link", { name: "Sign in with GitHub" }), + ).toHaveAttribute("href", /\/api\/auth\/signin\/github\?callbackUrl=\/dashboard/); + await expect(page.getByRole("link", { name: "View on GitHub" })).toHaveAttribute( + "href", + "https://github.com/Priyanshu-byte-coder/devtrack", + ); +}); + +test("dashboard stays protected for unauthenticated users", async ({ page }) => { + await page.goto("/dashboard"); + + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole("link", { name: "Sign in with GitHub" })).toBeVisible(); +}); diff --git a/e2e/public-profile.spec.js b/e2e/public-profile.spec.js new file mode 100644 index 00000000..b950b8f1 --- /dev/null +++ b/e2e/public-profile.spec.js @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test("public profile route renders without requiring authentication", async ({ page }) => { + await page.goto("/u/playwright-user"); + + await expect(page).toHaveURL(/\/u\/playwright-user$/); + await expect( + page.getByRole("heading", { + name: /(@playwright-user's Profile|Profile Not Found)/, + }), + ).toBeVisible(); + await expect(page.getByRole("link", { name: "Sign in with GitHub" })).toHaveCount(0); +}); diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 00000000..672e20ed --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,44 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = Number(process.env.PORT ?? 3000); +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { + timeout: 8_000, + }, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + webServer: { + command: `node node_modules/next/dist/bin/next dev -H 127.0.0.1 -p ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + NEXTAUTH_SECRET: "playwright-placeholder-secret-that-is-long-enough", + NEXTAUTH_URL: baseURL, + NEXT_PUBLIC_APP_URL: baseURL, + GITHUB_ID: "playwright-github-id", + GITHUB_SECRET: "playwright-github-secret", + NEXT_PUBLIC_SUPABASE_URL: "https://placeholder.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "placeholder-anon-key", + SUPABASE_SERVICE_ROLE_KEY: "placeholder-service-role-key", + }, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); From ec24da9065fe878a2797e4171d7fc6804861d08a Mon Sep 17 00:00:00 2001 From: saurabhhhcodes <157192462+saurabhhhcodes@users.noreply.github.com> Date: Tue, 19 May 2026 11:24:24 +0530 Subject: [PATCH 15/24] fix: keep contribution tooltip visible on touch --- src/components/ContributionGraph.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/ContributionGraph.tsx b/src/components/ContributionGraph.tsx index 867e51c1..baa2c049 100644 --- a/src/components/ContributionGraph.tsx +++ b/src/components/ContributionGraph.tsx @@ -82,6 +82,7 @@ export default function ContributionGraph() { const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [error, setError] = useState(null); + const [usesTouchTooltip, setUsesTouchTooltip] = useState(false); // Compare mode state const [compareMode, setCompareMode] = useState(false); @@ -108,6 +109,18 @@ export default function ContributionGraph() { } }, []); + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + + const media = window.matchMedia("(hover: none), (pointer: coarse)"); + const updateTooltipMode = () => setUsesTouchTooltip(media.matches); + + updateTooltipMode(); + media.addEventListener("change", updateTooltipMode); + + return () => media.removeEventListener("change", updateTooltipMode); + }, []); + const handleRangeChange = (newDays: number) => { setDays(newDays); if (typeof window !== "undefined") { @@ -235,6 +248,7 @@ export default function ContributionGraph() { const displayData = compareMode ? mergedData : data; const hasFriendData = compareMode && friendData.length > 0 && !compareError; + const tooltipTrigger = usesTouchTooltip ? "click" : "hover"; return (
Date: Tue, 19 May 2026 11:15:33 +0530 Subject: [PATCH 16/24] feat: add GitHub push webhook refresh endpoint --- README.md | 12 ++ src/app/api/webhooks/github/route.ts | 162 +++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/app/api/webhooks/github/route.ts diff --git a/README.md b/README.md index 1b855dfb..f4264adf 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ NEXTAUTH_SECRET=run_openssl_rand_base64_32 GITHUB_ID=your_client_id GITHUB_SECRET=your_client_secret +GITHUB_WEBHOOK_SECRET=your_random_webhook_secret ``` ### 5. Run @@ -133,6 +134,17 @@ npm run dev Visit `http://localhost:3000`. +### GitHub Webhook Refresh + +DevTrack can accept GitHub push webhooks at `/api/webhooks/github` to mark a user's metrics for refresh as soon as new commits land. + +1. Generate `GITHUB_WEBHOOK_SECRET` and add it to your deployment environment. +2. In the GitHub repository, open **Settings -> Webhooks -> Add webhook**. +3. Set the payload URL to `{NEXTAUTH_URL}/api/webhooks/github`. +4. Set content type to `application/json`, paste the same secret, and select the **Push** event. + +Webhook requests are verified with GitHub's `X-Hub-Signature-256` HMAC header before DevTrack touches user metrics. + --- ## Contributing diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts new file mode 100644 index 00000000..d63ffa7e --- /dev/null +++ b/src/app/api/webhooks/github/route.ts @@ -0,0 +1,162 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import { revalidatePath } from "next/cache"; +import { NextRequest, NextResponse } from "next/server"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +const SIGNATURE_HEADER = "x-hub-signature-256"; +const GITHUB_EVENT_HEADER = "x-github-event"; + +interface GitHubPushPayload { + after?: string; + commits?: Array; + pusher?: { + name?: string; + }; + repository?: { + full_name?: string; + }; + sender?: { + login?: string; + }; +} + +function getExpectedSignature(secret: string, body: string): string { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +function safeCompare(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + + if (left.length !== right.length) { + return false; + } + + return timingSafeEqual(left, right); +} + +function verifyGitHubSignature( + body: string, + signature: string | null, + secret: string +): boolean { + if (!signature?.startsWith("sha256=")) { + return false; + } + + return safeCompare(signature, getExpectedSignature(secret, body)); +} + +function getPushActor(payload: GitHubPushPayload): string | null { + return payload.sender?.login ?? payload.pusher?.name ?? null; +} + +async function markUserMetricsStale(githubLogin: string) { + const updatedAt = new Date().toISOString(); + + const { data: primaryUser, error: primaryError } = await supabaseAdmin + .from("users") + .update({ updated_at: updatedAt }) + .eq("github_login", githubLogin) + .select("id") + .maybeSingle(); + + if (primaryError) { + throw primaryError; + } + + if (primaryUser) { + return { userId: primaryUser.id as string, accountType: "primary" }; + } + + const { data: linkedAccount, error: linkedError } = await supabaseAdmin + .from("user_github_accounts") + .select("user_id") + .eq("github_login", githubLogin) + .maybeSingle(); + + if (linkedError) { + throw linkedError; + } + + if (!linkedAccount?.user_id) { + return null; + } + + const { error: updateError } = await supabaseAdmin + .from("users") + .update({ updated_at: updatedAt }) + .eq("id", linkedAccount.user_id); + + if (updateError) { + throw updateError; + } + + return { userId: linkedAccount.user_id as string, accountType: "linked" }; +} + +export async function POST(req: NextRequest) { + const secret = process.env.GITHUB_WEBHOOK_SECRET; + + if (!secret) { + return NextResponse.json( + { error: "GitHub webhook secret is not configured" }, + { status: 500 } + ); + } + + const body = await req.text(); + const signature = req.headers.get(SIGNATURE_HEADER); + + if (!verifyGitHubSignature(body, signature, secret)) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + const event = req.headers.get(GITHUB_EVENT_HEADER); + if (event !== "push") { + return NextResponse.json({ received: true, ignored: true, event }); + } + + let payload: GitHubPushPayload; + try { + payload = JSON.parse(body) as GitHubPushPayload; + } catch { + return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + const githubLogin = getPushActor(payload); + if (!githubLogin) { + return NextResponse.json( + { received: true, userMatched: false, reason: "Missing GitHub actor" }, + { status: 200 } + ); + } + + let staleResult: Awaited>; + try { + staleResult = await markUserMetricsStale(githubLogin); + } catch (error) { + console.error("Failed to mark GitHub metrics stale:", error); + return NextResponse.json( + { error: "Failed to trigger metric refresh" }, + { status: 500 } + ); + } + + if (staleResult) { + revalidatePath(`/u/${githubLogin}`); + revalidatePath("/dashboard"); + } + + return NextResponse.json({ + received: true, + userMatched: Boolean(staleResult), + accountType: staleResult?.accountType ?? null, + githubLogin, + repository: payload.repository?.full_name ?? null, + after: payload.after ?? null, + commitCount: payload.commits?.length ?? 0, + }); +} From 5e6c6da5b722feb134e8caaaf18d2ee3398d8f68 Mon Sep 17 00:00:00 2001 From: saurabhhhcodes <157192462+saurabhhhcodes@users.noreply.github.com> Date: Tue, 19 May 2026 11:20:36 +0530 Subject: [PATCH 17/24] feat: add CI analytics dashboard widget --- src/app/api/metrics/ci/route.ts | 305 ++++++++++++++++++++++++++++++++ src/app/dashboard/page.tsx | 10 +- src/components/CIAnalytics.tsx | 129 ++++++++++++++ 3 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 src/app/api/metrics/ci/route.ts create mode 100644 src/components/CIAnalytics.tsx diff --git a/src/app/api/metrics/ci/route.ts b/src/app/api/metrics/ci/route.ts new file mode 100644 index 00000000..1437e206 --- /dev/null +++ b/src/app/api/metrics/ci/route.ts @@ -0,0 +1,305 @@ +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 { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +interface TopRepo { + name: string; + commits: number; +} + +interface WorkflowRun { + conclusion: string | null; + created_at: string; + name: string | null; + updated_at: string; +} + +interface WorkflowStats { + failures: number; + total: number; +} + +interface CIAnalyticsResponse { + successRate: number; + averageDurationMinutes: number; + flakiestWorkflow: string | null; + totalRuns: number; + reposChecked: number; +} + +function toIsoDate(daysAgo: number): string { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + return date.toISOString().slice(0, 10); +} + +function getRunDurationMinutes(run: WorkflowRun): number { + const created = new Date(run.created_at).getTime(); + const updated = new Date(run.updated_at).getTime(); + + if (Number.isNaN(created) || Number.isNaN(updated) || updated < created) { + return 0; + } + + return (updated - created) / 60000; +} + +function mergeCIAnalytics( + a: CIAnalyticsResponse, + b: CIAnalyticsResponse +): CIAnalyticsResponse { + const totalRuns = a.totalRuns + b.totalRuns; + const weightedDuration = + totalRuns === 0 + ? 0 + : (a.averageDurationMinutes * a.totalRuns + + b.averageDurationMinutes * b.totalRuns) / + totalRuns; + const successes = + Math.round((a.successRate / 100) * a.totalRuns) + + Math.round((b.successRate / 100) * b.totalRuns); + + return { + successRate: totalRuns === 0 ? 0 : Math.round((successes / totalRuns) * 100), + averageDurationMinutes: Math.round(weightedDuration * 10) / 10, + flakiestWorkflow: a.flakiestWorkflow ?? b.flakiestWorkflow, + totalRuns, + reposChecked: a.reposChecked + b.reposChecked, + }; +} + +async function fetchTopRepos( + token: string, + githubLogin: string +): Promise { + const since = toIsoDate(30); + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${since}&per_page=100&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!searchRes.ok) { + throw new Error("GitHub API error"); + } + + const data = (await searchRes.json()) as { + items: Array<{ repository: { full_name: string } }>; + }; + const repoMap = new Map(); + + for (const item of data.items) { + const name = item.repository.full_name; + repoMap.set(name, (repoMap.get(name) ?? 0) + 1); + } + + return Array.from(repoMap.entries()) + .map(([name, commits]) => ({ name, commits })) + .sort((a, b) => b.commits - a.commits) + .slice(0, 5); +} + +async function fetchWorkflowRuns( + token: string, + repo: string +): Promise { + const created = toIsoDate(30); + const params = new URLSearchParams({ + per_page: "100", + created: `>=${created}`, + }); + const res = await fetch( + `${GITHUB_API}/repos/${repo}/actions/runs?${params.toString()}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (res.status === 404 || res.status === 403) { + return []; + } + + if (!res.ok) { + throw new Error("GitHub API error"); + } + + const data = (await res.json()) as { workflow_runs?: WorkflowRun[] }; + return data.workflow_runs ?? []; +} + +function aggregateRuns( + repos: TopRepo[], + runsByRepo: WorkflowRun[][] +): CIAnalyticsResponse { + const runs = runsByRepo.flat(); + const completedRuns = runs.filter((run) => run.conclusion); + const successfulRuns = completedRuns.filter( + (run) => run.conclusion === "success" + ); + const workflowStats = new Map(); + + for (const run of completedRuns) { + const name = run.name ?? "Unnamed workflow"; + const stats = workflowStats.get(name) ?? { failures: 0, total: 0 }; + stats.total += 1; + if (run.conclusion !== "success") { + stats.failures += 1; + } + workflowStats.set(name, stats); + } + + const flakiestWorkflow = + Array.from(workflowStats.entries()) + .filter(([, stats]) => stats.failures > 0) + .sort((a, b) => { + const aRate = a[1].failures / a[1].total; + const bRate = b[1].failures / b[1].total; + return bRate - aRate || b[1].failures - a[1].failures; + })[0]?.[0] ?? null; + + const totalDuration = completedRuns.reduce( + (sum, run) => sum + getRunDurationMinutes(run), + 0 + ); + + return { + successRate: + completedRuns.length === 0 + ? 0 + : Math.round((successfulRuns.length / completedRuns.length) * 100), + averageDurationMinutes: + completedRuns.length === 0 + ? 0 + : Math.round((totalDuration / completedRuns.length) * 10) / 10, + flakiestWorkflow, + totalRuns: runs.length, + reposChecked: repos.length, + }; +} + +async function fetchCIAnalyticsForAccount( + token: string, + githubLogin: string +): Promise { + const repos = await fetchTopRepos(token, githubLogin); + const runsByRepo = await Promise.all( + repos.map((repo) => fetchWorkflowRuns(token, repo.name)) + ); + + return aggregateRuns(repos, runsByRepo); +} + +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"); + + if (!accountId) { + try { + const result = await fetchCIAnalyticsForAccount( + session.accessToken, + session.githubLogin + ); + return Response.json(result); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + if (!session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { data: userRow } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + 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) => + fetchCIAnalyticsForAccount(account.token, account.githubLogin) + ) + ); + const merged = mergeMetrics(results, mergeCIAnalytics); + + if (!merged) { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + + return Response.json(merged); + } + + if (accountId === session.githubId) { + try { + const result = await fetchCIAnalyticsForAccount( + session.accessToken, + session.githubLogin + ); + return Response.json(result); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + const accountToken = await getAccountToken(userRow.id, accountId); + + if (!accountToken) { + 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 fetchCIAnalyticsForAccount( + accountToken, + accountRow.github_login + ); + return Response.json(result); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 757e5da9..23f9881f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -9,6 +9,7 @@ import TopRepos from "@/components/TopRepos"; import PinnedRepos from "@/components/PinnedRepos"; import LanguageBreakdown from "@/components/LanguageBreakdown"; import CommitTimeChart from "@/components/CommitTimeChart"; +import CIAnalytics from "@/components/CIAnalytics"; import IssueMetrics from "@/components/IssueMetrics"; import StreakAtRiskBanner from "@/components/StreakAtRiskBanner"; import FriendComparison from "@/components/FriendComparison"; @@ -71,9 +72,12 @@ export default async function DashboardPage() {
- {/* Row 3: Issue metrics */} -
- + {/* Row 3: Issue metrics + CI analytics */} +
+
+ +
+
{/* Row 4: Pinned repositories */} diff --git a/src/components/CIAnalytics.tsx b/src/components/CIAnalytics.tsx new file mode 100644 index 00000000..d900942e --- /dev/null +++ b/src/components/CIAnalytics.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useAccount } from "@/components/AccountContext"; + +interface CIAnalyticsData { + successRate: number; + averageDurationMinutes: number; + flakiestWorkflow: string | null; + totalRuns: number; + reposChecked: number; +} + +export default function CIAnalytics() { + const { selectedAccount } = useAccount(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchCIAnalytics = useCallback(() => { + setLoading(true); + setError(null); + + const accountParam = + selectedAccount !== null + ? `?accountId=${encodeURIComponent(selectedAccount)}` + : ""; + + fetch(`/api/metrics/ci${accountParam}`) + .then((res) => { + if (!res.ok) { + throw new Error("API error"); + } + return res.json(); + }) + .then((payload: CIAnalyticsData) => setData(payload)) + .catch(() => + setError("CI data unavailable - ensure Actions are enabled on your repos") + ) + .finally(() => setLoading(false)); + }, [selectedAccount]); + + useEffect(() => { + fetchCIAnalytics(); + }, [fetchCIAnalytics]); + + const stats = data + ? [ + { label: "Success Rate", value: `${data.successRate}%` }, + { label: "Avg Duration", value: `${data.averageDurationMinutes}m` }, + { label: "Runs (30d)", value: data.totalRuns }, + { label: "Repos Checked", value: data.reposChecked }, + ] + : []; + + return ( +
+
+
+

+ CI Analytics +

+

+ GitHub Actions health across your top repositories +

+
+ +
+ + {loading ? ( +
+ {[1, 2, 3, 4].map((item) => ( +
+ ))} +
+ ) : error ? ( +
+

{error}

+ +
+ ) : data ? ( +
+
+ {stats.map((stat) => ( +
+
+ {stat.value} +
+
+ {stat.label} +
+
+ ))} +
+ +
+

+ Flakiest workflow +

+

+ {data.flakiestWorkflow ?? "No failing workflows in this window"} +

+
+
+ ) : null} +
+ ); +} From 0eb695f68910bb22c5dfb5077b75e94997e30392 Mon Sep 17 00:00:00 2001 From: Sanchit Rishi Date: Sun, 17 May 2026 02:24:00 +0530 Subject: [PATCH 18/24] fix: align repo health score with days filter and add empty state (#83) --- src/app/api/metrics/repo-health/route.ts | 25 +++++++++++++++--------- src/components/TopRepos.tsx | 13 +++++++++--- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/app/api/metrics/repo-health/route.ts b/src/app/api/metrics/repo-health/route.ts index abad6a45..75a6a7e6 100644 --- a/src/app/api/metrics/repo-health/route.ts +++ b/src/app/api/metrics/repo-health/route.ts @@ -95,17 +95,18 @@ async function fetchJson(url: string, token: string, accept?: string): Promis async function fetchSignalsForRepo( token: string, - repoFullName: string + repoFullName: string, + days: number ): Promise { - const since30 = new Date(); - since30.setDate(since30.getDate() - 30); - const since30Str = toDateStr(since30); + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0]; // a) commit frequency in last 30 days (sampled to 100 via per_page=100) const commitSearch = await fetchJson<{ items: unknown[]; }>( - `${GITHUB_API}/search/commits?q=repo:${repoFullName}+committer-date:>${since30Str}&per_page=100&sort=committer-date&order=desc`, + `${GITHUB_API}/search/commits?q=repo:${repoFullName}+committer-date:>${since}&per_page=100&sort=committer-date&order=desc`, token, "application/vnd.github+json" ); @@ -116,14 +117,14 @@ async function fetchSignalsForRepo( total_count: number; items: Array<{ created_at: string; closed_at: string | null }>; }>( - `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+created:>${since30Str}&per_page=100&sort=created&order=desc`, + `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+created:>${since}&per_page=100&sort=created&order=desc`, token ); const mergedPrs = await fetchJson<{ total_count: number; }>( - `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+is:merged+merged:>${since30Str}&per_page=100&sort=updated&order=desc`, + `${GITHUB_API}/search/issues?q=repo:${repoFullName}+type:pr+is:merged+merged:>${since}&per_page=100&sort=updated&order=desc`, token ); @@ -170,10 +171,16 @@ export async function GET(req: NextRequest) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } + const requestedDays = parseInt( + req.nextUrl.searchParams.get("days") ?? "30", 10 + ); + const days = requestedDays === 7 || requestedDays === 30 + || requestedDays === 90 ? requestedDays : 30; + // 1) Determine top repos (top 6 by commit count). let topRepos: RepoSummary[] = []; try { - topRepos = (await fetchReposForAccount(session.accessToken, session.githubLogin, 30)).repos; + topRepos = (await fetchReposForAccount(session.accessToken, session.githubLogin, days)).repos; } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } @@ -183,7 +190,7 @@ export async function GET(req: NextRequest) { // 2) Fetch per-repo signals sequentially to preserve rate limits. for (const repo of topRepos) { try { - const signals = await fetchSignalsForRepo(session.accessToken, repo.name); + const signals = await fetchSignalsForRepo(session.accessToken, repo.name, days); scores.push(computeHealthScore(repo.name, signals)); } catch { // Skip repo on any failure. diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx index cf934133..cbe89f31 100644 --- a/src/components/TopRepos.tsx +++ b/src/components/TopRepos.tsx @@ -45,7 +45,7 @@ export default function TopRepos() { const accountParam = selectedAccount !== null ? `?accountId=${encodeURIComponent(selectedAccount)}` : ""; - fetch(`/api/metrics/repo-health${accountParam}`) + fetch(`/api/metrics/repo-health${accountParam}${accountParam ? "&" : "?"}days=${days}`) .then((r) => r.json()) .then((d: { repos: RepoHealthScore[] }) => { const map: Record = {}; @@ -56,7 +56,7 @@ export default function TopRepos() { }) .catch(() => setHealthScores({})) .finally(() => setHealthLoading(false)); - }, [selectedAccount]); + }, [days, selectedAccount]); useEffect(() => { if (!lastUpdated) return; @@ -204,7 +204,14 @@ export default function TopRepos() { > {health.score} - ) : null} + ) : ( + + -- + + )} {repo.commits} commit{repo.commits !== 1 ? "s" : ""} From 5f5079df0af7c1525fa1ba5f96790bd1ce15c720 Mon Sep 17 00:00:00 2001 From: Vijay Patil Date: Sun, 17 May 2026 10:38:20 +0530 Subject: [PATCH 19/24] feat(profile): add downloadable OG stats card (#169) --- package-lock.json | 7 + package.json | 1 + src/app/u/[username]/page.tsx | 29 ++- src/components/StatsCard.tsx | 349 ++++++++++++++++++++++++++++++++++ 4 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 src/components/StatsCard.tsx diff --git a/package-lock.json b/package-lock.json index df0ab101..56dea05c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@supabase/supabase-js": "^2.43.4", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "html-to-image": "^1.11.13", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.7", "next": "^14.2.35", @@ -3512,6 +3513,12 @@ "node": ">= 0.4" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", diff --git a/package.json b/package.json index 807987b6..af35b0c1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@supabase/supabase-js": "^2.43.4", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "html-to-image": "^1.11.13", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.7", "next": "^14.2.35", diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx index b7dff3af..c587a714 100644 --- a/src/app/u/[username]/page.tsx +++ b/src/app/u/[username]/page.tsx @@ -3,6 +3,7 @@ import BadgeSection from "@/components/BadgeSection"; import ContributionGraph from "@/components/ContributionGraph"; import StreakTracker from "@/components/StreakTracker"; import TopRepos from "@/components/TopRepos"; +import StatsCard from "@/components/StatsCard"; interface PublicProfileData { username: string; @@ -107,16 +108,30 @@ export default async function PublicProfilePage({ ); } + const avatarUrl = `https://avatars.githubusercontent.com/${profile.username}`; + const topRepo = profile.repos[0]?.name ?? ""; + return (
{/* Header */} -
-

- @{profile.username}'s Profile -

-

- GitHub activity and coding stats -

+
+
+

+ @{profile.username}'s Profile +

+

+ GitHub activity and coding stats +

+
+ {/* Download stats card button — client component */} +
{/* Row 1: Contribution graph + Streak */} diff --git a/src/components/StatsCard.tsx b/src/components/StatsCard.tsx new file mode 100644 index 00000000..7fd52f8e --- /dev/null +++ b/src/components/StatsCard.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useRef, useState } from "react"; +import { toPng } from "html-to-image"; + +interface StatsCardProps { + username: string; + avatarUrl: string; + currentStreak: number; + longestStreak: number; + totalCommits: number; + topRepo: string; +} + +/** Renders a 1200×630 OG-style stats card and triggers a PNG download. */ +export default function StatsCard({ + username, + avatarUrl, + currentStreak, + longestStreak, + totalCommits, + topRepo, +}: StatsCardProps) { + const cardRef = useRef(null); + const [generating, setGenerating] = useState(false); + + async function handleDownload() { + if (!cardRef.current) return; + setGenerating(true); + + try { + const dataUrl = await toPng(cardRef.current, { + width: 1200, + height: 630, + pixelRatio: 2, + style: { + // Make sure the off-screen card renders with correct dimensions + transform: "none", + }, + }); + + const link = document.createElement("a"); + link.download = `devtrack-${username}.png`; + link.href = dataUrl; + link.click(); + } catch (err) { + console.error("[StatsCard] Failed to generate image:", err); + } finally { + setGenerating(false); + } + } + + return ( + <> + {/* ── Download button ───────────────────────────────────────── */} + + + {/* ── Off-screen card (1200×630) ────────────────────────────── */} + {/* + Positioned absolutely off-screen so it doesn't affect page layout + but is still rendered in the DOM for html-to-image to capture. + */} +