diff --git a/package-lock.json b/package-lock.json index 5c52af4b..7796c3bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "dompurify": "^3.1.6", + "driver.js": "^1.4.0", "fflate": "^0.8.3", "html-to-image": "^1.11.13", "idb-keyval": "^6.2.4", @@ -9759,6 +9760,12 @@ "node": ">=4" } }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index e4d89fcd..5d544411 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "dompurify": "^3.1.6", + "driver.js": "^1.4.0", "fflate": "^0.8.3", "html-to-image": "^1.11.13", "idb-keyval": "^6.2.4", diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts index 572e8e14..a7f51264 100644 --- a/src/app/api/user/settings/route.ts +++ b/src/app/api/user/settings/route.ts @@ -11,10 +11,9 @@ import { cacheGet, cacheSet, cacheDelete } from "@/lib/metrics-cache"; export const dynamic = "force-dynamic"; async function fetchUserSettings(userId: string) { - // Tier 1: All columns const res1 = await supabaseAdmin .from("users") - .select("id, github_login, bio, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone, webhook_url, discord_muted_until") + .select("id, github_login, bio, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone, webhook_url, discord_muted_until, seen_onboarding") .eq("id", userId) .single(); @@ -66,10 +65,9 @@ async function fetchUserSettings(userId: string) { }; } - // Tier 2: Without bio, for deployments that have not run the latest migration. const res2 = await supabaseAdmin .from("users") - .select("id, github_login, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, webhook_url") + .select("id, github_login, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, webhook_url") .eq("id", userId) .single(); @@ -121,10 +119,9 @@ async function fetchUserSettings(userId: string) { }; } - // Tier 3: Without public_since and show_weekly_goals (added by migrations) const res3 = await supabaseAdmin .from("users") - .select("id, github_login, is_public, public_since, show_weekly_goals") + .select("id, github_login, is_public, public_since, show_weekly_goals") .eq("id", userId) .single(); @@ -139,6 +136,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasWebhookUrl: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -146,6 +144,7 @@ async function fetchUserSettings(userId: string) { wakatime_api_key_iv: null, discord_webhook_url: null, timezone: "UTC", + webhook_url: null, discord_muted_until: null, }; } @@ -161,6 +160,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasWebhookUrl: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -168,14 +168,14 @@ async function fetchUserSettings(userId: string) { wakatime_api_key_iv: null, discord_webhook_url: null, timezone: "UTC", + webhook_url: null, discord_muted_until: null, }; } - // Tier 4: Absolute minimum — columns guaranteed in every schema version const res4 = await supabaseAdmin .from("users") - .select("id, github_login, is_public") + .select("id, github_login, is_public") .eq("id", userId) .single(); @@ -190,6 +190,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasWebhookUrl: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -197,6 +198,7 @@ async function fetchUserSettings(userId: string) { wakatime_api_key_iv: null, discord_webhook_url: null, timezone: "UTC", + webhook_url: null, discord_muted_until: null, }; } @@ -211,6 +213,7 @@ async function fetchUserSettings(userId: string) { hasDiscordSettings: false, hasBio: false, hasDiscordMutedUntil: false, + hasWebhookUrl: false, leaderboard_opt_in: false, weekly_digest_opt_in: false, pinned_repos: [] as string[], @@ -218,6 +221,7 @@ async function fetchUserSettings(userId: string) { wakatime_api_key_iv: null, discord_webhook_url: null, timezone: "UTC", + webhook_url: null, discord_muted_until: null, }; } @@ -231,14 +235,11 @@ export async function GET(req: NextRequest) { const user = await resolveAppUser(session.githubId, session.githubLogin); if (!user) { - return NextResponse.json( - { error: "Failed to fetch user settings" }, - { status: 500 } - ); + return NextResponse.json({ error: "Failed to fetch user settings" }, { status: 500 }); } const cacheKey = `settings:${user.id}`; - const SETTINGS_TTL = 5 * 60; // 5 minutes + const SETTINGS_TTL = 5 * 60; const cached = await cacheGet>(cacheKey, SETTINGS_TTL); if (cached) { @@ -267,13 +268,13 @@ export async function GET(req: NextRequest) { timezone: result.timezone, webhook_url: result.webhook_url ?? null, discord_muted_until: result.discord_muted_until ?? null, + seen_onboarding: (result.data as any).seen_onboarding ?? false, }; await cacheSet(cacheKey, response, SETTINGS_TTL); return NextResponse.json(response); } - export async function PATCH(req: NextRequest) { const session = await getServerSession(authOptions); @@ -282,24 +283,33 @@ export async function PATCH(req: NextRequest) { } const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) { - return NextResponse.json( - { error: "User not found" }, - { status: 404 } - ); + return NextResponse.json({ error: "User not found" }, { status: 404 }); } - let body: { is_public?: boolean; show_weekly_goals?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key?: string; discord_webhook_url?: string | null; timezone?: string; bio?: string; webhook_url?: string | null; discord_muted_until?: string | null }; + let body: { + is_public?: boolean; + show_weekly_goals?: boolean; + leaderboard_opt_in?: boolean; + weekly_digest_opt_in?: boolean; + pinned_repos?: string[]; + wakatime_api_key?: string; + discord_webhook_url?: string | null; + timezone?: string; + bio?: string; + webhook_url?: string | null; + discord_muted_until?: string | null; + seen_onboarding?: boolean; + }; + try { body = await req.json(); } catch (e) { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const { is_public, show_weekly_goals, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone, bio, webhook_url, discord_muted_until } = body; + const { is_public, show_weekly_goals, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone, bio, webhook_url, discord_muted_until, seen_onboarding } = body; - // Retrieve supported columns first const settingsResult = await fetchUserSettings(user.id); if (settingsResult.error || !settingsResult.data) { console.error("Error fetching settings during PATCH:", settingsResult.error); @@ -307,7 +317,23 @@ export async function PATCH(req: NextRequest) { } const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn, hasDiscordSettings, hasBio, hasWebhookUrl, hasDiscordMutedUntil } = settingsResult; - const updates: { is_public?: boolean; public_since?: string | null; show_weekly_goals?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key_encrypted?: string | null; wakatime_api_key_iv?: string | null; discord_webhook_url?: string | null; timezone?: string; bio?: string; webhook_url?: string | null; discord_muted_until?: string | null } = {}; + + const updates: { + is_public?: boolean; + public_since?: string | null; + show_weekly_goals?: boolean; + leaderboard_opt_in?: boolean; + weekly_digest_opt_in?: boolean; + pinned_repos?: string[]; + wakatime_api_key_encrypted?: string | null; + wakatime_api_key_iv?: string | null; + discord_webhook_url?: string | null; + timezone?: string; + bio?: string; + webhook_url?: string | null; + discord_muted_until?: string | null; + seen_onboarding?: boolean; + } = {}; if (is_public !== undefined && is_public !== null && typeof is_public === "boolean") { updates.is_public = is_public; @@ -318,17 +344,13 @@ export async function PATCH(req: NextRequest) { } } - if ( - hasLeaderboardOptIn && - leaderboard_opt_in !== undefined && - leaderboard_opt_in !== null && - typeof leaderboard_opt_in === "boolean" - ) { + if (hasLeaderboardOptIn && leaderboard_opt_in !== undefined && leaderboard_opt_in !== null && typeof leaderboard_opt_in === "boolean") { updates.leaderboard_opt_in = leaderboard_opt_in; if (leaderboard_opt_in) { updates.is_public = true; } } + if (show_weekly_goals !== undefined && show_weekly_goals !== null && typeof show_weekly_goals === "boolean") { updates.show_weekly_goals = show_weekly_goals; } @@ -337,12 +359,7 @@ export async function PATCH(req: NextRequest) { updates.webhook_url = webhook_url; } - if ( - hasWeeklyDigestOptIn && - weekly_digest_opt_in !== undefined && - weekly_digest_opt_in !== null && - typeof weekly_digest_opt_in === "boolean" - ) { + if (hasWeeklyDigestOptIn && weekly_digest_opt_in !== undefined && weekly_digest_opt_in !== null && typeof weekly_digest_opt_in === "boolean") { updates.weekly_digest_opt_in = weekly_digest_opt_in; } @@ -353,23 +370,19 @@ export async function PATCH(req: NextRequest) { updates.pinned_repos = pinned_repos; } + if (typeof seen_onboarding === "boolean") { + updates.seen_onboarding = seen_onboarding; + } + if (!hasBio && bio !== undefined) { - return NextResponse.json( - { error: "Bio settings are not available until the latest database migration is applied" }, - { status: 400 } - ); + return NextResponse.json({ error: "Bio settings are not available until the latest database migration is applied" }, { status: 400 }); } if (hasBio && bio !== undefined) { const result = validateTextInput(bio, "Bio", 500); - if (!result.ok) { - return NextResponse.json( - { error: result.error }, - { status: 400 } - ); + return NextResponse.json({ error: result.error }, { status: 400 }); } - updates.bio = result.value; } @@ -394,7 +407,6 @@ export async function PATCH(req: NextRequest) { } } - // Handle Discord settings (only if the discord columns exist in the schema) if (hasDiscordSettings && discord_webhook_url !== undefined) { if (discord_webhook_url === "") { updates.discord_webhook_url = null; @@ -422,7 +434,6 @@ export async function PATCH(req: NextRequest) { } } - // If there are no updates (or none that are supported by the schema) if (Object.keys(updates).length === 0) { return NextResponse.json({ id: (settingsResult.data as any).id, @@ -442,7 +453,6 @@ export async function PATCH(req: NextRequest) { }); } - // Query only supported columns in the returning select statement const selectCols = ["id", "github_login", "is_public", "public_since", "show_weekly_goals"]; if (hasBio) selectCols.push("bio"); if (hasLeaderboardOptIn) selectCols.push("leaderboard_opt_in"); @@ -468,21 +478,13 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); } - // Bust settings cache so next GET returns fresh data. await cacheDelete(`settings:${user.id}`); - // If is_public or leaderboard_opt_in changed, the cached leaderboard would - // show stale eligibility until it expires (up to 1 hour). Bust the cache - // immediately so the next request reflects the updated preference. - const leaderboardEligibilityChanged = - "is_public" in updates || "leaderboard_opt_in" in updates; - + const leaderboardEligibilityChanged = "is_public" in updates || "leaderboard_opt_in" in updates; if (leaderboardEligibilityChanged) { try { await clearLeaderboardCache(); } catch { - // Cache invalidation is best-effort — a failure must not prevent the - // settings response from reaching the client. console.error("[settings] Failed to invalidate leaderboard cache after visibility change"); } } @@ -503,4 +505,4 @@ export async function PATCH(req: NextRequest) { webhook_url: (updated as any).webhook_url ?? null, discord_muted_until: (updated as any).discord_muted_until ?? null, }); -} +} \ No newline at end of file diff --git a/src/components/CodingActivityInsightsCard.tsx b/src/components/CodingActivityInsightsCard.tsx index f0d6f37b..4887d9b3 100644 --- a/src/components/CodingActivityInsightsCard.tsx +++ b/src/components/CodingActivityInsightsCard.tsx @@ -28,16 +28,21 @@ function formatCommitCount(count: number): string { function InsightRow({ label, value, + ariaLabel, }: { label: string; value: string; + ariaLabel?: string; }) { return (

{label}

-

+

{value}

@@ -176,7 +181,7 @@ export default function CodingActivityInsightsCard() { return []; } - const rows = [ + const rows: { label: string; value: string; ariaLabel?: string }[] = [ { label: "Most active", value: `${data.mostActiveHour.label} with ${formatCommitCount(data.mostActiveHour.count)}`, @@ -191,18 +196,19 @@ export default function CodingActivityInsightsCard() { }, { - label: "Productivity", - value: - data.productivityLevel === "Excellent" - ? "🟢 Excellent" - : data.productivityLevel === "Very Good" - ? "🔵 Very Good" - : data.productivityLevel === "Good" - ? "🟡 Good" - : data.productivityLevel === "Moderate" - ? "🟠 Moderate" - : "🔴 Low", - }, + label: "Productivity", + value: + data.productivityLevel === "Excellent" + ? "🟢 Excellent" + : data.productivityLevel === "Very Good" + ? "🔵 Very Good" + : data.productivityLevel === "Good" + ? "🟡 Good" + : data.productivityLevel === "Moderate" + ? "🟠 Moderate" + : "🔴 Low", + ariaLabel: `Productivity level: ${data.productivityLevel ?? "Low"}`, + }, { label: "Daily Average", @@ -349,6 +355,7 @@ export default function CodingActivityInsightsCard() { key={row.label} label={row.label} value={row.value} + ariaLabel={row.ariaLabel} /> ))} diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 166266fc..1c986073 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -140,6 +140,11 @@ export function useGoalTracker() { if (e) e.preventDefault(); setCreating(true); setCreateError(null); + if (target <= 0) { + setCreateError("Target must be greater than 0."); + setCreating(false); + return; + } try { const result = await submitGoalWithRefresh({ diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx new file mode 100644 index 00000000..f4b8701b --- /dev/null +++ b/src/components/OnboardingTour.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useCallback } from "react"; +import { driver } from "driver.js"; +import "driver.js/dist/driver.css"; + +const TOUR_STEPS = [ + { + element: "#widget-contribution-graph", + popover: { + title: "Contribution Graph", + description: "See your daily GitHub commit activity. Switch between 7, 14, 30, and 90 day views.", + }, + }, + { + element: "#widget-streak", + popover: { + title: "Streak Tracker", + description: "Your current commit streak — how many days in a row you've pushed code.", + }, + }, + { + element: "#widget-pr-metrics", + popover: { + title: "PR Analytics", + description: "Average review time, merge rate, and open vs closed pull request counts.", + }, + }, + { + element: "#widget-top-repos", + popover: { + title: "Top Repositories", + description: "Your most active repos ranked by commits. Click column headers to sort.", + }, + }, + { + element: "#widget-goals", + popover: { + title: "Weekly Goals", + description: "Set coding targets and track your progress automatically.", + }, + }, +]; + +async function markTourSeen() { + try { + await fetch("/api/user/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ seen_onboarding: true }), + }); + } catch { + // silent fail — not critical + } +} + +export default function OnboardingTour() { + const startTour = useCallback(() => { + const driverObj = driver({ + showProgress: true, + animate: true, + allowClose: true, + steps: TOUR_STEPS, + onDestroyStarted: () => { + markTourSeen(); + driverObj.destroy(); + }, + }); + + driverObj.drive(); + }, []); + + useEffect(() => { + if (typeof window !== "undefined" && window.navigator.webdriver) return; + const timer = setTimeout(startTour, 800); + return () => clearTimeout(timer); + }, [startTour]); + + return null; +} diff --git a/supabase/migrations/20260520000000_add_seen_onboarding.sql b/supabase/migrations/20260520000000_add_seen_onboarding.sql new file mode 100644 index 00000000..0ccb48cd --- /dev/null +++ b/supabase/migrations/20260520000000_add_seen_onboarding.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS seen_onboarding BOOLEAN DEFAULT FALSE;