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 @@ [![Contributors](https://img.shields.io/github/contributors/Priyanshu-byte-coder/devtrack?color=brightgreen)](https://github.com/Priyanshu-byte-coder/devtrack/graphs/contributors) [![Last Commit](https://img.shields.io/github/last-commit/Priyanshu-byte-coder/devtrack)](https://github.com/Priyanshu-byte-coder/devtrack/commits/main) [![Open Issues](https://img.shields.io/github/issues/Priyanshu-byte-coder/devtrack)](https://github.com/Priyanshu-byte-coder/devtrack/issues) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/Priyanshu-byte-coder?label=sponsors&color=ea4aaa)](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 ( -