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
@@ -632,6 +669,13 @@ function SettingsPageContent() {
Manage your profile and preferences
+=======
+
+
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 */}
@@ -681,6 +735,40 @@ function SettingsPageContent() {
/>
+=======
+ {/* Accessible Toggle Switch */}
+
+
+ {settings.is_public ? "On" : "Off"}
+
+ handleTogglePublic(!settings.is_public)}
+ className={`relative inline-flex h-6 w-10 cursor-pointer items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent)] disabled:cursor-not-allowed disabled:opacity-60 ${
+ settings.is_public ? "bg-[var(--accent)]" : "bg-[var(--control)]"
+ }`}
+ >
+
+ {settings.is_public ? "Disable public profile" : "Enable public profile"}
+
+
+
+
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
{/* Share Link Section */}
@@ -690,16 +778,25 @@ function SettingsPageContent() {
Share Your Profile
+
+ Your public profile URL
+
>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-[var(--accent-foreground)] text-sm font-medium hover:opacity-90 transition-opacity"
>
{copied ? "Copied!" : "Copy"}
@@ -878,6 +975,7 @@ function SettingsPageContent() {
)}
+<<<<<<< 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}
+
)}
-
- Try again
-
+
+ {/* Actions */}
+
+
+ Try Again
+
+
+ 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
+=======
+
+
+ {/* Skip-to-content link for keyboard/screen-reader users */}
+
+ Skip to main content
+
+
{children}
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
);
-}
+}
\ No newline at end of file
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 57e6ba7e5..546d71727 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -2,35 +2,45 @@ import Link from "next/link";
export default function NotFound() {
return (
-
-
- {/* Branded 404 code */}
-
- 404
-
-
- Page Not Found
+
+
+ {/* Branding */}
+
+ DevTrack
+
+
+ {/* 404 number */}
+
+
+ 404
+
+
+
+ Page Not Found
+
+
-
-
-
- Lost in space?
+ {/* Message */}
+
+ Oops! This page doesn't exist.
-
- The page you are looking for might have been removed, had its name changed, or is temporarily unavailable. Let's get you back on track!
+
+ The page you're looking for may have been moved, renamed, or
+ never existed. Let's get you back on track.
-
+ {/* Actions */}
+
- Go to Dashboard
+ Go Back to Dashboard
Go Home
@@ -38,4 +48,4 @@ export default function NotFound() {
);
-}
+}
\ No newline at end of file
diff --git a/src/app/page.tsx b/src/app/page.tsx
index d298bc64f..1b8f72d49 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -109,8 +109,47 @@ export default async function HomePage() {
const stats = await fetchRepoStats();
return (
+<<<<<<< HEAD
+=======
+
+
+
DevTrack
+
+ Open-source developer productivity dashboard. Track coding habits,
+ visualize GitHub contributions, and hit your goals.
+
+
+
+
+
+
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
);
-}
+}
\ No newline at end of file
diff --git a/src/components/ContributionGraph.tsx b/src/components/ContributionGraph.tsx
index 3fd5db119..3e0f26d44 100644
--- a/src/components/ContributionGraph.tsx
+++ b/src/components/ContributionGraph.tsx
@@ -1,9 +1,12 @@
"use client";
import { useEffect, useRef, useState } from "react";
+<<<<<<< HEAD
import { useAccount } from "@/components/AccountContext";
import CommitSearchPanel from "@/components/CommitSearchPanel";
import type { CommitItem } from "@/lib/github";
+=======
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
import {
BarChart,
Bar,
@@ -110,6 +113,7 @@ export default function ContributionGraph() {
const [loading, setLoading] = useState(true);
const [days, setDays] = useState(30);
const [chartType, setChartType] = useState
("bar");
+<<<<<<< HEAD
const [lastUpdated, setLastUpdated] = useState(null);
const [minutesAgo, setMinutesAgo] = useState(0);
const [error, setError] = useState(null);
@@ -174,6 +178,9 @@ export default function ContributionGraph() {
} catch { }
}
};
+=======
+ const announcerRef = useRef(null);
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
useEffect(() => {
setLoading(true);
@@ -249,7 +256,15 @@ export default function ContributionGraph() {
const sorted = Object.entries(res.data ?? {})
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, commits]) => ({ day, commits }));
+<<<<<<< HEAD
setFriendData(sorted);
+=======
+ setData(sorted);
+ if (announcerRef.current) {
+ const total = sorted.reduce((sum, d) => sum + d.commits, 0);
+ announcerRef.current.textContent = `Commit activity chart updated: ${total} commits over the last ${days} days.`;
+ }
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
})
.catch(() => {
setCompareError("Failed to load friend data");
@@ -378,7 +393,10 @@ export default function ContributionGraph() {
const hasFriendData = compareMode && friendData.length > 0 && !compareError;
const tooltipTrigger = usesTouchTooltip ? "click" : "hover";
+ const totalCommits = data.reduce((sum, d) => sum + d.commits, 0);
+
return (
+<<<<<<< HEAD
+ {/* Live region for dynamic updates */}
+
+
+
+
+ Commit Activity
+
+
+
+ {/* Range selector */}
+
+ {RANGES.map((r) => (
+ setDays(r.days)}
+ aria-pressed={days === r.days}
+ aria-label={`Show last ${r.label}`}
+ className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
+ days === r.days
+ ? "bg-[var(--accent)] text-[var(--accent-foreground)]"
+ : "text-[var(--muted-foreground)] hover:text-[var(--card-foreground)]"
+ }`}
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
>
{r.label}
))}
+<<<<<<< HEAD
{/* Custom date range */}
0 && (
+ >>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
>
{charts.map((chart) => (
setChartType(chart.key)}
aria-pressed={chartType === chart.key}
+<<<<<<< HEAD
className={`px-3 py-1 rounded-md transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${chartType === chart.key
? "bg-[var(--accent)] text-[var(--background)]"
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
+=======
+ aria-label={`${chart.label} chart`}
+ className={`px-3 py-1 rounded-md transition-colors duration-200 ${
+ chartType === chart.key
+ ? "bg-[var(--accent)] text-[var(--accent-foreground)]"
+ : "text-[var(--muted-foreground)] hover:text-[var(--card-foreground)]"
+ }`}
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
>
{chart.label}
@@ -541,6 +612,7 @@ export default function ContributionGraph() {
{loading ? (
+ ) : data.length === 0 ? (
+
+ No commits in the last {days} days.
+
+ ) : (
+ <>
+ {/* Accessible text summary for screen readers */}
+
+ {chartType === "bar" ? "Bar" : "Line"} chart showing {totalCommits} total
+ commits over the last {days} days.
+
+
+ {chartType === "bar" ? (
+
+
+
+
+ >>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
border: "1px solid var(--border)",
borderRadius: "8px",
}}
labelStyle={{
+<<<<<<< HEAD
color: "var(--foreground)",
fontSize: "12px",
}}
@@ -679,10 +782,34 @@ export default function ContributionGraph() {
contentStyle={{
background: "var(--card)",
color: "var(--foreground)",
+=======
+ color: "var(--tooltip-foreground)",
+ fontSize: "12px",
+ }}
+ cursor={{ fill: "var(--card-muted)" }}
+ />
+
+
+ ) : (
+
+
+
+
+ >>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
border: "1px solid var(--border)",
borderRadius: "8px",
}}
labelStyle={{
+<<<<<<< HEAD
color: "var(--foreground)",
fontSize: "12px",
}}
@@ -741,7 +868,26 @@ export default function ContributionGraph() {
{!compareMode && (
+=======
+ color: "var(--tooltip-foreground)",
+ fontSize: "12px",
+ }}
+ cursor={{ fill: "var(--card-muted)" }}
+ />
+
+
+ )}
+
+ >
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
)}
-
+
);
}
diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx
index ef576eebc..d4942607c 100644
--- a/src/components/DashboardHeader.tsx
+++ b/src/components/DashboardHeader.tsx
@@ -152,6 +152,7 @@ export default function DashboardHeader() {
: null;
return (
+<<<<<<< HEAD
@@ -230,6 +231,37 @@ export default function DashboardHeader() {
+=======
+
);
-}
+}
\ No newline at end of file
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
new file mode 100644
index 000000000..66bab633d
--- /dev/null
+++ b/src/components/ErrorBoundary.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import React from "react";
+
+interface Props {
+ children: React.ReactNode;
+ /** Optional section name shown in the error card, e.g. "Contribution Graph" */
+ section?: string;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+export class ErrorBoundary extends React.Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
+ // Log to console; replace with Sentry.captureException(error, { extra: info }) if needed
+ console.error(
+ `[DevTrack ErrorBoundary]${this.props.section ? ` [${this.props.section}]` : ""}`,
+ error,
+ info.componentStack,
+ );
+ }
+
+ handleReset = () => {
+ this.setState({ hasError: false, error: null });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+ {/* Icon */}
+
+
+
+
+ {this.props.section
+ ? `${this.props.section} failed to load`
+ : "Something went wrong here"}
+
+
+ Try refreshing this section. If the problem persists, reload the
+ page.
+
+
+
+
+ Try Again
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
\ No newline at end of file
diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx
index 83920c352..a503e280a 100644
--- a/src/components/GoalTracker.tsx
+++ b/src/components/GoalTracker.tsx
@@ -1,9 +1,14 @@
"use client";
+<<<<<<< HEAD
import { useCallback, useEffect, useState, useRef } from "react";
import { submitGoalWithRefresh } from "@/lib/goal-tracker";
type Recurrence = "none" | "weekly" | "monthly";
+=======
+import { useCallback, useEffect, useRef, useState } from "react";
+import { stripHtml } from "@/lib/sanitize";
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
interface Goal {
id: string;
@@ -36,6 +41,7 @@ export default function GoalTracker() {
const [recurrence, setRecurrence] = useState("none");
const [deadline, setDeadline] = useState("");
const [creating, setCreating] = useState(false);
+<<<<<<< HEAD
const [createError, setCreateError] = useState(null);
const [confirmingId, setConfirmingId] = useState(null);
const [deletingId, setDeletingId] = useState(null);
@@ -44,6 +50,9 @@ export default function GoalTracker() {
const [activeConfettiGoalId, setActiveConfettiGoalId] = useState(null);
const prevGoalsRef = useRef>(new Map());
const initialLoadDoneRef = useRef(false);
+=======
+ const statusRef = useRef(null);
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
const loadGoals = useCallback(async () => {
const response = await fetch("/api/goals");
@@ -128,7 +137,16 @@ export default function GoalTracker() {
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
+<<<<<<< HEAD
setCreateError(null);
+=======
+
+ const sanitizedLabel = stripHtml(label).slice(0, 100);
+ if (!sanitizedLabel) {
+ setCreating(false);
+ return;
+ }
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
try {
const result = await submitGoalWithRefresh({
@@ -144,6 +162,7 @@ export default function GoalTracker() {
setTitle("");
setTarget(7);
+<<<<<<< HEAD
setUnit("commits");
setRecurrence("none");
setDeadline("");
@@ -156,6 +175,13 @@ export default function GoalTracker() {
}
} catch {
setCreateError("Failed to create goal. Please try again.");
+=======
+ await loadGoals();
+
+ if (statusRef.current) {
+ statusRef.current.textContent = `Goal "${sanitizedLabel}" added successfully.`;
+ }
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
} finally {
setCreating(false);
}
@@ -241,6 +267,7 @@ export default function GoalTracker() {
if (loading) {
return (
+<<<<<<< HEAD
Loading weekly goals
@@ -255,11 +282,26 @@ export default function GoalTracker() {
))}
+=======
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
);
}
return (
+<<<<<<< HEAD
{/* ── Header ── */}
@@ -314,11 +356,31 @@ export default function GoalTracker() {
{goals.length === 0 ? (
No goals yet. Create one below.
+=======
+
+ {/* Live region for goal creation feedback */}
+
+
+
+ Weekly Goals
+
+
+ {goals.length === 0 ? (
+
+ No goals yet. Add one using the form below.
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
) : (
-
+
{goals.map((goal) => {
const pct = Math.min((goal.current / goal.target) * 100, 100);
+<<<<<<< HEAD
const isConfirming = confirmingId === goal.id;
const isDeleting = deletingId === goal.id;
const completed = goal.current >= goal.target;
@@ -443,6 +505,35 @@ export default function GoalTracker() {
+=======
+ const safeLabel = stripHtml(goal.label);
+ const progressId = `progress-${goal.id}`;
+ return (
+
+
+
+ {safeLabel}
+
+
+ {goal.current}/{goal.target}
+
+
+
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
)}
+<<<<<<< HEAD
{lastUpdated && (
{minutesAgo === 0 ? "Updated just now" : `Updated ${minutesAgo} min ago`}
@@ -466,6 +558,20 @@ export default function GoalTracker() {