diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index d06aca7b2..977b56e92 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -15,6 +15,11 @@ on: - "playwright.visual.config.mjs" - ".github/workflows/visual-regression.yml" workflow_dispatch: + inputs: + update_snapshots: + description: "Regenerate baselines and upload them as an artifact (for intentional UI changes)" + type: boolean + default: false permissions: contents: read @@ -64,8 +69,21 @@ jobs: run: pnpm run build - name: Run visual regression tests + if: ${{ !inputs.update_snapshots }} run: npx playwright test -c playwright.visual.config.mjs + - name: Regenerate baselines + if: ${{ inputs.update_snapshots }} + run: npx playwright test -c playwright.visual.config.mjs --update-snapshots=all + + - name: Upload regenerated baselines + if: ${{ inputs.update_snapshots }} + uses: actions/upload-artifact@v7 + with: + name: visual-baselines + path: tests/snapshots/ + retention-days: 7 + - name: Upload visual regression artifacts uses: actions/upload-artifact@v7 if: failure() diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js index c989e4102..a69a1b71a 100644 --- a/e2e/dashboard-widgets.spec.js +++ b/e2e/dashboard-widgets.spec.js @@ -229,16 +229,25 @@ test("dashboard widgets render with mocked metrics", async ({ page }) => { await expect( page.getByRole("heading", { name: "Dashboard", exact: true }) ).toBeVisible({ timeout: 30000 }); - await expect(page.getByRole("heading", { name: "Your Commits" }).first()).toBeVisible( - { timeout: 10000 } + await expect( + page.getByRole("heading", { name: "Your Commits" }).first() + ).toBeVisible({ timeout: 10000 }); + + await page.goto("/dashboard/pull-requests", { waitUntil: "load" }); + await expect( + page.getByRole("heading", { level: 1, name: "Pull requests" }) + ).toBeVisible({ timeout: 30000 }); + await expect(page.getByRole("heading", { name: "PR Analytics" })).toHaveCount( + 1 ); - await expect(page.getByRole("heading", { name: "PR Analytics" })).toHaveCount(1); await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible( { timeout: 10000 } ); + + await page.goto("/dashboard/goals", { waitUntil: "load" }); await expect( page.getByRole("heading", { name: "Goals", exact: true }).first() - ).toBeVisible({ timeout: 10000 }); + ).toBeVisible({ timeout: 30000 }); await expect(page.getByText("Make 10 commits")).toBeVisible({ timeout: 10000, }); @@ -279,9 +288,9 @@ test("goal form posts a new goal", async ({ page }) => { } }); - await page.goto("/dashboard", { waitUntil: "load" }); + await page.goto("/dashboard/goals", { waitUntil: "load" }); await expect( - page.getByRole("heading", { name: "Dashboard", exact: true }) + page.getByRole("heading", { level: 1, name: "Goals" }) ).toBeVisible({ timeout: 30000 }); await page.getByLabel("Goal title").fill("Ship one PR"); await page.getByLabel("Target").fill("1"); @@ -349,9 +358,9 @@ function mockMetricResponse(url) { thisWeek: { opened: 3, merged: 2 }, lastWeek: { opened: 1, merged: 1 }, }, - issues: { - thisWeek: { opened: 4, closed: 2 }, - lastWeek: { opened: 3, closed: 1 } + issues: { + thisWeek: { opened: 4, closed: 2 }, + lastWeek: { opened: 3, closed: 1 }, }, productivityScore: { current: 85, previous: 80 }, activeDays: { @@ -360,13 +369,9 @@ function mockMetricResponse(url) { }, streak: 3, topRepo: "demo/repo", - repoBreakdown: [ - { repoName: "demo/devtrack", commits: 10 }, - ], - dailyCommits: [ - { date: "2023-10-07", commits: 5 }, - ], - mostActiveDay: "2023-10-07" + repoBreakdown: [{ repoName: "demo/devtrack", commits: 10 }], + dailyCommits: [{ date: "2023-10-07", commits: 5 }], + mostActiveDay: "2023-10-07", }; } if (url.includes("/api/metrics/compare")) { diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 306385018..caee6934c 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -244,9 +244,9 @@ async function injectMockSession(page: import("@playwright/test").Page) { thisWeek: { opened: 3, merged: 2 }, lastWeek: { opened: 1, merged: 1 }, }, - issues: { - thisWeek: { opened: 5, closed: 0 }, - lastWeek: { opened: 3, closed: 0 } + issues: { + thisWeek: { opened: 5, closed: 0 }, + lastWeek: { opened: 3, closed: 0 }, }, productivityScore: { current: 88, previous: 75 }, activeDays: { thisWeek: 5, lastWeek: 4 }, @@ -254,7 +254,7 @@ async function injectMockSession(page: import("@playwright/test").Page) { topRepo: "demo/devtrack", repoBreakdown: [], dailyCommits: [], - mostActiveDay: "2023-10-07" + mostActiveDay: "2023-10-07", }), }) ); @@ -267,7 +267,7 @@ async function injectMockSession(page: import("@playwright/test").Page) { activeCount: 12, growthTrend: 15, sponsors: [], - sparklineData: [] + sparklineData: [], }), }) ); @@ -394,9 +394,11 @@ test("[Dashboard E2E] Commits widget renders", async ({ page }) => { }); test("[Dashboard E2E] PR Analytics widget renders", async ({ page }) => { - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await page.goto("/dashboard/pull-requests", { + waitUntil: "domcontentloaded", + }); await expect( - page.getByRole("heading", { name: "Dashboard", exact: true }) + page.getByRole("heading", { level: 1, name: "Pull requests" }) ).toBeVisible({ timeout: 30_000 }); await scrollToWidget(page, "PR Analytics"); }); @@ -404,9 +406,9 @@ test("[Dashboard E2E] PR Analytics widget renders", async ({ page }) => { test("[Dashboard E2E] Goals widget renders with mocked goal", async ({ page, }) => { - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await page.goto("/dashboard/goals", { waitUntil: "domcontentloaded" }); await expect( - page.getByRole("heading", { name: "Dashboard", exact: true }) + page.getByRole("heading", { level: 1, name: "Goals" }) ).toBeVisible({ timeout: 30_000 }); await scrollToWidget(page, "Goals"); await expect(page.getByText("Make 10 commits")).toBeVisible({ @@ -455,9 +457,9 @@ test("[Dashboard E2E] no uncaught console errors on dashboard load", async ({ }); test("[Dashboard E2E] weekly summary widget renders", async ({ page }) => { - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await page.goto("/dashboard/activity", { waitUntil: "domcontentloaded" }); await expect( - page.getByRole("heading", { name: "Dashboard", exact: true }) + page.getByRole("heading", { level: 1, name: "Activity" }) ).toBeVisible({ timeout: 30_000 }); // Weekly summary section should appear somewhere on the dashboard. await expect( @@ -504,23 +506,19 @@ test("[Dashboard E2E] analytics widgets render once without duplicate metric req if (request.method() !== "GET") return; const url = request.url(); - if ( - TRACKED_METRICS_ENDPOINTS.some((endpoint) => url.includes(endpoint)) - ) { + if (TRACKED_METRICS_ENDPOINTS.some((endpoint) => url.includes(endpoint))) { requestUrls.push(url); } }); - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await page.goto("/dashboard/pull-requests", { + waitUntil: "domcontentloaded", + }); await expect( - page.getByRole("heading", { name: "Dashboard", exact: true }) + page.getByRole("heading", { level: 1, name: "Pull requests" }) ).toBeVisible({ timeout: 30_000 }); const lazyAnalyticsWidgets = [ - { - region: "Repository Analytics", - heading: "Repo Analytics", - }, { region: "PR Breakdown", heading: "PR Breakdown", @@ -531,7 +529,9 @@ test("[Dashboard E2E] analytics widgets render once without duplicate metric req }, ] as const; - await page.getByRole("region", { name: "PR Metrics" }).scrollIntoViewIfNeeded(); + await page + .getByRole("region", { name: "PR Metrics" }) + .scrollIntoViewIfNeeded(); await expect( page.getByRole("heading", { name: "PR Analytics", level: 2 }) ).toBeVisible({ timeout: 15_000 }); @@ -551,6 +551,17 @@ test("[Dashboard E2E] analytics widgets render once without duplicate metric req ).toHaveCount(1); } + await page.goto("/dashboard/repositories", { waitUntil: "domcontentloaded" }); + await page + .getByRole("region", { name: "Repository Analytics" }) + .scrollIntoViewIfNeeded(); + await expect( + page.getByRole("heading", { name: "Repo Analytics", level: 2 }) + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.getByRole("region", { name: "Repository Analytics" }) + ).toHaveCount(1); + for (const endpoint of TRACKED_METRICS_ENDPOINTS) { const { min, max } = METRICS_REQUEST_BOUNDS[endpoint]; await expect @@ -559,8 +570,8 @@ test("[Dashboard E2E] analytics widgets render once without duplicate metric req const count = countMetricRequests(requestUrls, endpoint); return count >= min && count <= max; }, - { timeout: 15_000 }, + { timeout: 15_000 } ) .toBe(true); } -}); \ No newline at end of file +}); diff --git a/e2e/goals.spec.ts b/e2e/goals.spec.ts index 02823d3ec..fa0d4fa79 100644 --- a/e2e/goals.spec.ts +++ b/e2e/goals.spec.ts @@ -106,7 +106,10 @@ async function setupGoalsMocks(page: import("@playwright/test").Page) { await page.route("**/api/goals/sync**", (route) => route.fulfill({ contentType: "application/json", - body: JSON.stringify({ ok: true, last_synced_at: new Date().toISOString() }), + body: JSON.stringify({ + ok: true, + last_synced_at: new Date().toISOString(), + }), }) ); @@ -114,20 +117,24 @@ async function setupGoalsMocks(page: import("@playwright/test").Page) { } async function openGoalsWidget(page: import("@playwright/test").Page) { - await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await page.goto("/dashboard/goals", { waitUntil: "domcontentloaded" }); await expect( - page.getByRole("heading", { name: "Dashboard", exact: true }) + page.getByRole("heading", { level: 1, name: "Goals" }) ).toBeVisible({ timeout: 30_000 }); await scrollToWidget(page, "Goals"); } test("[Goals E2E] goals widget renders on dashboard", async ({ page }) => { - page.on("console", msg => console.log("BROWSER CONSOLE:", msg.text())); + page.on("console", (msg) => console.log("BROWSER CONSOLE:", msg.text())); await setupGoalsMocks(page); await page.route(/\/api\/goals(\?|$)/, (route) => { if (route.request().method() === "POST") { - return route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ ok: true }) }); + return route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }); } return route.fulfill({ contentType: "application/json", @@ -173,8 +180,14 @@ test("[Goals E2E] creating a goal sends POST /api/goals with correct payload", a await page.getByLabel("Unit", { exact: true }).selectOption("prs"); await page.getByRole("button", { name: "Create goal" }).click(); - await expect.poll(() => goalPosts.filter(Boolean), { timeout: 10_000 }).toHaveLength(1); - expect(goalPosts.find(Boolean)).toMatchObject({ title: "Ship one PR", target: 1, unit: "prs" }); + await expect + .poll(() => goalPosts.filter(Boolean), { timeout: 10_000 }) + .toHaveLength(1); + expect(goalPosts.find(Boolean)).toMatchObject({ + title: "Ship one PR", + target: 1, + unit: "prs", + }); }); test("[Goals E2E] newly created goal appears in the goals list", async ({ @@ -198,10 +211,17 @@ test("[Goals E2E] newly created goal appears in the goals list", async ({ // Use regex so only /api/goals is matched — NOT /api/goals/sync or /api/goals/:id. await page.route(/\/api\/goals(\?|$)/, async (route) => { if (route.request().method() === "POST") { - const body = route.request().postDataJSON() as Record | null; + const body = route.request().postDataJSON() as Record< + string, + unknown + > | null; // Guard: /api/goals/sync POSTs (if leaked) have null body — skip them. if (!body) { - return route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ ok: true }) }); + return route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }); } goalsStore.push({ id: `g-new-${Date.now()}`, @@ -227,7 +247,9 @@ test("[Goals E2E] newly created goal appears in the goals list", async ({ await openGoalsWidget(page); - await expect(page.getByText("Existing Goal")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("Existing Goal")).toBeVisible({ + timeout: 10_000, + }); // Scroll goal form into view and wait for it to be interactive. const titleInput = page.getByLabel("Goal title"); @@ -241,7 +263,9 @@ test("[Goals E2E] newly created goal appears in the goals list", async ({ await page.getByRole("button", { name: "Create goal" }).click(); // The new goal should appear without a page reload. - await expect(page.getByText("Ship five PRs")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("Ship five PRs")).toBeVisible({ + timeout: 10_000, + }); }); test("[Goals E2E] deleting a goal removes it from the list", async ({ @@ -264,7 +288,11 @@ test("[Goals E2E] deleting a goal removes it from the list", async ({ await page.route(/\/api\/goals(\?|$)/, async (route) => { if (route.request().method() === "POST") { - return route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ ok: true }) }); + return route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }); } return route.fulfill({ contentType: "application/json", @@ -294,15 +322,22 @@ test("[Goals E2E] deleting a goal removes it from the list", async ({ }); await openGoalsWidget(page); - await expect(page.getByText("Goal to Delete")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("Goal to Delete")).toBeVisible({ + timeout: 10_000, + }); // Click the delete button (trash icon) next to this goal. - const goalRow = page.locator("li").filter({ hasText: "Goal to Delete" }).first(); + const goalRow = page + .locator("li") + .filter({ hasText: "Goal to Delete" }) + .first(); await goalRow.getByRole("button", { name: /delete goal/i }).click(); // A confirmation modal appears — click "Permanently Delete" to confirm. await page.getByRole("button", { name: /permanently delete/i }).click(); // Goal should be gone. - await expect(page.getByText("Goal to Delete", { exact: true })).not.toBeVisible({ timeout: 10_000 }); + await expect( + page.getByText("Goal to Delete", { exact: true }) + ).not.toBeVisible({ timeout: 10_000 }); }); diff --git a/e2e/streak.spec.ts b/e2e/streak.spec.ts index 5e70b0a62..bbd7933eb 100644 --- a/e2e/streak.spec.ts +++ b/e2e/streak.spec.ts @@ -72,7 +72,7 @@ test.describe("[Streak E2E]", () => { const d = new Date("2026-05-18"); d.setDate(d.getDate() - i); const key = d.toISOString().slice(0, 10); - data[key] = i % 3 === 0 ? 0 : i % 2 + 1; + data[key] = i % 3 === 0 ? 0 : (i % 2) + 1; } await route.fulfill({ contentType: "application/json", @@ -164,24 +164,29 @@ test.describe("[Streak E2E]", () => { // immediately (skips the 650ms rAF animation) in headless Chromium. await page.emulateMedia({ reducedMotion: "reduce" }); - await page.goto("/dashboard", { waitUntil: "load" }); + await page.goto("/dashboard/activity", { waitUntil: "load" }); - // Wait for the dashboard heading — confirms the server-rendered page loaded + // Wait for the tab heading — confirms the server-rendered page loaded await expect( - page.getByRole("heading", { name: "Dashboard", exact: true }) + page.getByRole("heading", { level: 1, name: "Activity" }) ).toBeVisible({ timeout: 30_000 }); // Wait for the "Commit Streaks" heading to exist in the DOM first. // It is absent during the loading skeleton phase, so we must wait for // it before attempting to build a scoped locator from it. - const streakHeading = page.getByRole("heading", { name: "Commit Streaks" }).first(); + const streakHeading = page + .getByRole("heading", { name: "Commit Streaks" }) + .first(); await expect(streakHeading).toBeVisible({ timeout: 15_000 }); // Now scope to the nearest rounded-xl ancestor — the containerRef div // that wraps both the heading and the stats grid. - const section = page.locator('div.rounded-xl').filter({ - has: page.getByRole("heading", { name: "Commit Streaks" }) - }).first(); + const section = page + .locator("div.rounded-xl") + .filter({ + has: page.getByRole("heading", { name: "Commit Streaks" }), + }) + .first(); // The "Longest Streak" stat card contains the number as a raw text node // inside a div whose full text content is "21 days" (number + unit span). diff --git a/package.json b/package.json index a3dd55f9b..702d28e78 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,6 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.107.0", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "@ducanh2912/next-pwa": "^10.2.9", "@google/generative-ai": "^0.24.1", "@sentry/nextjs": "^10", @@ -74,6 +71,7 @@ ] }, "devDependencies": { + "@electric-sql/pglite": "0.5.8", "@emnapi/core": "^1.11.0", "@emnapi/runtime": "^1.11.0", "@next/env": "16.3.8", @@ -94,6 +92,7 @@ "eslint": "^9.39.4", "eslint-config-next": "16.3.8", "husky": "^9.1.7", + "js-yaml": "^4.3.2", "jsdom": "^29.1.1", "lint-staged": "^17.2.0", "postcss": "^8.5.23", @@ -103,9 +102,7 @@ "tree-sitter": "^0.25.0", "typescript": "^6", "vitest": "^4.1.11", - "web-tree-sitter": "^0.26.10", - "@electric-sql/pglite": "0.5.8", - "js-yaml": "^4.3.2" + "web-tree-sitter": "^0.26.10" }, "engines": { "node": ">=22.12 <23" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4f90b7be..32125d585 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,15 +37,6 @@ importers: '@anthropic-ai/sdk': specifier: ^0.107.0 version: 0.107.0(zod@4.4.3) - '@dnd-kit/core': - specifier: ^6.3.1 - version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@18.3.1) '@ducanh2912/next-pwa': specifier: ^10.2.9 version: 10.2.9(@types/babel__core@7.20.5)(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@22.20.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.107.2(postcss@8.5.26)) @@ -873,28 +864,6 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@dnd-kit/accessibility@3.1.1': - resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} - peerDependencies: - react: '>=16.8.0' - - '@dnd-kit/core@6.3.1': - resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@dnd-kit/sortable@10.0.0': - resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: '>=16.8.0' - - '@dnd-kit/utilities@3.2.2': - resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} - peerDependencies: - react: '>=16.8.0' - '@ducanh2912/next-pwa@10.2.9': resolution: {integrity: sha512-Wtu823+0Ga1owqSu1I4HqKgeRYarduCCKwsh1EJmJiJqgbt+gvVf5cFwFH8NigxYyyEvriAro4hzm0pMSrXdRQ==} peerDependencies: @@ -6629,31 +6598,6 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@dnd-kit/accessibility@3.1.1(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.1 - - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/utilities@3.2.2(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.1 - '@ducanh2912/next-pwa@10.2.9(@types/babel__core@7.20.5)(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(@types/node@22.20.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.107.2(postcss@8.5.26))': dependencies: fast-glob: 3.3.2 diff --git a/public/openapi.yaml b/public/openapi.yaml index 6640c6c92..61280df5d 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1922,29 +1922,6 @@ paths: description: Successful response. See the route contract for the returned JSON fields. x-contract-detail: Route and method are verified from source; endpoint-specific JSON schemas have not yet been expanded. security: [] - /api/user/dashboard-layout: - get: - operationId: get_api_user_dashboard_layout - summary: GET /api/user/dashboard-layout - tags: - - user - responses: - '200': - description: Successful response. See the route contract for the returned JSON fields. - x-contract-detail: Route and method are verified from source; endpoint-specific JSON schemas have not yet been expanded. - security: - - cookieAuth: [] - patch: - operationId: patch_api_user_dashboard_layout - summary: PATCH /api/user/dashboard-layout - tags: - - user - responses: - '200': - description: Successful response. See the route contract for the returned JSON fields. - x-contract-detail: Route and method are verified from source; endpoint-specific JSON schemas have not yet been expanded. - security: - - cookieAuth: [] /api/user/data-export: delete: operationId: delete_api_user_data_export diff --git a/src/app/api/ai-insights/route.ts b/src/app/api/ai-insights/route.ts index 14ba71e81..5a7fd5f4d 100644 --- a/src/app/api/ai-insights/route.ts +++ b/src/app/api/ai-insights/route.ts @@ -1,4 +1,8 @@ -import { loadInsightMetrics, InsightMetricsUnavailableError } from "@/lib/insight-metrics"; +import { + loadInsightMetrics, + InsightMetricsUnavailableError, +} from "@/lib/insight-metrics"; +import { groqChatCompletion } from "@/lib/groq"; import { checkAiQuota } from "@/lib/ai-quota"; import { toDatabaseJson } from "@/lib/database-json"; import { NextResponse } from "next/server"; @@ -161,13 +165,34 @@ export async function GET(request: Request) { } let loaded: Awaited>; - try { loaded = await loadInsightMetrics(request); } - catch(error) { - const status=error instanceof InsightMetricsUnavailableError && error.response.status===429 ? 429 : 503; - const retryAfter=error instanceof InsightMetricsUnavailableError ? error.response.headers.get('retry-after') : null; - return NextResponse.json({error:'Complete metrics are unavailable; insights were not generated'}, {status, ...(retryAfter?{headers:{'Retry-After':retryAfter}}:{})}); + try { + loaded = await loadInsightMetrics(request); + } catch (error) { + const status = + error instanceof InsightMetricsUnavailableError && + error.response.status === 429 + ? 429 + : 503; + const retryAfter = + error instanceof InsightMetricsUnavailableError + ? error.response.headers.get("retry-after") + : null; + return NextResponse.json( + { + error: "Complete metrics are unavailable; insights were not generated", + }, + { + status, + ...(retryAfter ? { headers: { "Retry-After": retryAfter } } : {}), + } + ); } - const [contributionsRaw, prsRaw, streakRaw, reposRaw] = loaded as [ContributionsApiResponse, PRsApiResponse, StreakApiResponse, ReposApiResponse]; + const [contributionsRaw, prsRaw, streakRaw, reposRaw] = loaded as [ + ContributionsApiResponse, + PRsApiResponse, + StreakApiResponse, + ReposApiResponse, + ]; const commitsByDay: Record = contributionsRaw.data ?? {}; const commitsArray = Object.entries(commitsByDay) @@ -198,7 +223,8 @@ export async function GET(request: Request) { let aiSummary: string | null = null; if (type === "weekly_summary" && process.env.GROQ_API_KEY) { - const quota=await checkAiQuota(session.githubId);if(quota) return quota; + const quota = await checkAiQuota(session.githubId); + if (quota) return quota; try { const topRepoName = metrics.repos[0]?.name ?? "unknown"; const totalCommits = metrics.commits.reduce((s, d) => s + d.count, 0); @@ -218,21 +244,9 @@ export async function GET(request: Request) { trendLabel, }); - const groqRes = await fetch( - "https://api.groq.com/openai/v1/chat/completions", - { - method: "POST", - signal: AbortSignal.timeout(15_000), - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.GROQ_API_KEY}`, - }, - body: JSON.stringify({ - model: "llama-3.3-70b-versatile", - max_tokens: 300, - messages: [{ role: "user", content: prompt }], - }), - } + const groqRes = await groqChatCompletion( + { max_tokens: 300, messages: [{ role: "user", content: prompt }] }, + { signal: AbortSignal.timeout(15_000) } ); if (groqRes.ok) { diff --git a/src/app/api/personality/route.ts b/src/app/api/personality/route.ts index 613410801..7e6e5cf0a 100644 --- a/src/app/api/personality/route.ts +++ b/src/app/api/personality/route.ts @@ -1,4 +1,5 @@ import { toDatabaseJson } from "@/lib/database-json"; +import { groqChatCompletion } from "@/lib/groq"; import { NextResponse } from "next/server"; import { getServerSession } from "@/lib/server-session"; import { authOptions } from "@/lib/auth"; @@ -6,14 +7,14 @@ import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; import { personalityReportPrompt } from "@/lib/ai-prompts"; import { - computePersonalityDimensions, - buildFallbackReport, - type PersonalityReport, - type PersonalityInputMetrics, + computePersonalityDimensions, + buildFallbackReport, + type PersonalityReport, + type PersonalityInputMetrics, } from "@/lib/personality-analysis"; import { - upstashRateLimitFixedWindow, - getUpstashConfig, + upstashRateLimitFixedWindow, + getUpstashConfig, } from "@/lib/upstash-rest"; import { createMemoryFixedWindowRateLimiter } from "@/lib/rate-limit"; @@ -24,273 +25,272 @@ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours, matches ai-insights // In-memory fallback used only when Upstash Redis is not configured. const memoryLimiter = createMemoryFixedWindowRateLimiter({ - windowMs: PERSONALITY_WINDOW_SECONDS * 1000, - pruneIntervalMs: PERSONALITY_WINDOW_SECONDS * 1000, - maxEntries: 10_000, + windowMs: PERSONALITY_WINDOW_SECONDS * 1000, + pruneIntervalMs: PERSONALITY_WINDOW_SECONDS * 1000, + maxEntries: 10_000, }); export const dynamic = "force-dynamic"; interface TimeBlocksResponse { - morning?: number; - afternoon?: number; - evening?: number; - night?: number; + morning?: number; + afternoon?: number; + evening?: number; + night?: number; } interface ContributionsApiResponse { - data?: Record; - timeBlocks?: TimeBlocksResponse; + data?: Record; + timeBlocks?: TimeBlocksResponse; } interface PRsApiResponse { - open?: number; - merged?: number; - avgReviewHours?: number; + open?: number; + merged?: number; + avgReviewHours?: number; } interface StreakApiResponse { - current?: number; - longest?: number; - totalActiveDays?: number; + current?: number; + longest?: number; + totalActiveDays?: number; } interface RepoSummary { - name: string; - commits: number; + name: string; + commits: number; } interface ReposApiResponse { - repos?: RepoSummary[]; + repos?: RepoSummary[]; } function parseGroqReport(raw: string): Partial | null { - try { - // Groq is asked to return raw JSON, but strip code fences defensively - // in case the model wraps its answer anyway. - const cleaned = raw.replace(/```json|```/g, "").trim(); - const parsed = JSON.parse(cleaned); - - if ( - typeof parsed.archetype !== "string" || - typeof parsed.tagline !== "string" || - typeof parsed.description !== "string" || - !Array.isArray(parsed.strengths) || - typeof parsed.funFact !== "string" - ) { - return null; - } - - return { - archetype: parsed.archetype, - tagline: parsed.tagline, - description: parsed.description, - strengths: parsed.strengths.slice(0, 4).map((s: unknown) => String(s)), - funFact: parsed.funFact, - }; - } catch { - return null; + try { + // Groq is asked to return raw JSON, but strip code fences defensively + // in case the model wraps its answer anyway. + const cleaned = raw.replace(/```json|```/g, "").trim(); + const parsed = JSON.parse(cleaned); + + if ( + typeof parsed.archetype !== "string" || + typeof parsed.tagline !== "string" || + typeof parsed.description !== "string" || + !Array.isArray(parsed.strengths) || + typeof parsed.funFact !== "string" + ) { + return null; } + + return { + archetype: parsed.archetype, + tagline: parsed.tagline, + description: parsed.description, + strengths: parsed.strengths.slice(0, 4).map((s: unknown) => String(s)), + funFact: parsed.funFact, + }; + } catch { + return null; + } } export async function GET(request: Request) { - const session = await getServerSession(authOptions); - - if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - // Keyed on the application user UUID (not session.githubId) so cached - // rows are removed via ON DELETE CASCADE when the account is deleted — - // same rationale as /api/ai-insights (see migration fixing ai_insights - // ownership for the history here). - const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - const userId = user.id; - const { searchParams } = new URL(request.url); - const forceRefresh = searchParams.get("refresh") === "true"; - - // Check the cache before touching the rate-limit counter so repeated - // reads of an already-generated report never consume quota. - if (!forceRefresh) { - const { data: cached } = await supabaseAdmin - .from("ai_insights") - .select("*") - .eq("user_id", userId) - .eq("insight_type", PERSONALITY_INSIGHT_TYPE) - .gte("expires_at", new Date().toISOString()) - .order("generated_at", { ascending: false }) - .limit(1) - .maybeSingle(); - - if (cached) { - return NextResponse.json({ data: cached.content, cached: true }); - } + const session = await getServerSession(authOptions); + + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Keyed on the application user UUID (not session.githubId) so cached + // rows are removed via ON DELETE CASCADE when the account is deleted — + // same rationale as /api/ai-insights (see migration fixing ai_insights + // ownership for the history here). + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const userId = user.id; + const { searchParams } = new URL(request.url); + const forceRefresh = searchParams.get("refresh") === "true"; + + // Check the cache before touching the rate-limit counter so repeated + // reads of an already-generated report never consume quota. + if (!forceRefresh) { + const { data: cached } = await supabaseAdmin + .from("ai_insights") + .select("*") + .eq("user_id", userId) + .eq("insight_type", PERSONALITY_INSIGHT_TYPE) + .gte("expires_at", new Date().toISOString()) + .order("generated_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (cached) { + return NextResponse.json({ data: cached.content, cached: true }); } - - // No valid cache (or refresh requested) — enforce the rate limit only - // when a fresh generation is actually needed. - let rateLimitDenied = false; - let retryAfterSeconds = PERSONALITY_WINDOW_SECONDS; - - if (getUpstashConfig()) { - const result = await upstashRateLimitFixedWindow({ - key: `personality:${userId}`, - limit: PERSONALITY_LIMIT, - windowSeconds: PERSONALITY_WINDOW_SECONDS, - }); - if (!result.allowed) { - rateLimitDenied = true; - retryAfterSeconds = result.retryAfter ?? PERSONALITY_WINDOW_SECONDS; - } - } else { - const result = memoryLimiter.check(`personality:${userId}`, PERSONALITY_LIMIT); - if (!result.allowed) { - rateLimitDenied = true; - retryAfterSeconds = Math.max(result.reset - Math.floor(Date.now() / 1000), 1); - } + } + + // No valid cache (or refresh requested) — enforce the rate limit only + // when a fresh generation is actually needed. + let rateLimitDenied = false; + let retryAfterSeconds = PERSONALITY_WINDOW_SECONDS; + + if (getUpstashConfig()) { + const result = await upstashRateLimitFixedWindow({ + key: `personality:${userId}`, + limit: PERSONALITY_LIMIT, + windowSeconds: PERSONALITY_WINDOW_SECONDS, + }); + if (!result.allowed) { + rateLimitDenied = true; + retryAfterSeconds = result.retryAfter ?? PERSONALITY_WINDOW_SECONDS; } - - if (rateLimitDenied) { - return NextResponse.json( - { error: "Rate limit exceeded. Try again later." }, - { - status: 429, - headers: { "Retry-After": String(retryAfterSeconds) }, - } - ); + } else { + const result = memoryLimiter.check( + `personality:${userId}`, + PERSONALITY_LIMIT + ); + if (!result.allowed) { + rateLimitDenied = true; + retryAfterSeconds = Math.max( + result.reset - Math.floor(Date.now() / 1000), + 1 + ); } - - const baseUrl = process.env.NEXTAUTH_URL ?? "http://localhost:3000"; - const cookie = request.headers.get("cookie") ?? ""; - const headers = { Cookie: cookie }; - - const [contributionsRes, prsRes, streakRes, reposRes] = await Promise.all([ - fetch(`${baseUrl}/api/metrics/contributions?days=90`, { - headers, - cache: "no-store", - }), - fetch(`${baseUrl}/api/metrics/prs`, { headers, cache: "no-store" }), - fetch(`${baseUrl}/api/metrics/streak`, { headers, cache: "no-store" }), - fetch(`${baseUrl}/api/metrics/repos?days=90`, { - headers, - cache: "no-store", - }), - ]); - - const [contributionsRaw, prsRaw, streakRaw, reposRaw]: [ - ContributionsApiResponse, - PRsApiResponse, - StreakApiResponse, - ReposApiResponse, - ] = await Promise.all([ - contributionsRes.ok ? contributionsRes.json() : {}, - prsRes.ok ? prsRes.json() : {}, - streakRes.ok ? streakRes.json() : {}, - reposRes.ok ? reposRes.json() : {}, - ]); - - const commitsByDay: Record = contributionsRaw.data ?? {}; - const commitsArray = Object.entries(commitsByDay) - .map(([date, count]) => ({ date, count })) - .sort((a, b) => a.date.localeCompare(b.date)); - const totalCommits = commitsArray.reduce((s, d) => s + d.count, 0); - - const metrics: PersonalityInputMetrics = { - timeBlocks: { - morning: contributionsRaw.timeBlocks?.morning ?? 0, - afternoon: contributionsRaw.timeBlocks?.afternoon ?? 0, - evening: contributionsRaw.timeBlocks?.evening ?? 0, - night: contributionsRaw.timeBlocks?.night ?? 0, - }, - totalCommits, - activeDays: streakRaw.totalActiveDays ?? 0, - longestStreak: streakRaw.longest ?? 0, - currentStreak: streakRaw.current ?? 0, - prsMerged: prsRaw.merged ?? 0, - prsOpen: prsRaw.open ?? 0, - avgMergeTimeDays: (prsRaw.avgReviewHours ?? 0) / 24, - topRepoName: reposRaw.repos?.[0]?.name ?? "your top repo", - repoCount: reposRaw.repos?.length ?? 0, - commitsByDay: commitsArray, - }; - - const dimensions = computePersonalityDimensions(metrics); - let report: PersonalityReport = buildFallbackReport(dimensions, { - topRepoName: metrics.topRepoName, + } + + if (rateLimitDenied) { + return NextResponse.json( + { error: "Rate limit exceeded. Try again later." }, + { + status: 429, + headers: { "Retry-After": String(retryAfterSeconds) }, + } + ); + } + + const baseUrl = process.env.NEXTAUTH_URL ?? "http://localhost:3000"; + const cookie = request.headers.get("cookie") ?? ""; + const headers = { Cookie: cookie }; + + const [contributionsRes, prsRes, streakRes, reposRes] = await Promise.all([ + fetch(`${baseUrl}/api/metrics/contributions?days=90`, { + headers, + cache: "no-store", + }), + fetch(`${baseUrl}/api/metrics/prs`, { headers, cache: "no-store" }), + fetch(`${baseUrl}/api/metrics/streak`, { headers, cache: "no-store" }), + fetch(`${baseUrl}/api/metrics/repos?days=90`, { + headers, + cache: "no-store", + }), + ]); + + const [contributionsRaw, prsRaw, streakRaw, reposRaw]: [ + ContributionsApiResponse, + PRsApiResponse, + StreakApiResponse, + ReposApiResponse, + ] = await Promise.all([ + contributionsRes.ok ? contributionsRes.json() : {}, + prsRes.ok ? prsRes.json() : {}, + streakRes.ok ? streakRes.json() : {}, + reposRes.ok ? reposRes.json() : {}, + ]); + + const commitsByDay: Record = contributionsRaw.data ?? {}; + const commitsArray = Object.entries(commitsByDay) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)); + const totalCommits = commitsArray.reduce((s, d) => s + d.count, 0); + + const metrics: PersonalityInputMetrics = { + timeBlocks: { + morning: contributionsRaw.timeBlocks?.morning ?? 0, + afternoon: contributionsRaw.timeBlocks?.afternoon ?? 0, + evening: contributionsRaw.timeBlocks?.evening ?? 0, + night: contributionsRaw.timeBlocks?.night ?? 0, + }, + totalCommits, + activeDays: streakRaw.totalActiveDays ?? 0, + longestStreak: streakRaw.longest ?? 0, + currentStreak: streakRaw.current ?? 0, + prsMerged: prsRaw.merged ?? 0, + prsOpen: prsRaw.open ?? 0, + avgMergeTimeDays: (prsRaw.avgReviewHours ?? 0) / 24, + topRepoName: reposRaw.repos?.[0]?.name ?? "your top repo", + repoCount: reposRaw.repos?.length ?? 0, + commitsByDay: commitsArray, + }; + + const dimensions = computePersonalityDimensions(metrics); + let report: PersonalityReport = buildFallbackReport(dimensions, { + topRepoName: metrics.topRepoName, + longestStreak: metrics.longestStreak, + prsMerged: metrics.prsMerged, + }); + + if (process.env.GROQ_API_KEY) { + try { + const prompt = personalityReportPrompt({ + workingStyle: dimensions.workingStyle, + commitPattern: dimensions.commitPattern, + collaborationStyle: dimensions.collaborationStyle, + perfectionismScore: dimensions.perfectionismScore, + nightCommitPct: dimensions.nightCommitPct, + morningCommitPct: dimensions.morningCommitPct, + totalCommits: metrics.totalCommits, + activeDays: metrics.activeDays, longestStreak: metrics.longestStreak, prsMerged: metrics.prsMerged, - }); - - if (process.env.GROQ_API_KEY) { - try { - const prompt = personalityReportPrompt({ - workingStyle: dimensions.workingStyle, - commitPattern: dimensions.commitPattern, - collaborationStyle: dimensions.collaborationStyle, - perfectionismScore: dimensions.perfectionismScore, - nightCommitPct: dimensions.nightCommitPct, - morningCommitPct: dimensions.morningCommitPct, - totalCommits: metrics.totalCommits, - activeDays: metrics.activeDays, - longestStreak: metrics.longestStreak, - prsMerged: metrics.prsMerged, - avgMergeTimeDays: metrics.avgMergeTimeDays, - topRepoName: metrics.topRepoName, - repoCount: metrics.repoCount, - }); - - const groqRes = await fetch( - "https://api.groq.com/openai/v1/chat/completions", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.GROQ_API_KEY}`, - }, - body: JSON.stringify({ - model: "llama-3.3-70b-versatile", - max_tokens: 400, - temperature: 0.8, - messages: [{ role: "user", content: prompt }], - }), - } - ); - - if (groqRes.ok) { - const groqData = (await groqRes.json()) as { - choices?: Array<{ message?: { content?: string } }>; - }; - const content = groqData.choices?.[0]?.message?.content; - const parsed = content ? parseGroqReport(content) : null; - - if (parsed) { - report = { ...dimensions, ...parsed, source: "ai" } as PersonalityReport; - } - } else { - console.error("Groq API error", groqRes.status, await groqRes.text()); - } - } catch (err) { - console.error("Groq API error — falling back to rule-based report", err); + avgMergeTimeDays: metrics.avgMergeTimeDays, + topRepoName: metrics.topRepoName, + repoCount: metrics.repoCount, + }); + + const groqRes = await groqChatCompletion({ + max_tokens: 400, + temperature: 0.8, + messages: [{ role: "user", content: prompt }], + }); + + if (groqRes.ok) { + const groqData = (await groqRes.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = groqData.choices?.[0]?.message?.content; + const parsed = content ? parseGroqReport(content) : null; + + if (parsed) { + report = { + ...dimensions, + ...parsed, + source: "ai", + } as PersonalityReport; } + } else { + console.error("Groq API error", groqRes.status, await groqRes.text()); + } + } catch (err) { + console.error("Groq API error — falling back to rule-based report", err); } - - const now = new Date(); - const expiresAt = new Date(now.getTime() + CACHE_TTL_MS); - - await supabaseAdmin.from("ai_insights").upsert( - { - user_id: userId, - insight_type: PERSONALITY_INSIGHT_TYPE, - content: toDatabaseJson(report), - generated_at: now.toISOString(), - expires_at: expiresAt.toISOString(), - }, - { onConflict: "user_id,insight_type" } - ); - - return NextResponse.json({ data: report, cached: false }); -} \ No newline at end of file + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + CACHE_TTL_MS); + + await supabaseAdmin.from("ai_insights").upsert( + { + user_id: userId, + insight_type: PERSONALITY_INSIGHT_TYPE, + content: toDatabaseJson(report), + generated_at: now.toISOString(), + expires_at: expiresAt.toISOString(), + }, + { onConflict: "user_id,insight_type" } + ); + + return NextResponse.json({ data: report, cached: false }); +} diff --git a/src/app/api/project-tutor/route.ts b/src/app/api/project-tutor/route.ts index 8917078b3..007247ed5 100644 --- a/src/app/api/project-tutor/route.ts +++ b/src/app/api/project-tutor/route.ts @@ -1,27 +1,18 @@ import { githubRateLimitResponse } from "@/lib/github-rate-limit"; import { githubRequest } from "@/lib/github-request"; import { NextRequest, NextResponse } from "next/server"; +import { groqChatCompletion } from "@/lib/groq"; import { getServerSession } from "@/lib/server-session"; import { authOptions } from "@/lib/auth"; -const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"; - async function callGroq(prompt: string): Promise { const apiKey = process.env.GROQ_API_KEY; if (!apiKey) return "AI insights unavailable — GROQ_API_KEY not configured."; - const res = await githubRequest(GROQ_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: "llama3-8b-8192", - messages: [{ role: "user", content: prompt }], - max_tokens: 1024, - temperature: 0.7, - }), + const res = await groqChatCompletion({ + messages: [{ role: "user", content: prompt }], + max_tokens: 1024, + temperature: 0.7, }); if (!res.ok) throw new Error("Groq API error"); @@ -39,9 +30,17 @@ async function fetchRepoData(owner: string, repo: string) { const safeOwner = encodeURIComponent(owner); const safeRepo = encodeURIComponent(repo); const [repoRes, readmeRes, languagesRes] = await Promise.all([ - githubRequest(`https://api.github.com/repos/${safeOwner}/${safeRepo}`, { headers }), - githubRequest(`https://api.github.com/repos/${safeOwner}/${safeRepo}/readme`, { headers }), - githubRequest(`https://api.github.com/repos/${safeOwner}/${safeRepo}/languages`, { headers }), + githubRequest(`https://api.github.com/repos/${safeOwner}/${safeRepo}`, { + headers, + }), + githubRequest( + `https://api.github.com/repos/${safeOwner}/${safeRepo}/readme`, + { headers } + ), + githubRequest( + `https://api.github.com/repos/${safeOwner}/${safeRepo}/languages`, + { headers } + ), ]); const repoData = repoRes.ok ? await repoRes.json() : {}; @@ -50,7 +49,9 @@ async function fetchRepoData(owner: string, repo: string) { let readmeContent = ""; if (readmeData.content) { - readmeContent = Buffer.from(readmeData.content, "base64").toString("utf-8").slice(0, 2000); + readmeContent = Buffer.from(readmeData.content, "base64") + .toString("utf-8") + .slice(0, 2000); } return { repoData, readmeContent, languages }; @@ -58,22 +59,38 @@ async function fetchRepoData(owner: string, repo: string) { export async function POST(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { const { repoUrl, action, question } = await req.json(); - if (!repoUrl) return NextResponse.json({ error: "repo URL required" }, { status: 400 }); + if (!repoUrl) + return NextResponse.json({ error: "repo URL required" }, { status: 400 }); const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/); - if (!match) return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 }); + if (!match) + return NextResponse.json( + { error: "Invalid GitHub URL" }, + { status: 400 } + ); const [, owner, repo] = match; // Validate owner and repo are valid GitHub identifiers to prevent path traversal - const githubIdentifier = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,97}[a-zA-Z0-9])?$/; - if (!githubIdentifier.test(owner) || !githubIdentifier.test(repo.replace(/\.git$/, ""))) - return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 }); - const { repoData, readmeContent, languages } = await fetchRepoData(owner, repo); + const githubIdentifier = + /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,97}[a-zA-Z0-9])?$/; + if ( + !githubIdentifier.test(owner) || + !githubIdentifier.test(repo.replace(/\.git$/, "")) + ) + return NextResponse.json( + { error: "Invalid GitHub URL" }, + { status: 400 } + ); + const { repoData, readmeContent, languages } = await fetchRepoData( + owner, + repo + ); const techStack = Object.keys(languages).join(", ") || "Unknown"; const description = repoData.description || "No description"; @@ -105,7 +122,11 @@ ${context} Format your response with clear headings using markdown.`; const analysis = await callGroq(prompt); - return NextResponse.json({ analysis, techStack, description: repoData.description }); + return NextResponse.json({ + analysis, + techStack, + description: repoData.description, + }); } if (action === "questions") { @@ -138,10 +159,22 @@ Return ONLY the JSON, no other text.`; } catch { return NextResponse.json({ questions: { - easy: ["What is the main purpose of this project?", "What technologies did you use?", "How do you run this project locally?"], - medium: ["How did you structure your codebase?", "What was the most challenging part?", "How did you handle errors?"], - advanced: ["How would you scale this?", "What would you do differently?", "How would you add authentication?"], - } + easy: [ + "What is the main purpose of this project?", + "What technologies did you use?", + "How do you run this project locally?", + ], + medium: [ + "How did you structure your codebase?", + "What was the most challenging part?", + "How did you handle errors?", + ], + advanced: [ + "How would you scale this?", + "What would you do differently?", + "How would you add authentication?", + ], + }, }); } } @@ -166,6 +199,9 @@ Give a clear, concise answer focused on interview preparation. Keep it under 200 if (githubLimit) return githubLimit; console.error("Project tutor error:", error); - return NextResponse.json({ error: "Something went wrong" }, { status: 500 }); + return NextResponse.json( + { error: "Something went wrong" }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/stream/route.ts b/src/app/api/stream/route.ts index a4bebd154..51c2ef2b2 100644 --- a/src/app/api/stream/route.ts +++ b/src/app/api/stream/route.ts @@ -18,7 +18,7 @@ export const dynamic = "force-dynamic"; // Changed from 2s to 60s per connection — 30x cheaper. // For 500 concurrent users: ~8 queries/sec instead of ~500 queries/sec. // -// 3. Maximum connection duration (MAX_CONNECTION_DURATION_MS = 5 min) +// 3. Maximum connection duration (MAX_CONNECTION_DURATION_MS = 4 min) // Stale connections are forcibly closed even if the client never sends // an abort signal. EventSource reconnects automatically. // @@ -41,7 +41,9 @@ const MAX_CONNECTIONS_PER_USER = 4; const POLL_INTERVAL_MS = 60_000; // Maximum lifetime for any single SSE connection (5 minutes). -const MAX_CONNECTION_DURATION_MS = 5 * 60 * 1000; +// Below the 300s function limit, so the stream ends cleanly before the +// platform kills it (which logged a timeout error for every connection). +const MAX_CONNECTION_DURATION_MS = 4 * 60 * 1000; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); @@ -193,10 +195,7 @@ export async function GET(req: NextRequest) { checkData(); }, POLL_INTERVAL_MS); - maxDurationTimeout = setTimeout( - closeStream, - MAX_CONNECTION_DURATION_MS - ); + maxDurationTimeout = setTimeout(closeStream, MAX_CONNECTION_DURATION_MS); cleanupStream = closeStream; if (req.signal.aborted) { diff --git a/src/app/api/user/dashboard-layout/route.ts b/src/app/api/user/dashboard-layout/route.ts deleted file mode 100644 index b08ee58a7..000000000 --- a/src/app/api/user/dashboard-layout/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { toDatabaseJson } from "@/lib/database-json"; -import { NextResponse } from "next/server"; -import { getServerSession } from "@/lib/server-session"; -import { authOptions } from "@/lib/auth"; -import { resolveAppUser } from "@/lib/resolve-user"; -import { supabaseAdmin } from "@/lib/supabase"; -import { - getDefaultDashboardLayout, - normalizeDashboardLayout, -} from "@/lib/dashboard-layout"; - -export async function GET() { - try { - const session = await getServerSession(authOptions); - - if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const appUser = await resolveAppUser(session.githubId, session.githubLogin); - - if (!appUser) { - return NextResponse.json({ layout: getDefaultDashboardLayout(), source: "default" }); - } - - const { data, error } = await supabaseAdmin - .from("users") - .select("dashboard_layout") - .eq("id", appUser.id) - .single(); - - if (error) { - return NextResponse.json({ - layout: getDefaultDashboardLayout(), - source: "default", - }); - } - - return NextResponse.json({ - layout: normalizeDashboardLayout(data?.dashboard_layout), - source: "database", - }); - } catch { - return NextResponse.json({ - layout: getDefaultDashboardLayout(), - source: "fallback", - }); - } -} - -export async function PATCH(request: Request) { - try { - const session = await getServerSession(authOptions); - - if (!session?.githubId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = (await request.json()) as { layout?: unknown }; - const layout = normalizeDashboardLayout(body.layout); - - const appUser = await resolveAppUser(session.githubId, session.githubLogin); - - if (!appUser) { - return NextResponse.json( - { error: "User not found" }, - { status: 404 }, - ); - } - - const { error } = await supabaseAdmin - .from("users") - .update({ - dashboard_layout: toDatabaseJson(layout), - updated_at: new Date().toISOString(), - }) - .eq("id", appUser.id); - - if (error) { - return NextResponse.json( - { error: "Failed to save dashboard layout" }, - { status: 500 }, - ); - } - - return NextResponse.json({ layout }); - } catch { - return NextResponse.json( - { error: "Failed to save dashboard layout" }, - { status: 500 }, - ); - } -} diff --git a/src/app/dashboard/DashboardDynamicWidgets.tsx b/src/app/dashboard/DashboardDynamicWidgets.tsx deleted file mode 100644 index ad46a82fc..000000000 --- a/src/app/dashboard/DashboardDynamicWidgets.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -const SkeletonCard = () => ( -
-
-
-
-); - -const ContributionGraphSkeleton = () => ( -
-

Your Commits

-
-
-); - -const PRMetricsSkeleton = () => ( -
-

PR Analytics

-
-
-); - -export const CodingActivityInsightsCard = dynamic( - () => import("@/components/CodingActivityInsightsCard"), - { ssr: false, loading: () => }, -); - -export const FriendComparison = dynamic( - () => import("@/components/FriendComparison"), - { ssr: false, loading: () => }, -); - -export const ActivityRingChart = dynamic( - () => import("@/components/ActivityRingChart"), - { ssr: false, loading: () => }, -); - -export const ContributionGraph = dynamic( - () => import("@/components/ContributionGraph"), - { ssr: false, loading: () => }, -); - -export const ContributionHeatmap = dynamic( - () => import("@/components/ContributionHeatmap"), - { ssr: false, loading: () => }, -); - -export const RepoContributionDistribution = dynamic( - () => import("@/components/RepoContributionDistribution"), - { - ssr: false, - loading: () => , - }, -); - -export const PRMetrics = dynamic(() => import("@/components/PRMetrics"), { - ssr: false, - loading: () => , -}); - -export const PRBreakdownChart = dynamic( - () => import("@/components/PRBreakdownChart"), - { ssr: false, loading: () => }, -); - -export const CommitTimeChart = dynamic( - () => import("@/components/CommitTimeChart"), - { ssr: false, loading: () => }, -); - -export const PRReviewTrendChart = dynamic( - () => import("@/components/PRReviewTrendChart"), - { ssr: false, loading: () => }, -); - -export const ProductiveHoursWidget = dynamic( - () => import("@/components/ProductiveHoursWidget"), - { ssr: false, loading: () => }, -); - -export { SkeletonCard }; diff --git a/src/app/dashboard/activity/page.tsx b/src/app/dashboard/activity/page.tsx new file mode 100644 index 000000000..3deba380a --- /dev/null +++ b/src/app/dashboard/activity/page.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from "next"; +import PageHeader, { SectionTitle } from "@/components/shell/PageHeader"; +import { + ActivityRing, + CodingActivityInsights, + CodingTime, + CommitTime, + ConsistencyScore, + ContributionHeatmap, + LocalCodingTime, + ProductiveHours, + StreakTracker, + WeeklySummary, +} from "@/components/dashboard/widgets"; + +export const metadata: Metadata = { title: "Activity · DevTrack" }; + +export default function ActivityPage() { + return ( + <> + + + Contributions + + + Streaks & consistency +
+ +
+ + +
+
+ + When you code +
+ + + + +
+ + Coding time +
+ + +
+ + ); +} diff --git a/src/app/dashboard/community/page.tsx b/src/app/dashboard/community/page.tsx new file mode 100644 index 000000000..09dbc7cb2 --- /dev/null +++ b/src/app/dashboard/community/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { MessagesSquare, Trophy, UsersRound } from "lucide-react"; +import PageHeader, { SectionTitle } from "@/components/shell/PageHeader"; +import { + CommunityMetrics, + FriendComparison, + SponsorAnalytics, +} from "@/components/dashboard/widgets"; + +export const metadata: Metadata = { title: "Community · DevTrack" }; + +const linkClass = + "inline-flex items-center gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] px-3 py-1.5 text-sm font-medium text-[var(--card-foreground)] hover:bg-[var(--control)]"; + +export default function CommunityPage() { + return ( + <> + + +
+
); } diff --git a/src/app/dashboard/pull-requests/page.tsx b/src/app/dashboard/pull-requests/page.tsx new file mode 100644 index 000000000..3bf9089a4 --- /dev/null +++ b/src/app/dashboard/pull-requests/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import PageHeader, { SectionTitle } from "@/components/shell/PageHeader"; +import { + CIAnalytics, + IssueMetrics, + PRBreakdown, + PRMetrics, + PRReviewTrend, +} from "@/components/dashboard/widgets"; + +export const metadata: Metadata = { title: "Pull requests · DevTrack" }; + +export default function PullRequestsPage() { + return ( + <> + + + + + Breakdown & reviews +
+ + +
+ + Issues & CI +
+ + +
+ + ); +} diff --git a/src/app/dashboard/repositories/page.tsx b/src/app/dashboard/repositories/page.tsx new file mode 100644 index 000000000..8d46c29c3 --- /dev/null +++ b/src/app/dashboard/repositories/page.tsx @@ -0,0 +1,57 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { GitCompare, HeartPulse } from "lucide-react"; +import PageHeader, { SectionTitle } from "@/components/shell/PageHeader"; +import { + InactiveRepos, + LanguageBreakdown, + PinnedRepos, + RepoAnalytics, + RepoContributionDistribution, + TopRepos, +} from "@/components/dashboard/widgets"; + +export const metadata: Metadata = { title: "Repositories · DevTrack" }; + +const linkClass = + "inline-flex items-center gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] px-3 py-1.5 text-sm font-medium text-[var(--card-foreground)] hover:bg-[var(--control)]"; + +export default function RepositoriesPage() { + return ( + <> + + +