Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3bbd546
feat: add contact page and footer link
KrutagyaKaneria May 28, 2026
e88e843
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 28, 2026
921dd5a
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 28, 2026
f50fc5c
fix: replace fake contact submission with GitHub issues redirect
KrutagyaKaneria May 28, 2026
4cd1d78
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 28, 2026
01c054d
fix: resolve broken dashboard widget e2e payload
KrutagyaKaneria May 28, 2026
5844120
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 28, 2026
9dddaaf
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 28, 2026
a5068c8
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 28, 2026
ba301fb
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 29, 2026
c6e7fa1
fix: stabilize playwright auth and supabase env
KrutagyaKaneria May 29, 2026
27a6c71
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 29, 2026
34d82c9
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 29, 2026
676d999
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 29, 2026
f3f5c3e
fix: stabilize notifications e2e tests
KrutagyaKaneria May 29, 2026
5461640
fix: resolve build and lockfile issues
KrutagyaKaneria May 29, 2026
5751e8e
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 29, 2026
581db24
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 29, 2026
701c9f5
fix: resolve build and lockfile issues
KrutagyaKaneria May 29, 2026
1b4919f
fix: resolve build and lockfile issues
KrutagyaKaneria May 29, 2026
8d84995
Merge branch 'main' into feat/issue-1401-contact-page
KrutagyaKaneria May 29, 2026
dcf1b3a
fix: resolve build and lockfile issues
KrutagyaKaneria May 29, 2026
cbe7ad4
fix: add missing JetBrains Mono import
KrutagyaKaneria May 29, 2026
fe80a0e
ci.yml
KrutagyaKaneria May 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,18 @@ jobs:
env:
# Dummy values — API routes are force-dynamic and only run at request
# time, not build time, so these are never actually used.
NEXTAUTH_SECRET: ci-placeholder-secret-that-is-long-enough-32c
NEXTAUTH_URL: http://localhost:3000
GITHUB_ID: ci-placeholder
GITHUB_SECRET: ci-placeholder
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key
SUPABASE_SERVICE_ROLE_KEY: placeholder-service-key
NEXT_PUBLIC_APP_URL: http://localhost:3000
NEXTAUTH_SECRET: test-nextauth-secret
NEXTAUTH_URL: http://127.0.0.1:3000

GITHUB_ID: ci-placeholder
GITHUB_SECRET: ci-placeholder

NEXT_PUBLIC_SUPABASE_URL: https://example.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder-anon-key

SUPABASE_SERVICE_ROLE_KEY: ci-placeholder-service-role-key

NEXT_PUBLIC_APP_URL: http://127.0.0.1:3000
steps:
- uses: actions/checkout@v4

Expand Down
6 changes: 6 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Everything you need to run DevTrack locally from scratch in under 10 minutes.
You also need free accounts on:
- [Supabase](https://supabase.com) — for the database
- GitHub — for OAuth (you already have this)
- [Resend](https://resend.com) — for the contact form backend

---

Expand Down Expand Up @@ -78,6 +79,11 @@ NEXTAUTH_SECRET=generate_with_openssl_rand_base64_32
# GitHub OAuth
GITHUB_ID=Ov23...
GITHUB_SECRET=your_github_client_secret

# Contact form email delivery
RESEND_API_KEY=re_xxx...
RESEND_FROM_EMAIL="DevTrack <contact@your-domain.com>"
CONTACT_TO_EMAIL=you@example.com
```

Generate `NEXTAUTH_SECRET`:
Expand Down
13 changes: 8 additions & 5 deletions e2e/dashboard-widgets.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
test.beforeEach(async ({ page }) => {
// Create a valid NextAuth JWT and set it as the session cookie so
// dashboard pages render as an authenticated user in Playwright.
const token = await encode({

Check failure on line 7 in e2e/dashboard-widgets.spec.js

View workflow job for this annotation

GitHub Actions / Playwright smoke tests

[chromium] › e2e/dashboard-widgets.spec.js:235:5 › goal form posts a new goal

5) [chromium] › e2e/dashboard-widgets.spec.js:235:5 › goal form posts a new goal ───────────────── Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── TypeError: "ikm" must be at least one byte in length 5 | // Create a valid NextAuth JWT and set it as the session cookie so 6 | // dashboard pages render as an authenticated user in Playwright. > 7 | const token = await encode({ | ^ 8 | secret: process.env.NEXTAUTH_SECRET ?? "playwright-placeholder-secret-that-is-long-enough", 9 | token: { 10 | name: "Playwright User", at normalizeIkm (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:26:15) at hkdf (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:47:60) at getDerivedEncryptionKey (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:100:34) at encode (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:40:34) at /home/runner/work/devtrack/devtrack/e2e/dashboard-widgets.spec.js:7:29

Check failure on line 7 in e2e/dashboard-widgets.spec.js

View workflow job for this annotation

GitHub Actions / Playwright smoke tests

[chromium] › e2e/dashboard-widgets.spec.js:235:5 › goal form posts a new goal

5) [chromium] › e2e/dashboard-widgets.spec.js:235:5 › goal form posts a new goal ───────────────── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── TypeError: "ikm" must be at least one byte in length 5 | // Create a valid NextAuth JWT and set it as the session cookie so 6 | // dashboard pages render as an authenticated user in Playwright. > 7 | const token = await encode({ | ^ 8 | secret: process.env.NEXTAUTH_SECRET ?? "playwright-placeholder-secret-that-is-long-enough", 9 | token: { 10 | name: "Playwright User", at normalizeIkm (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:26:15) at hkdf (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:47:60) at getDerivedEncryptionKey (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:100:34) at encode (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:40:34) at /home/runner/work/devtrack/devtrack/e2e/dashboard-widgets.spec.js:7:29

Check failure on line 7 in e2e/dashboard-widgets.spec.js

View workflow job for this annotation

GitHub Actions / Playwright smoke tests

[chromium] › e2e/dashboard-widgets.spec.js:220:5 › contribution graph range buttons request a new range

4) [chromium] › e2e/dashboard-widgets.spec.js:220:5 › contribution graph range buttons request a new range Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── TypeError: "ikm" must be at least one byte in length 5 | // Create a valid NextAuth JWT and set it as the session cookie so 6 | // dashboard pages render as an authenticated user in Playwright. > 7 | const token = await encode({ | ^ 8 | secret: process.env.NEXTAUTH_SECRET ?? "playwright-placeholder-secret-that-is-long-enough", 9 | token: { 10 | name: "Playwright User", at normalizeIkm (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:26:15) at hkdf (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:47:60) at getDerivedEncryptionKey (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:100:34) at encode (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:40:34) at /home/runner/work/devtrack/devtrack/e2e/dashboard-widgets.spec.js:7:29

Check failure on line 7 in e2e/dashboard-widgets.spec.js

View workflow job for this annotation

GitHub Actions / Playwright smoke tests

[chromium] › e2e/dashboard-widgets.spec.js:220:5 › contribution graph range buttons request a new range

4) [chromium] › e2e/dashboard-widgets.spec.js:220:5 › contribution graph range buttons request a new range Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── TypeError: "ikm" must be at least one byte in length 5 | // Create a valid NextAuth JWT and set it as the session cookie so 6 | // dashboard pages render as an authenticated user in Playwright. > 7 | const token = await encode({ | ^ 8 | secret: process.env.NEXTAUTH_SECRET ?? "playwright-placeholder-secret-that-is-long-enough", 9 | token: { 10 | name: "Playwright User", at normalizeIkm (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:26:15) at hkdf (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:47:60) at getDerivedEncryptionKey (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:100:34) at encode (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:40:34) at /home/runner/work/devtrack/devtrack/e2e/dashboard-widgets.spec.js:7:29

Check failure on line 7 in e2e/dashboard-widgets.spec.js

View workflow job for this annotation

GitHub Actions / Playwright smoke tests

[chromium] › e2e/dashboard-widgets.spec.js:211:5 › dashboard widgets render with mocked metrics

3) [chromium] › e2e/dashboard-widgets.spec.js:211:5 › dashboard widgets render with mocked metrics Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── TypeError: "ikm" must be at least one byte in length 5 | // Create a valid NextAuth JWT and set it as the session cookie so 6 | // dashboard pages render as an authenticated user in Playwright. > 7 | const token = await encode({ | ^ 8 | secret: process.env.NEXTAUTH_SECRET ?? "playwright-placeholder-secret-that-is-long-enough", 9 | token: { 10 | name: "Playwright User", at normalizeIkm (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:26:15) at hkdf (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:47:60) at getDerivedEncryptionKey (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:100:34) at encode (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:40:34) at /home/runner/work/devtrack/devtrack/e2e/dashboard-widgets.spec.js:7:29

Check failure on line 7 in e2e/dashboard-widgets.spec.js

View workflow job for this annotation

GitHub Actions / Playwright smoke tests

[chromium] › e2e/dashboard-widgets.spec.js:211:5 › dashboard widgets render with mocked metrics

3) [chromium] › e2e/dashboard-widgets.spec.js:211:5 › dashboard widgets render with mocked metrics Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── TypeError: "ikm" must be at least one byte in length 5 | // Create a valid NextAuth JWT and set it as the session cookie so 6 | // dashboard pages render as an authenticated user in Playwright. > 7 | const token = await encode({ | ^ 8 | secret: process.env.NEXTAUTH_SECRET ?? "playwright-placeholder-secret-that-is-long-enough", 9 | token: { 10 | name: "Playwright User", at normalizeIkm (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:26:15) at hkdf (/home/runner/work/devtrack/devtrack/node_modules/@panva/hkdf/dist/node/cjs/index.js:47:60) at getDerivedEncryptionKey (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:100:34) at encode (/home/runner/work/devtrack/devtrack/node_modules/next-auth/jwt/index.js:40:34) at /home/runner/work/devtrack/devtrack/e2e/dashboard-widgets.spec.js:7:29
secret: process.env.NEXTAUTH_SECRET ?? "playwright-placeholder-secret-that-is-long-enough",
token: {
name: "Playwright User",
Expand Down Expand Up @@ -113,12 +113,15 @@
});
});

await page.route("**/api/goals/sync**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
await page.route("**/api/goals/sync**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
ok: true,
last_synced_at: new Date().toISOString(),
}),
});
});

await page.route("**/api/goals/sync", async (route) => {
await route.fulfill({
Expand Down
241 changes: 180 additions & 61 deletions e2e/notifications.spec.js
Original file line number Diff line number Diff line change
@@ -1,56 +1,8 @@
import { expect, test } from "@playwright/test";
import { encode } from "next-auth/jwt";

const authSecret = "playwright-placeholder-secret-that-is-long-enough";

/** Returns a properly-shaped mock response for each metric endpoint. */
function mockMetricResponse(url) {
if (url.includes("/api/metrics/prs"))
return { open: 2, merged: 8, closed: 1, avgReviewHours: 6, avgFirstReviewHours: 3, mergeRate: "80%" };
if (url.includes("/api/metrics/pr-breakdown"))
return { draft: 1, merged: 8, open: 2, closed: 1 };
if (url.includes("/api/metrics/issues"))
return { opened: 4, closed: 3, currentlyOpen: 1, avgCloseTimeDays: 2, trend: 1, mostActiveRepo: "demo/repo" };
if (url.includes("/api/metrics/repos") || url.includes("/api/metrics/pinned-repos"))
return { repos: [{ name: "demo/repo", commits: 12, url: "https://github.com/demo/repo" }] };
if (url.includes("/api/metrics/languages"))
return { languages: [{ language: "TypeScript", count: 12 }] };
if (url.includes("/api/metrics/streak"))
return { current: 3, longest: 9, lastCommitDate: "2026-05-18", totalActiveDays: 12 };
if (url.includes("/api/metrics/weekly-summary"))
return {
commits: { current: 10, previous: 7, delta: 3, trend: "up" },
prs: { thisWeek: { opened: 3, merged: 2 }, lastWeek: { opened: 1, merged: 1 } },
activeDays: { thisWeek: 5, lastWeek: 4 },
streak: 3,
topRepo: "demo/repo",
};
if (url.includes("/api/metrics/compare"))
return { user: { commits: 10 }, friend: { commits: 8 } };
if (url.includes("/api/metrics/repo-health"))
return { repositories: [] };
if (url.includes("/api/metrics/ci"))
return { successRate: 95, averageDurationMinutes: 3, flakiestWorkflow: null, totalRuns: 42, reposChecked: 5 };
if (url.includes("/api/metrics/activity"))
return { data: [] };
if (url.includes("/api/metrics/commit-time"))
return { data: [] };
if (url.includes("/api/metrics/personal-records"))
return { records: [] };
if (url.includes("/api/metrics/discussions"))
return { total: 0, answered: 0 };
if (url.includes("/api/metrics/pr-review-trend"))
return { trend: [] };
if (url.includes("/api/metrics/inactive-repos"))
return { repos: [] };
if (url.includes("/api/metrics/coding-time"))
return { hasData: false, not_configured: true, todaysSeconds: 0, totalSeconds7Days: 0, chartData: [], topLanguage: "", topProject: "" };
if (url.includes("/api/metrics/coding-activity-insights"))
return { hourlyCounts: [], mostActiveHour: { hour: 0, count: 0, label: "" }, leastActiveHour: { hour: 0, count: 0, label: "" }, totalActivities: 0, averageDailyCommits: 0, consistencyScore: 0, productivityLevel: "Low", timezone: "UTC" };
if (url.includes("/api/metrics/contributions"))
return { data: { "2026-05-16": 3, "2026-05-17": 5, "2026-05-18": 2 } };
return {};
}
const authSecret =
process.env.NEXTAUTH_SECRET ?? "playwright-placeholder-secret-that-is-long-enough";

test.beforeEach(async ({ page }) => {
const token = await encode({
Expand Down Expand Up @@ -167,21 +119,188 @@ test("notification bell opens and closes drawer", async ({ page }) => {
});
});

await page.goto("/dashboard", { waitUntil: "load" });
// Mock the dashboard widgets so the page does not crash before the bell is usable.
const metricRoutes = [
"**/api/metrics/prs**",
"**/api/metrics/pr-breakdown**",
"**/api/metrics/issues**",
"**/api/metrics/repos**",
"**/api/metrics/languages**",
"**/api/metrics/streak**",
"**/api/metrics/pinned-repos**",
"**/api/metrics/weekly-summary**",
"**/api/metrics/compare**",
"**/api/metrics/repo-health**",
"**/api/metrics/ci**",
"**/api/streak/freeze**",
"**/api/user/github-accounts**",
"**/api/integrations/jira**",
"**/api/metrics/activity**",
"**/api/metrics/commit-time**",
"**/api/metrics/personal-records**",
"**/api/metrics/discussions**",
"**/api/metrics/pr-review-trend**",
"**/api/metrics/inactive-repos**",
"**/api/local-coding/stats**",
"**/api/metrics/coding-time**",
"**/api/metrics/coding-activity-insights**",
];

for (const pattern of metricRoutes) {
await page.route(pattern, async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify(mockMetricResponse(route.request().url())),
});
});
}
await page.route("**/api/goals**", async (route) => {
await route.fulfill({ json: { goals: [] } });
});

await page.goto("/dashboard");

// Wait for the dashboard to fully render
// Ensure the dashboard has rendered before looking for the bell button.
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible({ timeout: 30000 });

// Find and click the notification bell
const bellButton = page.getByRole("button", { name: /Notifications/ });
const bellButton = page.getByTestId("notification-bell-button");

await expect(bellButton).toBeVisible({ timeout: 10000 });

await bellButton.click();
const drawerHeading = page.getByRole("heading", { name: "Notifications" });
await expect(drawerHeading).toBeVisible({ timeout: 5000 });
await expect(page.getByText("Test notification")).toBeVisible({ timeout: 5000 });
// Retry clicking if the button is briefly detached during client re-renders.
await page.waitForTimeout(1000);
for (let attempt = 0; attempt < 3; attempt++) {
try {
await bellButton.click();
break;
} catch (err) {
if (attempt === 2) throw err;
await page.waitForTimeout(200);
}
}

// Click again to close
await bellButton.click();
await expect(drawerHeading).not.toBeVisible({ timeout: 5000 });
const drawerHeading = page.getByRole("heading", {
name: "Notifications",
});

await expect(drawerHeading).toBeVisible();

await expect(
page.getByText("Test notification")
).toBeVisible();

await bellButton.click();

await expect(drawerHeading).not.toBeVisible();
});

function mockMetricResponse(url) {
if (url.includes("/api/metrics/prs")) {
return {
open: 0,
merged: 0,
closed: 0,
prs: [],
rate: 0,
trend: { direction: "same", percentage: 0 },
};
}

if (url.includes("/api/metrics/pr-breakdown")) {
return { labels: ["Open", "Merged", "Closed"], data: [0, 0, 0] };
}

if (url.includes("/api/metrics/issues")) {
return {
opened: 0,
closed: 0,
open: 0,
issues: [],
total: 0,
};
}

if (url.includes("/api/metrics/repos")) {
return { repos: [], total: 0 };
}

if (url.includes("/api/metrics/languages")) {
return { languages: [] };
}

if (url.includes("/api/metrics/streak")) {
return { currentStreak: 0, longestStreak: 0, dailyActivity: [] };
}

if (url.includes("/api/metrics/pinned-repos")) {
return { repos: [] };
}

if (url.includes("/api/metrics/weekly-summary")) {
return {
commits: { current: 0, previous: 0, delta: 0, trend: "same" },
prs: { thisWeek: { opened: 0, merged: 0 }, lastWeek: { opened: 0, merged: 0 } },
activeDays: { thisWeek: 0, lastWeek: 0 },
streak: 0,
topRepo: null,
};
}

if (url.includes("/api/metrics/compare")) {
return { friends: [] };
}

if (url.includes("/api/metrics/repo-health")) {
return { health: [] };
}

if (url.includes("/api/metrics/ci")) {
return { builds: [], summary: { passing: 0, failing: 0 } };
}

if (url.includes("/api/metrics/activity")) {
return { activity: [] };
}

if (url.includes("/api/metrics/commit-time")) {
return { hours: [] };
}

if (url.includes("/api/metrics/personal-records")) {
return { records: [] };
}

if (url.includes("/api/metrics/discussions")) {
return { discussions: [] };
}

if (url.includes("/api/metrics/pr-review-trend")) {
return { trend: [] };
}

if (url.includes("/api/metrics/inactive-repos")) {
return { repos: [] };
}

if (url.includes("/api/metrics/coding-time")) {
return { hours: [] };
}

if (url.includes("/api/metrics/coding-activity-insights")) {
return { insights: [] };
}

if (url.includes("/api/streak/freeze")) {
return { freezes: [] };
}

if (url.includes("/api/user/github-accounts")) {
return { accounts: [] };
}

if (url.includes("/api/integrations/jira")) {
return { credentials: null };
}

return {};
}
Loading
Loading