diff --git a/package-lock.json b/package-lock.json index c47f690f..500b102c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -80,6 +81,7 @@ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -937,6 +939,7 @@ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.60.0" }, @@ -1416,6 +1419,7 @@ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz", "integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==", "license": "MIT", + "peer": true, "dependencies": { "@supabase/auth-js": "2.106.2", "@supabase/functions-js": "2.106.2", @@ -1537,6 +1541,7 @@ "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1567,6 +1572,7 @@ "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1634,6 +1640,7 @@ "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", @@ -2459,6 +2466,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2924,6 +2932,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3891,6 +3900,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", @@ -4060,6 +4070,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5565,6 +5576,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5636,6 +5648,7 @@ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", @@ -6008,6 +6021,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "14.2.35", "@swc/helpers": "0.5.5", @@ -6670,6 +6684,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -6840,6 +6855,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" @@ -6930,6 +6946,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" }, @@ -6942,6 +6959,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" @@ -7964,6 +7982,7 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8122,6 +8141,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8321,6 +8341,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/dashboard/page.tsx b/src/app/dashboard/page.tsx index 617bea42..4eb88e63 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -94,7 +94,7 @@ import RecentActivity from "@/components/RecentActivity"; import { authOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; -import DashboardSSEProvider from "@/components/DashboardSSEProvider"; +import { DashboardSyncProvider } from "@/components/DashboardHeader"; export default async function DashboardPage() { const session = await getServerSession(authOptions); @@ -104,134 +104,133 @@ export default async function DashboardPage() { if (session.error === "TokenRevoked") redirect("/"); return ( - -
+ +
-
- - Year in Code - - - Settings - - -
- - -
- -
-
-
-

Your Year in Code is here! ✨

-

Discover your top languages, longest streaks, and coding habits of the year.

-
-
- View Wrapped + +
+ + Year in Code + + + Settings + + +
+ + + +
+ +
+
+
+

Your Year in Code is here! ✨

+

Discover your top languages, longest streaks, and coding habits of the year.

+
+
+ View Wrapped +
+
+
-
-
-
- -
+ +
-
- -
+
+ +
-
- -
+
+ +
-
- -
+
+ +
- {/* Row 1: Contribution graph + Streak + Local Coding Time */} -
-
- -
- + {/* Row 1: Contribution graph + Streak + Friend Comparison */} +
+
+ +
+ +
+
+ +
-
+ +
+ -
-
- + +
+ +
-
- - -
- -
+ {/* Row 2: PR metrics, community metrics, PR breakdown & Time Chart */} +
+ + + +
-
- {/* Row 2: PR metrics, community metrics, PR breakdown & Time Chart */} -
- - - - -
- {/* Row 2b: Activity Ring Chart */} -
- -
+ {/* Activity and insights */} +
+ +
-
- -
+
+ +
-
- -
+
+ +
- {/* Row 3: Issue metrics + CI analytics */} -
-
- + {/* Issues and CI */} +
+
+ +
+
- -
- {/* Row 3b: Discussion activity */} -
- -
- {/* Row 4: Pinned repositories */} -
- -
+
+ +
- {/* Row 5: Inactive repository reminder */} -
- -
+ {/* Pinned and inactive repos */} +
+ +
- {/* Row 6: Top repos + Language breakdown + Goal tracker */} -
- - - -
+
+ +
- {/* Row 7: Recent GitHub activity */} -
- + {/* Top repos, languages, goals */} +
+ + + +
+ +
+ +
-
- + ); } diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx index 0b53420a..d1920784 100644 --- a/src/components/DashboardHeader.tsx +++ b/src/components/DashboardHeader.tsx @@ -1,7 +1,15 @@ -"use client"; - +"use client" + +import { + createContext, + ReactNode, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; import NotificationBell from "@/components/NotificationBell"; -import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; import AccountToggle from "@/components/AccountToggle"; import SignOutButton from "@/components/SignOutButton"; @@ -9,9 +17,79 @@ import ThemeToggle from "@/components/ThemeToggle"; import UserAvatar from "@/components/UserAvatar"; import KeyboardShortcuts from "@/components/KeyboardShortcuts"; + +type DashboardSyncContextValue = { + lastSynced: Date | null; +}; + +const DashboardSyncContext = createContext({ + lastSynced: null, +}); + +function getRequestPath(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input.startsWith("http") ? new URL(input).pathname : input; + } + + if (input instanceof URL) { + return input.pathname; + } + + return new URL((input as Request).url).pathname; +} + +function isDashboardDataRequest(input: RequestInfo | URL): boolean { + const requestPath = getRequestPath(input); + + return ( + requestPath.startsWith("/api/metrics/") || + requestPath === "/api/goals" || + requestPath.startsWith("/api/goals/") || + requestPath.startsWith("/api/streak/") || + requestPath === "/api/user/github-accounts" || + requestPath.startsWith("/api/badge/") + ); +} + +export function DashboardSyncProvider({ children }: { children: ReactNode }) { + const [lastSynced, setLastSynced] = useState(null); + + useLayoutEffect(() => { + const originalFetch = window.fetch; + + window.fetch = async (...args) => { + const response = await originalFetch(...args); + + if (response.ok && isDashboardDataRequest(args[0])) { + setLastSynced(new Date()); + } + + return response; + }; + + return () => { + window.fetch = originalFetch; + }; + }, []); + + const value = useMemo(() => ({ lastSynced }), [lastSynced]); + + return ( + + {children} + + ); +} + +function useDashboardSync() { + return useContext(DashboardSyncContext); +} + export default function DashboardHeader() { const { data: session } = useSession(); const [isPublic, setIsPublic] = useState(null); + const { lastSynced } = useDashboardSync(); + const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!session) { @@ -38,6 +116,20 @@ export default function DashboardHeader() { loadSettings(); }, [session]); + useEffect(() => { + if (!lastSynced) return; + + const interval = setInterval(() => { + setNow(Date.now()); + }, 60000); + + return () => clearInterval(interval); + }, [lastSynced]); + + const minutesAgo = lastSynced + ? Math.floor((now - lastSynced.getTime()) / 60000) + : null; + return (
@@ -53,6 +145,11 @@ export default function DashboardHeader() { > coding activity at a glance

+ {minutesAgo !== null && ( +

+ {minutesAgo <= 0 ? "Synced just now" : `Synced ${minutesAgo} min ago`} +

+ )}
{/* Right Section */} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index bc01e8e3..2f5b7297 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,12 +1,15 @@ import { createClient } from "@supabase/supabase-js"; -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; -const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ""; +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? ""; +const isPlaceholderSupabaseConfig = + (supabaseUrl && supabaseUrl.includes("placeholder.supabase.co")) || + (serviceRoleKey && serviceRoleKey.includes("placeholder-service-role-key")); // Do not throw here — build-time rendering can touch this module before // runtime environment variables are present. Guard call sites instead. export const supabaseAdmin: any = - supabaseUrl && serviceRoleKey && !supabaseUrl.includes("placeholder") + supabaseUrl && serviceRoleKey && !isPlaceholderSupabaseConfig ? createClient(supabaseUrl, serviceRoleKey) : null; @@ -23,9 +26,7 @@ interface User { * Look up a user by GitHub username only if their profile is public. * Returns the user row if found and is_public is true, otherwise null. */ -export async function getUserByUsername( - username: string -): Promise { +export async function getUserByUsername(username: string): Promise { if (!supabaseAdmin) return null; try {