diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..fac40a5f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
\ No newline at end of file
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 00000000..7f5ba5c4
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: Priyanshu-byte-coder
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 9be35f72..68d65585 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -35,10 +35,10 @@ jobs:
run: npm ci
- name: Install Playwright browsers
- run: npx -y @playwright/test@1.49.1 install --with-deps chromium
+ run: npx playwright install --with-deps chromium
- name: Run Playwright tests
- run: npx -y @playwright/test@1.49.1 test
+ run: npx playwright test
- name: Upload Playwright report
uses: actions/upload-artifact@v4
diff --git a/.gitignore b/.gitignore
index befc7f4b..3620579f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,6 @@ Thumbs.db
desktop.ini
test-results/
+
+.vercel
+.env*
diff --git a/README.md b/README.md
index fbeaef69..369e8d8d 100644
--- a/README.md
+++ b/README.md
@@ -15,8 +15,9 @@
[](https://github.com/Priyanshu-byte-coder/devtrack/graphs/contributors)
[](https://github.com/Priyanshu-byte-coder/devtrack/commits/main)
[](https://github.com/Priyanshu-byte-coder/devtrack/issues)
+[](https://github.com/sponsors/Priyanshu-byte-coder)
-**[π Live Demo](https://devtrack-delta.vercel.app)** Β· **[π Dev Guide](./DEVELOPMENT.md)** Β· **[π Report Bug](https://github.com/Priyanshu-byte-coder/devtrack/issues/new?template=bug_report.md)** Β· **[β¨ Request Feature](https://github.com/Priyanshu-byte-coder/devtrack/issues/new?template=feature_request.md)**
+**[π Live Demo](https://devtrack-delta.vercel.app)** Β· **[π Dev Guide](./DEVELOPMENT.md)** Β· **[π Report Bug](https://github.com/Priyanshu-byte-coder/devtrack/issues/new?template=bug_report.md)** Β· **[β¨ Request Feature](https://github.com/Priyanshu-byte-coder/devtrack/issues/new?template=feature_request.md)** Β· **[π Sponsor](https://github.com/sponsors/Priyanshu-byte-coder)**
@@ -33,6 +34,7 @@
- [Getting Started](#-getting-started)
- [Roadmap](#-roadmap)
- [Contributing](#-contributing)
+- [Sponsors](#-sponsors)
- [License](#-license)
---
@@ -240,6 +242,21 @@ See **[CONTRIBUTING.md](./CONTRIBUTING.md)** for full guidelines, commit style,
---
+## π Sponsors
+
+DevTrack is free and open source. Sponsoring helps cover infrastructure costs (Supabase, Vercel, API usage) and lets me dedicate more time to new features.
+
+| Tier | Amount | Perks |
+|------|--------|-------|
+| β Coffee | $5 / mo | Your name in this README |
+| π― Backer | $15 / mo | Name + priority response on issues |
+| π Champion | $50 / mo | Name + logo in README + feature request priority |
+| π One-time | $10+ | One-time thanks, no recurring commitment |
+
+**[β Sponsor DevTrack on GitHub](https://github.com/sponsors/Priyanshu-byte-coder)**
+
+---
+
## π License
MIT β see [LICENSE](./LICENSE) for details.
diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js
index 6f6fa745..18d43b32 100644
--- a/e2e/dashboard-widgets.spec.js
+++ b/e2e/dashboard-widgets.spec.js
@@ -171,6 +171,9 @@ test.beforeEach(async ({ page }) => {
"**/api/metrics/pr-review-trend**",
"**/api/metrics/inactive-repos**",
"**/api/notifications**",
+ "**/api/local-coding/stats**",
+ "**/api/metrics/coding-time**",
+ "**/api/metrics/coding-activity-insights**",
];
for (const pattern of metricRoutes) {
@@ -182,6 +185,15 @@ for (const pattern of metricRoutes) {
});
}
+ await page.route("**/api/stream**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "text/event-stream",
+ body: "data: {}\n\n",
+ });
+ });
+
+
});
test("dashboard widgets render with mocked metrics", async ({ page }) => {
await page.goto("/dashboard", { waitUntil: "load" });
@@ -293,5 +305,35 @@ function mockMetricResponse(url) {
if (url.includes("/api/user/github-accounts")) {
return { accounts: [] };
}
+ if (url.includes("/api/local-coding/stats")) {
+ return {
+ dailyData: [],
+ totals: { totalSeconds: 0, totalDays: 0, avgSecondsPerDay: 0 },
+ hasData: false,
+ };
+ }
+ 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",
+ };
+ }
return {};
}
diff --git a/e2e/landing.spec.js b/e2e/landing.spec.js
index 14a93a82..55803e33 100644
--- a/e2e/landing.spec.js
+++ b/e2e/landing.spec.js
@@ -33,8 +33,6 @@ test("landing page shows dashboard link", async ({ page }) => {
test("landing shows footer", async ({ page }) => {
await page.goto("/");
-
await expect(page.getByRole("contentinfo").first()).toBeVisible();
});
-
diff --git a/package-lock.json b/package-lock.json
index 4d540824..c47f690f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4564,6 +4564,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
diff --git a/src/app/api/metrics/activity/route.ts b/src/app/api/metrics/activity/route.ts
index 1737b797..7b91f634 100644
--- a/src/app/api/metrics/activity/route.ts
+++ b/src/app/api/metrics/activity/route.ts
@@ -14,15 +14,14 @@ import {
} from "@/lib/metrics-cache";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";
-
-export const dynamic = "force-dynamic";
-
import {
type ActivityItem,
type RawEvent,
formatActivity,
} from "@/lib/activity-formatter";
+export const dynamic = "force-dynamic";
+
async function fetchFormattedActivity(token: string): Promise {
const events = (await fetchUserEvents(token)) as RawEvent[];
diff --git a/src/app/api/metrics/coding-time/route.ts b/src/app/api/metrics/coding-time/route.ts
deleted file mode 100644
index d2405e73..00000000
--- a/src/app/api/metrics/coding-time/route.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { getServerSession } from "next-auth";
-import { NextRequest, NextResponse } from "next/server";
-import { authOptions } from "@/lib/auth";
-import { supabaseAdmin } from "@/lib/supabase";
-import { resolveAppUser } from "@/lib/resolve-user";
-import { decryptToken } from "@/lib/crypto";
-
-export const dynamic = "force-dynamic";
-
-export async function GET(req: NextRequest) {
- try {
- const session = await getServerSession(authOptions);
-
- if (!session?.githubId) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const user = await resolveAppUser(session.githubId, session.githubLogin);
- if (!user) {
- return NextResponse.json({ error: "User not found" }, { status: 404 });
- }
-
- const { data: userData, error: userError } = await supabaseAdmin
- .from("users")
- .select("wakatime_api_key_encrypted, wakatime_api_key_iv")
- .eq("id", user.id)
- .single();
-
- if (userError || !userData?.wakatime_api_key_encrypted || !userData?.wakatime_api_key_iv) {
- return NextResponse.json({ error: "Wakatime not configured", not_configured: true }, { status: 404 });
- }
-
- const apiKey = decryptToken(userData.wakatime_api_key_encrypted, userData.wakatime_api_key_iv);
- if (!apiKey) {
- return NextResponse.json({ error: "Failed to decrypt API key", not_configured: true }, { status: 500 });
- }
-
- const authHeader = `Basic ${Buffer.from(apiKey + ":").toString("base64")}`;
-
- const res = await fetch("https://wakatime.com/api/v1/users/current/summaries?range=Last%207%20Days", {
- headers: { Authorization: authHeader },
- cache: "no-store",
- });
-
- if (!res.ok) {
- console.error("Wakatime API error:", res.status, res.statusText);
- return NextResponse.json({ error: "Failed to fetch from Wakatime" }, { status: 500 });
- }
-
- const json = await res.json();
- const summaries = json.data || [];
-
- if (summaries.length === 0) {
- return NextResponse.json({ hasData: false });
- }
-
- // Process data
- const today = summaries[summaries.length - 1];
- const todaysSeconds = today?.grand_total?.total_seconds || 0;
-
- let totalSeconds7Days = 0;
- const languagesMap: Record = {};
- const projectsMap: Record = {};
-
- const chartData = summaries.map((day: any) => {
- const dateStr = day.range.date; // e.g. 2023-10-01
- const totalSeconds = day.grand_total.total_seconds || 0;
- totalSeconds7Days += totalSeconds;
-
- day.languages?.forEach((lang: any) => {
- languagesMap[lang.name] = (languagesMap[lang.name] || 0) + lang.total_seconds;
- });
-
- day.projects?.forEach((proj: any) => {
- projectsMap[proj.name] = (projectsMap[proj.name] || 0) + proj.total_seconds;
- });
-
- return {
- date: dateStr,
- hours: parseFloat((totalSeconds / 3600).toFixed(2)),
- };
- });
-
- const getTop = (map: Record) => {
- return Object.entries(map).sort((a, b) => b[1] - a[1])[0]?.[0] || "None";
- };
-
- return NextResponse.json({
- hasData: true,
- todaysSeconds,
- totalSeconds7Days,
- chartData,
- topLanguage: getTop(languagesMap),
- topProject: getTop(projectsMap),
- });
- } catch (error) {
- console.error("Error fetching Wakatime metrics:", error);
- return NextResponse.json({ error: "Internal server error" }, { status: 500 });
- }
-}
diff --git a/src/app/api/metrics/compare/route.ts b/src/app/api/metrics/compare/route.ts
index fbcd933f..f5642e00 100644
--- a/src/app/api/metrics/compare/route.ts
+++ b/src/app/api/metrics/compare/route.ts
@@ -4,6 +4,13 @@ import { authOptions } from "@/lib/auth";
import { dateDiffDays, toDateStr } from "@/lib/dateUtils";
import { normalizeGitHubUsername } from "@/lib/validate-github-username";
+import {
+ isMetricsCacheBypassed,
+ METRICS_CACHE_TTL_SECONDS,
+ metricsCacheKey,
+ withMetricsCache,
+} from "@/lib/metrics-cache";
+
export const dynamic = "force-dynamic";
const GITHUB_API = "https://api.github.com";
@@ -34,6 +41,24 @@ export async function GET(req: NextRequest) {
}
const encodedUsername = encodeURIComponent(normalizedUsername);
+ const bypass = isMetricsCacheBypassed(req);
+ const cacheKey = metricsCacheKey(
+ session.githubId ?? session.githubLogin,
+ "compare",
+ {
+ username: normalizedUsername,
+ }
+);
+
+try {
+ const data = await withMetricsCache(
+ {
+ bypass,
+ key: cacheKey,
+ ttlSeconds: METRICS_CACHE_TTL_SECONDS.compare,
+ },
+ async () => {
+
// 1. Verify user exists
const userRes = await fetch(`${GITHUB_API}/users/${encodedUsername}`, {
@@ -42,10 +67,13 @@ export async function GET(req: NextRequest) {
});
if (!userRes.ok) {
- if (userRes.status === 404) return Response.json({ error: "User not found" }, { status: 404 });
- return Response.json({ error: "GitHub API error or User is private" }, { status: 502 });
+ if (userRes.status === 404) {
+ throw new Error("User not found");
}
+ throw new Error("GitHub API error or User is private");
+}
+
// 2. Commits & Streak (fetch 90 days)
const since90 = new Date();
since90.setDate(since90.getDate() - 90);
@@ -149,11 +177,28 @@ export async function GET(req: NextRequest) {
prs = prsData.total_count || 0;
}
- return Response.json({
- username: normalizedUsername,
- streak,
- commits30d,
- topLanguage,
- prs
- });
+ return {
+ username: normalizedUsername,
+ streak,
+ commits30d,
+ topLanguage,
+ prs,
+};
+ }
+);
+
+return Response.json(data);
+} catch (error) {
+ if (error instanceof Error && error.message === "User not found") {
+ return Response.json(
+ { error: "User not found" },
+ { status: 404 }
+ );
+ }
+
+ return Response.json(
+ { error: "GitHub API error or User is private" },
+ { status: 502 }
+ );
}
+}
\ No newline at end of file
diff --git a/src/app/api/metrics/repo-analytics/route.ts b/src/app/api/metrics/repo-analytics/route.ts
new file mode 100644
index 00000000..52af8216
--- /dev/null
+++ b/src/app/api/metrics/repo-analytics/route.ts
@@ -0,0 +1,129 @@
+import { getServerSession } from "next-auth";
+import { NextRequest } from "next/server";
+import { authOptions } from "@/lib/auth";
+import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache";
+import { computeHealthScore } from "@/lib/repo-health";
+import { RepoAnalyticsResponse } from "@/lib/repoAnalytics";
+
+export const dynamic = "force-dynamic";
+const GITHUB_API = "https://api.github.com";
+
+const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
+
+export async function GET(req: NextRequest) {
+ const session = await getServerSession(authOptions);
+ if (!session?.accessToken || !session.githubLogin) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const repoParam = req.nextUrl.searchParams.get("repo");
+ if (!repoParam) return Response.json({ error: "Missing repo parameter" }, { status: 400 });
+
+ const bypass = isMetricsCacheBypassed(req);
+ const key = metricsCacheKey(session.githubId ?? session.githubLogin, `repo-analytics-${repoParam}` as any, { days: 30 });
+
+ try {
+ const data = await withMetricsCache({ bypass, key, ttlSeconds: 60 * 60 }, async () => {
+ const repoRes = await fetch(`${GITHUB_API}/repos/${repoParam}`, {
+ headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" },
+ cache: "no-store",
+ });
+ if (!repoRes.ok) throw new Error("API error fetching repo overview");
+ const repoData = await repoRes.json();
+
+ const contribRes = await fetch(`${GITHUB_API}/repos/${repoParam}/contributors?per_page=10`, {
+ headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" },
+ cache: "no-store",
+ });
+ const contribData = contribRes.ok ? await contribRes.json() : [];
+
+ const langRes = await fetch(`${GITHUB_API}/repos/${repoParam}/languages`, {
+ headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" },
+ cache: "no-store",
+ });
+ const langData = langRes.ok ? await langRes.json() : {};
+
+ const totalBytes = Object.values(langData).reduce((a: any, b: any) => a + b, 0) as number;
+ const languageBreakdown = Object.entries(langData)
+ .map(([name, bytes]: [string, any], index) => ({
+ name,
+ percentage: totalBytes > 0 ? Math.round((bytes / totalBytes) * 100) : 0,
+ color: COLORS[index % COLORS.length]
+ }))
+ .sort((a, b) => b.percentage - a.percentage);
+
+ const primaryStack = languageBreakdown.slice(0, 3).map((l) => l.name);
+
+ const activityRes = await fetch(`${GITHUB_API}/repos/${repoParam}/stats/commit_activity`, {
+ headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" },
+ cache: "no-store",
+ });
+
+ let timeline: { date: string; events: number }[] = [];
+ if (activityRes.ok && activityRes.status === 200) {
+ const activityData = await activityRes.json();
+ if (Array.isArray(activityData) && activityData.length > 0) {
+ const lastWeek = activityData[activityData.length - 1];
+ const days = lastWeek.days || [];
+ const today = new Date();
+ for (let i = 0; i < 7; i++) {
+ const d = new Date(today);
+ d.setDate(d.getDate() - (6 - i));
+ timeline.push({
+ date: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
+ events: days[i] || 0
+ });
+ }
+ }
+ }
+
+ if (timeline.length === 0) {
+ for (let i = 6; i >= 0; i--) {
+ const d = new Date();
+ d.setDate(d.getDate() - i);
+ timeline.push({ date: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), events: 0 });
+ }
+ }
+
+ const healthSignals = {
+ commitFrequency: timeline.reduce((a, b) => a + b.events, 0),
+ prMergeRate: 0.8,
+ avgPrOpenTimeHours: 24,
+ openIssuesCount: repoData.open_issues_count || 0,
+ daysSinceLastCommit: 1,
+ };
+
+ const health = computeHealthScore(repoData.name, healthSignals);
+
+ const result: RepoAnalyticsResponse = {
+ overview: {
+ description: repoData.description,
+ stars: repoData.stargazers_count,
+ forks: repoData.forks_count,
+ openIssues: repoData.open_issues_count,
+ watchers: repoData.subscribers_count || repoData.watchers_count || 0,
+ license: repoData.license?.name || "No License",
+ defaultBranch: repoData.default_branch,
+ createdAt: repoData.created_at,
+ updatedAt: repoData.updated_at,
+ },
+ contributors: Array.isArray(contribData) ? contribData.map((c: any) => ({
+ login: c.login,
+ avatarUrl: c.avatar_url,
+ contributions: c.contributions
+ })) : [],
+ timeline,
+ health,
+ primaryStack,
+ languageBreakdown
+ };
+
+ return result;
+ });
+
+ return Response.json(data);
+ } catch (error) {
+ console.error(error);
+ return Response.json({ error: "GitHub API error" }, { status: 502 });
+ }
+}
diff --git a/src/app/api/metrics/repo-explorer/route.ts b/src/app/api/metrics/repo-explorer/route.ts
new file mode 100644
index 00000000..31c34f1b
--- /dev/null
+++ b/src/app/api/metrics/repo-explorer/route.ts
@@ -0,0 +1,103 @@
+import { getServerSession } from "next-auth";
+import { NextRequest } from "next/server";
+import { authOptions } from "@/lib/auth";
+import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache";
+import { ExplorerRepoCardData } from "@/lib/repoAnalytics";
+
+export const dynamic = "force-dynamic";
+const GITHUB_API = "https://api.github.com";
+
+export async function GET(req: NextRequest) {
+ const session = await getServerSession(authOptions);
+ if (!session?.accessToken || !session.githubLogin) {
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const bypass = isMetricsCacheBypassed(req);
+ const key = metricsCacheKey(session.githubId ?? session.githubLogin, "repo-explorer-v2" as any, { days: 7 });
+
+ try {
+ const data = await withMetricsCache({ bypass, key, ttlSeconds: 30 * 60 }, async () => {
+ // 1. Fetch user repos (up to 100 to show more repos)
+ const reposRes = await fetch(`${GITHUB_API}/user/repos?sort=pushed&per_page=100`, {
+ headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" },
+ cache: "no-store",
+ });
+
+ if (!reposRes.ok) throw new Error("API error fetching repos");
+ const repos = await reposRes.json();
+
+ // 2. Fetch last 30 days of commits across all repos for the user
+ const since = new Date();
+ since.setDate(since.getDate() - 30);
+ const sinceStr = since.toISOString().slice(0, 10);
+
+ const searchRes = await fetch(`${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, {
+ headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" },
+ cache: "no-store",
+ });
+
+ const repoCommits: Record = {};
+
+ if (searchRes.ok) {
+ const searchData = await searchRes.json();
+ const items = searchData.items || [];
+ for (const item of items) {
+ const repoName = item.repository.full_name;
+ if (!repoCommits[repoName]) {
+ repoCommits[repoName] = [];
+ }
+ repoCommits[repoName].push(item.commit.author.date);
+ }
+ }
+
+ const result: ExplorerRepoCardData[] = [];
+
+ for (const repo of repos) {
+ const commitDates = repoCommits[repo.full_name] || [];
+ const commitCount = commitDates.length;
+
+ const dayMap: Record = {};
+ for (let i = 6; i >= 0; i--) {
+ const d = new Date();
+ d.setDate(d.getDate() - i);
+ dayMap[d.toISOString().slice(0, 10)] = 0;
+ }
+
+ for (const dateStr of commitDates) {
+ const dStr = dateStr.slice(0, 10);
+ if (dayMap[dStr] !== undefined) {
+ dayMap[dStr]++;
+ }
+ }
+
+ const activity7d = Object.entries(dayMap).map(([date, count]) => {
+ const d = new Date(date);
+ const dayName = d.toLocaleDateString('en-US', { weekday: 'short' });
+ return { day: dayName, commits: count };
+ });
+
+ result.push({
+ id: String(repo.id),
+ name: repo.name,
+ fullName: repo.full_name,
+ commitCount, // 30-day commit count
+ createdAt: repo.created_at,
+ updatedAt: repo.updated_at,
+ primaryLanguage: repo.language,
+ htmlUrl: repo.html_url,
+ activity7d,
+ });
+ }
+
+ // Sort result by activity and recency
+ result.sort((a, b) => b.commitCount - a.commitCount || new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
+
+ return { repos: result };
+ });
+ return Response.json(data);
+ } catch (error) {
+ console.error(error);
+ return Response.json({ error: "GitHub API error" }, { status: 502 });
+ }
+}
diff --git a/src/app/api/wakatime/route.ts b/src/app/api/wakatime/route.ts
new file mode 100644
index 00000000..316be6f9
--- /dev/null
+++ b/src/app/api/wakatime/route.ts
@@ -0,0 +1,83 @@
+import { NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { supabaseAdmin } from "@/lib/supabase";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.githubId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { data: user } = await supabaseAdmin
+ .from("users")
+ .select("id, wakatime_api_key_encrypted")
+ .eq("github_id", session.githubId)
+ .single();
+
+ if (!user) {
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
+ }
+
+ if (!user.wakatime_api_key_encrypted) {
+ return NextResponse.json({ hasData: false, not_configured: true });
+ }
+
+ // Get last 7 days of stats
+ const date7DaysAgo = new Date();
+ date7DaysAgo.setDate(date7DaysAgo.getDate() - 7);
+ const dateStr = date7DaysAgo.toISOString().split("T")[0];
+
+ const { data: stats, error } = await supabaseAdmin
+ .from("wakatime_stats")
+ .select("*")
+ .eq("user_id", user.id)
+ .gte("date", dateStr)
+ .order("date", { ascending: true });
+
+ if (error || !stats || stats.length === 0) {
+ return NextResponse.json({ hasData: false });
+ }
+
+ // Process data from DB cache
+ const today = stats[stats.length - 1];
+ const todaysSeconds = today?.total_seconds || 0;
+
+ let totalSeconds7Days = 0;
+ const languagesMap: Record = {};
+ const projectsMap: Record = {};
+
+ const chartData = stats.map((day: any) => {
+ const totalSeconds = day.total_seconds || 0;
+ totalSeconds7Days += totalSeconds;
+
+ (day.languages || []).forEach((lang: any) => {
+ languagesMap[lang.name] = (languagesMap[lang.name] || 0) + lang.total_seconds;
+ });
+
+ (day.projects || []).forEach((proj: any) => {
+ projectsMap[proj.name] = (projectsMap[proj.name] || 0) + proj.total_seconds;
+ });
+
+ return {
+ date: day.date,
+ hours: parseFloat((totalSeconds / 3600).toFixed(2)),
+ };
+ });
+
+ const getTop = (map: Record) => {
+ return Object.entries(map).sort((a, b) => b[1] - a[1])[0]?.[0] || "None";
+ };
+
+ return NextResponse.json({
+ hasData: true,
+ todaysSeconds,
+ totalSeconds7Days,
+ chartData,
+ topLanguage: getTop(languagesMap),
+ topProject: getTop(projectsMap),
+ });
+}
diff --git a/src/app/api/wakatime/sync/route.ts b/src/app/api/wakatime/sync/route.ts
new file mode 100644
index 00000000..c1dbd8f1
--- /dev/null
+++ b/src/app/api/wakatime/sync/route.ts
@@ -0,0 +1,91 @@
+import { NextResponse } from "next/server";
+import { supabaseAdmin } from "@/lib/supabase";
+import { decryptToken } from "@/lib/crypto";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(req: Request) {
+ // To secure the cron job, check the authorization header
+ const authHeader = req.headers.get("authorization");
+ if (authHeader !== `Bearer ${process.env.CRON_SECRET}` && process.env.NODE_ENV !== "development") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ // Fetch users with wakatime keys
+ const { data: users, error } = await supabaseAdmin
+ .from("users")
+ .select("id, wakatime_api_key_encrypted, wakatime_api_key_iv")
+ .not("wakatime_api_key_encrypted", "is", null)
+ .not("wakatime_api_key_iv", "is", null);
+
+ if (error) {
+ console.error("Failed to fetch users for wakatime sync:", error);
+ return NextResponse.json({ error: "Database error" }, { status: 500 });
+ }
+
+ let successCount = 0;
+ let failureCount = 0;
+
+ // Process in chunks to avoid overwhelming the API and improve speed
+ const CHUNK_SIZE = 5;
+ for (let i = 0; i < users.length; i += CHUNK_SIZE) {
+ const chunk = users.slice(i, i + CHUNK_SIZE);
+
+ await Promise.allSettled(chunk.map(async (user) => {
+ try {
+ const apiKey = decryptToken(
+ user.wakatime_api_key_encrypted!,
+ user.wakatime_api_key_iv!
+ );
+
+ if (!apiKey) {
+ console.error(`Decryption failed for user ${user.id}`);
+ failureCount++;
+ return;
+ }
+
+ // Fetch from Wakatime with no-store cache
+ const res = await fetch("https://wakatime.com/api/v1/users/current/summaries?range=Last%207%20Days", {
+ headers: {
+ Authorization: `Basic ${Buffer.from(apiKey + ":").toString("base64")}`,
+ },
+ cache: "no-store"
+ });
+
+ if (!res.ok) {
+ console.error(`Wakatime API error for user ${user.id}: ${res.status}`);
+ failureCount++;
+ return;
+ }
+
+ const data = await res.json();
+ const now = new Date().toISOString();
+
+ const statsToUpsert = data.data.map((day: any) => ({
+ user_id: user.id,
+ date: day.range.date,
+ total_seconds: Math.round(day.grand_total.total_seconds),
+ languages: day.languages.map((l: any) => ({ name: l.name, total_seconds: l.total_seconds, percent: l.percent })),
+ projects: day.projects.map((p: any) => ({ name: p.name, total_seconds: p.total_seconds, percent: p.percent })),
+ updated_at: now
+ }));
+
+ const { error: upsertError } = await supabaseAdmin
+ .from("wakatime_stats")
+ .upsert(statsToUpsert, { onConflict: "user_id, date" });
+
+ if (upsertError) {
+ console.error(`Failed to upsert wakatime stats for user ${user.id}:`, upsertError);
+ failureCount++;
+ } else {
+ successCount++;
+ }
+ } catch (e) {
+ console.error(`Error processing wakatime stats for user ${user.id}:`, e);
+ failureCount++;
+ }
+ }));
+ }
+
+ return NextResponse.json({ success: successCount, failure: failureCount });
+}
diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx
index d4b47549..e54b67be 100644
--- a/src/app/auth/signin/page.tsx
+++ b/src/app/auth/signin/page.tsx
@@ -75,6 +75,9 @@ export default function SignInPage() {
textAlign: "center",
position: "relative",
zIndex: 1,
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
}}
>
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 89ba88f5..617bea42 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -10,6 +10,7 @@ import LanguageBreakdown from "@/components/LanguageBreakdown";
import CIAnalytics from "@/components/CIAnalytics";
import IssueMetrics from "@/components/IssueMetrics";
import StreakAtRiskBanner from "@/components/StreakAtRiskBanner";
+import RepoAnalyticsExplorer from "@/components/repo-analytics/RepoAnalyticsExplorer";
import dynamic from "next/dynamic";
const SkeletonCard = () => (
@@ -88,7 +89,7 @@ import ExportButton from "@/components/ExportButton";
import Link from "next/link";
import PersonalRecords from "@/components/PersonalRecords";
import LocalCodingTime from "@/components/LocalCodingTime";
-import CodingTimeCard from "@/components/CodingTimeCard";
+import CodingTimeWidget from "@/components/CodingTimeWidget";
import RecentActivity from "@/components/RecentActivity";
import { authOptions } from "@/lib/auth";
import { getServerSession } from "next-auth";
@@ -163,13 +164,16 @@ export default async function DashboardPage() {
+
+
+
diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx
index 932100ba..720c50fc 100644
--- a/src/app/dashboard/settings/page.tsx
+++ b/src/app/dashboard/settings/page.tsx
@@ -334,16 +334,13 @@ function SettingsPageContent() {
const copyShareLink = () => {
if (!settings) return;
const link = `${window.location.origin}/u/${settings.github_login}`;
- navigator.clipboard
- .writeText(link)
- .then(() => {
- setCopied(true);
- toast.success("Link copied successfully!");
- setTimeout(() => setCopied(false), 2000);
- })
- .catch(() => {
- toast.error("Failed to copy link");
- });
+ navigator.clipboard.writeText(link).then(() => {
+ setCopied(true);
+ toast.success("Link copied successfully!");
+ setTimeout(() => setCopied(false), 2000);
+ }).catch(() => {
+ toast.error("Failed to copy link");
+ });
};
const handleRemoveAccount = async (githubId: string) => {
@@ -438,7 +435,6 @@ function SettingsPageContent() {
{statusMessage.message}
)}
-
{/* Public Profile Section */}
@@ -462,16 +458,14 @@ function SettingsPageContent() {
className="sr-only"
/>
@@ -592,16 +586,14 @@ function SettingsPageContent() {
className="sr-only"
/>
@@ -700,7 +692,7 @@ function SettingsPageContent() {
-
+
- {Analytics ?
: null}
- {SpeedInsights ?
: null}
+
);
-}
\ No newline at end of file
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 72a2a76e..5c5148b5 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,5 +1,7 @@
import ParticleBackground from "@/components/ParticleBackground";
-import { Syne, DM_Sans, JetBrains_Mono } from "next/font/google";
+
+import { Syne, DM_Sans, JetBrains_Mono } from 'next/font/google';
+
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { redirect } from "next/navigation";
diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx
index 4dd7e73c..fc5fed22 100644
--- a/src/app/u/[username]/page.tsx
+++ b/src/app/u/[username]/page.tsx
@@ -1,4 +1,5 @@
import { Metadata } from "next";
+import { redirect } from "next/navigation";
import BadgeSection from "@/components/BadgeSection";
import GitHubAchievements from "@/components/GitHubAchievements";
import StatsCard from "@/components/StatsCard";
@@ -6,6 +7,10 @@ import CopyLinkButton from "@/components/CopyLinkButton";
import ThemeToggle from "@/components/ThemeToggle";
import { getUserByUsername } from "@/lib/supabase";
import { syncGitHubAchievementsForUser } from "@/lib/github-achievements";
+
+
+
+
import {
fetchPublicTopRepos,
fetchPublicContributions,
@@ -18,9 +23,17 @@ async function fetchPublicProfile(
options: { includeAchievements?: boolean } = {}
): Promise
{
const user = await getUserByUsername(username);
+
if (!user) return null;
+ const canonicalUsername = user.github_login.toLowerCase();
+
+ if (username !== canonicalUsername) {
+ redirect(`/u/${canonicalUsername}`);
+ }
+
const githubToken = process.env.GITHUB_TOKEN;
+
const [repos, contributions, streak, achievementsCache] = await Promise.all([
fetchPublicTopRepos(user.github_login, githubToken, 30),
fetchPublicContributions(user.github_login, githubToken, 30),
diff --git a/src/components/AIMentorWidget.tsx b/src/components/AIMentorWidget.tsx
index 3a9294e1..0c8b150e 100644
--- a/src/components/AIMentorWidget.tsx
+++ b/src/components/AIMentorWidget.tsx
@@ -83,6 +83,11 @@ export function AIMentorWidget() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isCollapsed, setIsCollapsed] = useState(false);
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
useEffect(() => {
fetch("/api/ai-insights?type=weekly_summary", { cache: "no-store" })
@@ -109,10 +114,13 @@ export function AIMentorWidget() {
if (!data) return null;
- const formattedDate = new Date(data.generatedAt).toLocaleDateString(
- undefined,
- { month: "short", day: "numeric", year: "numeric" }
- );
+ const formattedDate = mounted
+ ? new Date(data.generatedAt).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ : "";
return (
diff --git a/src/components/AccountToggle.tsx b/src/components/AccountToggle.tsx
index c799b18f..e91e6213 100644
--- a/src/components/AccountToggle.tsx
+++ b/src/components/AccountToggle.tsx
@@ -77,7 +77,7 @@ export default function AccountToggle() {
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
isActive
? "border-[var(--accent)] bg-[var(--accent)] text-[var(--accent-foreground)]"
- : "border-[var(--border)] bg-[var(--control)] text-[var(--card-foreground)] hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]"
+ : "border-[var(--card-muted)] bg-[var(--card-muted)] text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]"
}`}
>
{option.label}
diff --git a/src/components/CodingActivityInsightsCard.tsx b/src/components/CodingActivityInsightsCard.tsx
index 6fc4c35a..d83cfdd6 100644
--- a/src/components/CodingActivityInsightsCard.tsx
+++ b/src/components/CodingActivityInsightsCard.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { RefreshCw, Loader2 } from "lucide-react";
import {
Bar,
BarChart,
@@ -245,17 +246,22 @@ export default function CodingActivityInsightsCard() {
{subtitle}
-
+
+
{loading ? (
diff --git a/src/components/CodingTimeCard.tsx b/src/components/CodingTimeWidget.tsx
similarity index 97%
rename from src/components/CodingTimeCard.tsx
rename to src/components/CodingTimeWidget.tsx
index e38a6ee7..1af55156 100644
--- a/src/components/CodingTimeCard.tsx
+++ b/src/components/CodingTimeWidget.tsx
@@ -28,14 +28,14 @@ function formatDate(dateStr: string): string {
return date.toLocaleDateString("en-US", { weekday: "short" });
}
-export default function CodingTimeCard() {
+export default function CodingTimeWidget() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadStats() {
try {
- const res = await fetch("/api/metrics/coding-time");
+ const res = await fetch("/api/wakatime");
const json = await res.json();
setData(json);
} catch {
diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx
index ef3e6b58..0b53420a 100644
--- a/src/components/DashboardHeader.tsx
+++ b/src/components/DashboardHeader.tsx
@@ -56,23 +56,24 @@ export default function DashboardHeader() {
{/* Right Section */}
-
+
{isPublic === true && session?.githubLogin && (
Share Profile
)}
-
+
-
+
@@ -102,4 +103,4 @@ export default function DashboardHeader() {
);
-}
+}
\ No newline at end of file
diff --git a/src/components/DeferredVercelMetrics.tsx b/src/components/DeferredVercelMetrics.tsx
new file mode 100644
index 00000000..0d859866
--- /dev/null
+++ b/src/components/DeferredVercelMetrics.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { ComponentType } from "react";
+
+type IdleWindow = Window &
+ typeof globalThis & {
+ requestIdleCallback?: (
+ callback: IdleRequestCallback,
+ options?: IdleRequestOptions,
+ ) => number;
+ cancelIdleCallback?: (handle: number) => void;
+ };
+
+export default function DeferredVercelMetrics() {
+ const [shouldLoadMetrics, setShouldLoadMetrics] = useState(false);
+ const [Analytics, setAnalytics] = useState
(null);
+ const [SpeedInsights, setSpeedInsights] = useState(
+ null,
+ );
+
+ useEffect(() => {
+ if (shouldLoadMetrics) return;
+
+ const browserWindow: IdleWindow = window;
+ const loadMetrics = () => setShouldLoadMetrics(true);
+
+ if (browserWindow.requestIdleCallback && browserWindow.cancelIdleCallback) {
+ const idleCallbackId = browserWindow.requestIdleCallback(loadMetrics, {
+ timeout: 3000,
+ });
+
+ return () => browserWindow.cancelIdleCallback?.(idleCallbackId);
+ }
+
+ const timeoutId = browserWindow.setTimeout(loadMetrics, 2000);
+
+ return () => browserWindow.clearTimeout(timeoutId);
+ }, [shouldLoadMetrics]);
+
+ useEffect(() => {
+ if (!shouldLoadMetrics) return;
+
+ let isMounted = true;
+
+ import("@vercel/analytics/next")
+ .then((module) => {
+ if (isMounted) setAnalytics(() => module.Analytics ?? null);
+ })
+ .catch(() => {});
+
+ import("@vercel/speed-insights/next")
+ .then((module) => {
+ if (isMounted) setSpeedInsights(() => module.SpeedInsights ?? null);
+ })
+ .catch(() => {});
+
+ return () => {
+ isMounted = false;
+ };
+ }, [shouldLoadMetrics]);
+
+ if (!shouldLoadMetrics || (!Analytics && !SpeedInsights)) return null;
+
+ return (
+ <>
+ {Analytics ? : null}
+ {SpeedInsights ? : null}
+ >
+ );
+}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 37b70570..72aa497b 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -4,8 +4,9 @@ const year = new Date().getFullYear();
export default function Footer() {
return (
-