diff --git a/package-lock.json b/package-lock.json index a012c1542..3368a1ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4697,6 +4697,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -5803,6 +5804,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6607,6 +6609,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8163,6 +8166,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8332,6 +8336,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11808,6 +11813,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -13962,6 +13968,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -14132,6 +14139,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -14269,6 +14277,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -14281,6 +14290,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -16183,6 +16193,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16530,6 +16541,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index fd64f5ed9..a0164233b 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -1,5 +1,4 @@ import { getServerSession } from "next-auth"; -import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; @@ -25,10 +24,36 @@ async function fetchActiveDates( // stores each account's dates separately and merges them in the GET handler. const key = metricsCacheKey(cacheContext.userId, "streak", { githubLogin }); +<<<<<<< HEAD // withMetricsCache returns cached dates if available within the TTL window, // skipping all GitHub API calls below. This is the primary protection against // exhausting the Search API rate limit on repeated page loads. const dates = await withMetricsCache( +======= +function dateDiffDays(a: string, b: string): number { + return ( + (new Date(b).getTime() - new Date(a).getTime()) / (1000 * 60 * 60 * 24) + ); +} + +function toDateStr(d: Date): string { + return d.toISOString().slice(0, 10); +} + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session.githubLogin || !session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Fetch 90 days to calculate a meaningful longest streak + const since = new Date(); + since.setDate(since.getDate() - 90); + const sinceStr = since.toISOString().slice(0, 10); + + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=asc`, +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) { bypass: cacheContext.bypass, key, @@ -176,6 +201,7 @@ function calculateStreakFromDates( // totalActiveDays counts only days with real commits or freezes in the 90-day window, // not the full streak length — useful for the "active days" stat on the dashboard. totalActiveDays: commitDays.length, +<<<<<<< HEAD freezeDates: Array.from(freezeDates), }; } @@ -352,4 +378,7 @@ export async function GET(req: NextRequest) { } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } +======= + }); +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) } diff --git a/src/app/api/streak/freeze/route.ts b/src/app/api/streak/freeze/route.ts index 317b679f5..5a85e8ccb 100644 --- a/src/app/api/streak/freeze/route.ts +++ b/src/app/api/streak/freeze/route.ts @@ -1,3 +1,4 @@ +<<<<<<< HEAD import type { NextRequest } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; @@ -9,6 +10,11 @@ import { metricsCacheKey, withMetricsCache, } from "@/lib/metrics-cache"; +======= +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) export const dynamic = "force-dynamic"; @@ -16,14 +22,24 @@ function todayStr(): string { return new Date().toISOString().slice(0, 10); } +<<<<<<< HEAD +<<<<<<< HEAD // GET /api/streak/freeze // Returns whether the user currently has an unused freeze available. export async function GET(req: NextRequest) { +======= +======= +// GET /api/streak/freeze +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) +// Returns whether the user currently has an unused freeze available. +export async function GET() { +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) const session = await getServerSession(authOptions); if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } +<<<<<<< HEAD const user = await resolveAppUser(session.githubId, session.githubLogin); if (!user) return Response.json({ error: "User not found" }, { status: 404 }); @@ -43,21 +59,45 @@ export async function GET(req: NextRequest) { } async function getFreezeStatus(userId: string) { +======= + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) const today = todayStr(); const { data: pending } = await supabaseAdmin .from("streak_freezes") .select("id, freeze_date") +<<<<<<< HEAD .eq("user_id", userId) +======= + .eq("user_id", user.id) +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) .gte("freeze_date", today) .limit(1); const hasFreeze = Array.isArray(pending) && pending.length > 0; +<<<<<<< HEAD return { hasFreeze, freezeDate: hasFreeze ? pending![0].freeze_date : null }; } // POST /api/streak/freeze +======= + return Response.json({ hasFreeze, freezeDate: hasFreeze ? pending![0].freeze_date : null }); +} +<<<<<<< HEAD +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) +======= + +// POST /api/streak/freeze +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) // Inserts a freeze for today. Fails if the user already holds an unused freeze. export async function POST() { const session = await getServerSession(authOptions); @@ -65,11 +105,22 @@ export async function POST() { return Response.json({ error: "Unauthorized" }, { status: 401 }); } +<<<<<<< HEAD const user = await resolveAppUser(session.githubId, session.githubLogin); +======= + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) if (!user) return Response.json({ error: "User not found" }, { status: 404 }); const today = todayStr(); +<<<<<<< HEAD +<<<<<<< HEAD // Prevent users from stockpiling unused freezes const { count } = await supabaseAdmin .from("streak_freezes") @@ -86,10 +137,14 @@ export async function POST() { ); } +======= + // only 1 unused freeze at a time +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) const { data: existing } = await supabaseAdmin .from("streak_freezes") .select("id") .eq("user_id", user.id) +<<<<<<< HEAD .eq("freeze_date", today) .maybeSingle(); @@ -99,13 +154,38 @@ export async function POST() { { user_id: user.id, freeze_date: today }, { onConflict: "user_id,freeze_date" } ) +======= + .gte("freeze_date", today) + .limit(1); + + if (Array.isArray(existing) && existing.length > 0) { + return Response.json( + { error: "You already have an unused streak freeze." }, + { status: 409 } + ); + } + +======= +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) + const { data: freeze, error } = await supabaseAdmin + .from("streak_freezes") + .insert({ user_id: user.id, freeze_date: today }) +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) .select() .single(); if (error) { + // Unique constraint violation — already has a freeze for today + if (error.code === "23505") { + return Response.json( + { error: "You already have an unused streak freeze." }, + { status: 409 } + ); + } return Response.json({ error: "Failed to apply freeze." }, { status: 500 }); } +<<<<<<< HEAD const alreadyExisted = existing !== null; const statusCode = alreadyExisted ? 200 : 201; @@ -137,3 +217,10 @@ export async function DELETE() { return Response.json({ success: true }); } +======= + return Response.json({ freeze }, { status: 201 }); +} +<<<<<<< HEAD +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) +======= +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index aac71d790..8822a52f7 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -102,6 +102,7 @@ export default async function DashboardPage() { const session = await getServerSession(authOptions); if (!session) redirect("/"); +<<<<<<< HEAD return (
@@ -265,5 +266,46 @@ export default async function DashboardPage() {
+======= + if (!session) { + redirect("/"); + } + + return ( +
+ + +
+ {/* Live region: announces dynamic alerts to screen readers */} +
+ + + + {/* Row 1: Contribution graph + Streak */} +
+
+ +
+
+ +
+
+ + {/* Row 2: PR metrics */} +
+ +
+ + {/* Row 3: Top repos + Language breakdown + Goal tracker */} +
+ + + +
+
+
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) ); -} +} \ No newline at end of file diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 92b7511d1..bc90a7aac 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -1,6 +1,10 @@ "use client"; +<<<<<<< HEAD import { Suspense, useEffect, useMemo, useState } from "react"; +======= +import { useEffect, useRef, useState } from "react"; +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) import { useSession } from "next-auth/react"; import { redirect, useSearchParams } from "next/navigation"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; @@ -128,6 +132,7 @@ function SettingsPageContent() { const [accountsLoading, setAccountsLoading] = useState(true); const [saving, setSaving] = useState(false); const [copied, setCopied] = useState(false); +<<<<<<< HEAD const [removeError, setRemoveError] = useState(null); const [removingAccountId, setRemovingAccountId] = useState( null @@ -223,19 +228,18 @@ function SettingsPageContent() { setShowConfirmModal(false); setPendingPath(null); }; +======= + const statusRef = useRef(null); +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) - // Redirect to signin if not authenticated useEffect(() => { if (status === "unauthenticated") { redirect("/"); } }, [status]); - // Load settings on mount useEffect(() => { - if (status !== "authenticated" || !session?.githubLogin) { - return; - } + if (status !== "authenticated" || !session?.githubLogin) return; async function loadSettings() { try { @@ -369,9 +373,20 @@ function SettingsPageContent() { if (res.ok) { const updated = await res.json(); setSettings(updated); + if (statusRef.current) { + statusRef.current.textContent = value + ? "Public profile enabled. Your stats are now shareable." + : "Public profile disabled. Your stats are now private."; + } } else { console.error("Failed to update settings"); +<<<<<<< HEAD toast.error("Failed to update public profile setting"); +======= + if (statusRef.current) { + statusRef.current.textContent = "Failed to update settings. Please try again."; + } +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) } } catch (error) { console.error("Error updating settings:", error); @@ -541,6 +556,7 @@ function SettingsPageContent() { const copyShareLink = () => { if (!settings) return; const link = `${window.location.origin}/u/${settings.github_login}`; +<<<<<<< HEAD navigator.clipboard.writeText(link).then(() => { setCopied(true); toast.success("Link copied successfully!"); @@ -576,20 +592,29 @@ function SettingsPageContent() { } finally { setRemovingAccountId(null); } +======= + navigator.clipboard.writeText(link); + setCopied(true); + if (statusRef.current) { + statusRef.current.textContent = "Profile link copied to clipboard."; + } + setTimeout(() => setCopied(false), 2000); +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) }; if (status === "loading" || loading) { return (
-
+
{[1, 2, 3].map((i) => ( -
+
))}
@@ -602,7 +627,7 @@ function SettingsPageContent() { return (
-

+

Failed to load settings.

@@ -610,9 +635,21 @@ function SettingsPageContent() { ); } + const shareUrl = + typeof window !== "undefined" + ? `${window.location.origin}/u/${settings.github_login}` + : `/u/${settings.github_login}`; + return ( -
+
+ {/* Live region for status announcements */} +
+
+<<<<<<< HEAD
+======= +
+

Settings

+

+ Manage your profile and preferences +

+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
{statusMessage && ( @@ -647,17 +691,27 @@ function SettingsPageContent() { )} {/* Public Profile Section */} -
+
-

+

Public Profile

-

+

Share your GitHub stats with a public profile link

+<<<<<<< HEAD {/* Toggle Switch */} +======= + {/* Accessible Toggle Switch */} +
+ + {settings.is_public ? "On" : "Off"} + + +
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
{/* Share Link Section */} @@ -690,16 +778,25 @@ function SettingsPageContent() { Share Your Profile
+
)} +<<<<<<< HEAD {isDirty && (
@@ -1353,3 +1451,10 @@ export default function SettingsPage() { ); } +======= +
+
+
+ ); +} +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) diff --git a/src/app/error.tsx b/src/app/error.tsx index 962b06e4c..1aaf4c804 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect } from "react"; -import { getSafeErrorMessage } from "@/lib/error-utils"; +import Link from "next/link"; export default function Error({ error, @@ -11,23 +11,23 @@ export default function Error({ reset: () => void; }) { useEffect(() => { - if (process.env.NODE_ENV !== "production") { - console.error(error); - } + // Log to console in all envs; swap console.error for Sentry.captureException if needed + console.error("[DevTrack] Application error:", error); }, [error]); return (
+ {/* Icon */}
-
+
+ + {/* Branding */} +

+ DevTrack · 500 +

+

Something went wrong

-

- {getSafeErrorMessage(error)} +

+ An unexpected server error occurred. Our team has been notified. You + can try again or head back to the dashboard.

+ + {/* Error digest for support */} {error.digest && ( -

- Error ID: {error.digest} +

+ Error ID:{" "} + + {error.digest} +

)} - + + {/* Actions */} +
+ + + Go to Dashboard + +
); -} +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index e85566853..9cb4190ee 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,12 +22,17 @@ --control: #f1f5f9; --control-hover: #e2e8f0; --tooltip: #ffffff; +<<<<<<< HEAD --tooltip-foreground: #111827; --destructive-muted: rgba(239, 68, 68, 0.1); --destructive-muted-border: rgba(239, 68, 68, 0.3); --destructive-foreground: #ffffff; --shadow-soft: 0 12px 30px -20px rgba(37, 99, 235, 0.35); --shadow-medium: 0 18px 35px -24px rgba(37, 99, 235, 0.4); +======= + --tooltip-foreground: #0f172a; + --focus-ring: #6366f1; +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) } .dark { @@ -56,6 +61,7 @@ --control-hover: #344a67; --tooltip: #1a2538; --tooltip-foreground: #f8fafc; +<<<<<<< HEAD --destructive-muted: rgba(248, 113, 113, 0.1); --destructive-muted-border: rgba(248, 113, 113, 0.3); --destructive-foreground: #ffffff; @@ -77,6 +83,9 @@ body:has(.lnd-root) { body:has(.wrapped-root) { background: #020817 !important; background-color: #020817 !important; +======= + --focus-ring: #818cf8; +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) } body { @@ -353,3 +362,28 @@ body { .animate-shimmer { animation: shimmer 1.5s infinite; } + +/* ── Global focus styles (WCAG 2.1 AA) ── */ +/* Visible focus ring for all interactive elements */ +:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; + border-radius: 4px; +} + +/* Skip-to-content link – visually hidden until focused */ +.skip-link { + position: absolute; + top: -999px; + left: 0; + z-index: 9999; + padding: 0.5rem 1rem; + background: var(--accent); + color: var(--accent-foreground); + font-weight: 600; + border-radius: 0 0 4px 0; + text-decoration: none; +} +.skip-link:focus { + top: 0; +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2bc501382..6c9533d8c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -56,6 +56,7 @@ export default async function RootLayout({ children: React.ReactNode; }) { return ( +<<<<<<< HEAD