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/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/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). 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/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/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/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/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/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, + }); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9e4bdb66..a1e69a5e 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -14,7 +14,6 @@ import StreakAtRiskBanner from "@/components/StreakAtRiskBanner"; import FriendComparison from "@/components/FriendComparison"; import WeeklySummaryCard from "@/components/WeeklySummaryCard"; import ExportButton from "@/components/ExportButton"; -import PersonalRecords from "@/components/PersonalRecords"; import { authOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; @@ -36,10 +35,6 @@ export default async function DashboardPage() { -
- -
- {/* Row 1: Contribution graph + Streak + Friend Comparison */}
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/ContributionGraph.tsx b/src/components/ContributionGraph.tsx index cda13878..4d860d78 100644 --- a/src/components/ContributionGraph.tsx +++ b/src/components/ContributionGraph.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import RateLimitBanner from "./RateLimitBanner"; import { BarChart, Bar, @@ -12,7 +13,6 @@ import { CartesianGrid, Tooltip, ResponsiveContainer, - Legend, } from "recharts"; interface DayData { @@ -20,16 +20,11 @@ interface DayData { commits: number; } -interface GraphPoint { - date: string; - you: number; - friend: number; -} - type ViewMode = "bar" | "line"; const RANGES = [ { label: "7d", days: 7 }, + { label: "14d", days: 14 }, { label: "30d", days: 30 }, { label: "90d", days: 90 }, ]; @@ -39,37 +34,6 @@ const charts: { key: ViewMode; label: string }[] = [ { key: "line", label: "Line" }, ]; -function mergeContributionData( - myData: DayData[], - friendData: DayData[] -): GraphPoint[] { - const map = new Map(); - - myData.forEach(d => { - map.set(d.day, { - date: d.day, - you: d.commits, - friend: 0, - }); - }); - - friendData.forEach(d => { - if (!map.has(d.day)) { - map.set(d.day, { - date: d.day, - you: 0, - friend: d.commits, - }); - } else { - map.get(d.day)!.friend = d.commits; - } - }); - - return Array.from(map.values()).sort((a, b) => - a.date.localeCompare(b.date) - ); -} - export default function ContributionGraph() { const { selectedAccount } = useAccount(); const [data, setData] = useState([]); @@ -79,50 +43,23 @@ export default function ContributionGraph() { const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [error, setError] = useState(null); - - // Compare mode state - const [compareMode, setCompareMode] = useState(false); - const [compareUser, setCompareUser] = useState(null); - const [friendData, setFriendData] = useState([]); - const [compareError, setCompareError] = useState(null); - const [compareLoading, setCompareLoading] = useState(false); - const [compareRequestId, setCompareRequestId] = useState(0); - - // Fetch my data - useEffect(() => { - if (typeof window !== "undefined") { - try { - const stored = localStorage.getItem("devtrack:contribution-range"); - if (stored === "7" || stored === "30" || stored === "90" || stored === "365") { - setDays(Number(stored)); - } else { - localStorage.setItem("devtrack:contribution-range", "30"); - setDays(30); - } - } catch { - setDays(30); - } - } - }, []); - - const handleRangeChange = (newDays: number) => { - setDays(newDays); - if (typeof window !== "undefined") { - try { - localStorage.setItem("devtrack:contribution-range", String(newDays)); - } catch {} - } - }; + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); useEffect(() => { setLoading(true); setError(null); + setRateLimitResetAt(null); const accountParam = selectedAccount !== null ? `&accountId=${encodeURIComponent(selectedAccount)}` : ""; fetch(`/api/metrics/contributions?days=${days}${accountParam}`) - .then((r) => { + .then(async (r) => { + if (r.status === 429) { + const d = (await r.json()) as { error: string; resetAt: number }; + setRateLimitResetAt(d.resetAt); + throw new Error("Rate limited"); + } if (!r.ok) throw new Error("API error"); return r.json(); }) @@ -130,10 +67,13 @@ export default function ContributionGraph() { const sorted = Object.entries(res.data ?? {}) .sort(([a], [b]) => a.localeCompare(b)) .map(([day, commits]) => ({ day, commits })); + setData(sorted); }) - .catch(() => { - setError("Failed to load contribution data."); + .catch((err) => { + if (err.message !== "Rate limited") { + setError("Failed to load contribution data."); + } }) .finally(() => { setLoading(false); @@ -142,64 +82,6 @@ export default function ContributionGraph() { }); }, [days, selectedAccount]); - // Fetch friend data when compare mode is on and compareUser changes - useEffect(() => { - if (!compareMode || !compareUser) { - setFriendData([]); - setCompareError(null); - return; - } - - setCompareLoading(true); - setCompareError(null); - - fetch(`/api/metrics/contributions?days=${days}&username=${encodeURIComponent(compareUser)}`) - .then((r) => { - if (!r.ok) throw new Error("Failed to fetch friend data"); - return r.json(); - }) - .then((res: { data: Record }) => { - const sorted = Object.entries(res.data ?? {}) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, commits]) => ({ day, commits })); - setFriendData(sorted); - }) - .catch(() => { - setCompareError("Failed to load friend data"); - setFriendData([]); - }) - .finally(() => { - setCompareLoading(false); - }); - }, [compareMode, compareUser, days, compareRequestId]); - - useEffect(() => { - const onCompareUser = (event: Event) => { - const customEvent = event as CustomEvent<{ username?: string }>; - const username = customEvent.detail?.username?.trim(); - if (!username) return; - setCompareUser(username); - setCompareMode(true); - setCompareError(null); - setCompareRequestId((prev) => prev + 1); - }; - - const onClearCompareUser = () => { - setCompareMode(false); - setCompareUser(null); - setFriendData([]); - setCompareError(null); - }; - - window.addEventListener("devtrack:compare-user", onCompareUser as EventListener); - window.addEventListener("devtrack:clear-compare-user", onClearCompareUser); - - return () => { - window.removeEventListener("devtrack:compare-user", onCompareUser as EventListener); - window.removeEventListener("devtrack:clear-compare-user", onClearCompareUser); - }; - }, []); - useEffect(() => { if (!lastUpdated) return; const interval = setInterval(() => { @@ -209,54 +91,26 @@ export default function ContributionGraph() { return () => clearInterval(interval); }, [lastUpdated]); - const handleClearCompare = () => { - window.dispatchEvent(new Event("devtrack:clear-compare-user")); - setCompareMode(false); - setCompareUser(null); - setFriendData([]); - setCompareError(null); - }; - - const mergedData = - compareMode && data.length > 0 - ? mergeContributionData(data, friendData) - : []; - - const displayData = compareMode ? mergedData : data; - const hasFriendData = compareMode && friendData.length > 0 && !compareError; - return ( -

+
-
-

- {compareMode && compareUser ? `You vs ${compareUser}` : "Your Commits"} -

- {compareMode && compareError && ( -

{compareError}

- )} - {compareMode && compareLoading && ( -

Loading friend data...

- )} -
+

+ Commit Activity +

- {/* Range buttons */} -
+ +
{RANGES.map((r) => ( @@ -264,11 +118,11 @@ export default function ContributionGraph() {
{/* Chart Toggle Buttons */} - {displayData.length > 0 && !error && ( + {data.length > 0 && !error && (
{charts.map((chart) => ( ))}
)} - - {/* Clear compare button */} - {compareMode && ( - - )}
{loading ? ( -
+
+ ) : rateLimitResetAt ? ( + ) : error ? ( -
-

+

+

{error} Please try refreshing.

- ) : displayData.length === 0 ? ( -

+ ) : data.length === 0 ? ( +

No commits in the last {days} days.

) : ( -
- + {chartType === "bar" ? ( - + - + + - {hasFriendData && ( - - )} - {compareMode && hasFriendData ? ( - <> - - - - ) : ( - - )} ) : ( - + - + + - {hasFriendData && ( - - )} - {compareMode && hasFriendData ? ( - <> - - - - ) : ( - - )} )} -
)} - - {lastUpdated && !compareMode && ( + {lastUpdated && (

{minutesAgo === 0 ? "Updated just now" : `Updated ${minutesAgo} min ago`}

)} - - {compareMode && compareUser && !compareLoading && !compareError && ( -

- Comparing with {compareUser} -

- )}
); } 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 && ( )} + 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/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() { (null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); const fetchMetrics = useCallback(() => { setLoading(true); setError(null); + setRateLimitResetAt(null); const url = selectedAccount !== null @@ -26,12 +29,21 @@ export default function PRMetrics() { : "/api/metrics/prs"; fetch(url) - .then((r) => { + .then(async (r) => { + if (r.status === 429) { + const d = (await r.json()) as { error: string; resetAt: number }; + setRateLimitResetAt(d.resetAt); + throw new Error("Rate limited"); + } if (!r.ok) throw new Error("API error"); return r.json(); }) .then((data: PRData) => setMetrics(data)) - .catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment.")) + .catch((err) => { + if (err.message !== "Rate limited") { + setError("We couldn't load your PR analytics right now. Please try again in a moment."); + } + }) .finally(() => setLoading(false)); }, [selectedAccount]); @@ -60,6 +72,8 @@ export default function PRMetrics() { /> ))}
+ ) : rateLimitResetAt ? ( + ) : error ? (

{error}

diff --git a/src/components/RateLimitBanner.tsx b/src/components/RateLimitBanner.tsx new file mode 100644 index 00000000..695f5455 --- /dev/null +++ b/src/components/RateLimitBanner.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +interface RateLimitBannerProps { + resetAt: number; +} + +function toResetDate(resetAt: number): Date { + // GitHub reset timestamps are usually epoch seconds; support ms as well. + const ms = resetAt < 1_000_000_000_000 ? resetAt * 1000 : resetAt; + return new Date(ms); +} + +function formatRemaining(ms: number): string { + if (ms <= 0) return "a few seconds"; + + const totalSeconds = Math.ceil(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes <= 0) return `${seconds}s`; + if (minutes < 60) return `${minutes}m ${seconds}s`; + + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + return `${hours}h ${remMinutes}m`; +} + +export default function RateLimitBanner({ resetAt }: RateLimitBannerProps) { + const resetDate = useMemo(() => toResetDate(resetAt), [resetAt]); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + const remaining = resetDate.getTime() - now; + + return ( +
+

GitHub API rate limit reached.

+

+ Try again in {formatRemaining(remaining)} + {" "}(resets at {resetDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}). +

+
+ ); +} 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} + +
+ ))} +
+ +
+ +
+
+
+ ); +} 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. + */} +