diff --git a/docs/superpowers/plans/2026-04-26-profile-devices-redesign.md b/docs/superpowers/plans/2026-04-26-profile-devices-redesign.md new file mode 100644 index 0000000..d12f622 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-profile-devices-redesign.md @@ -0,0 +1,2292 @@ +# Profile & Devices Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Revamp the profile page into a full settings hub and create a devices page with aggregated stats and charts. + +**Architecture:** Profile page expands from max-w-3xl to max-w-7xl with Settings sub-tabs (Profile, Security, Linked Accounts) powered by Better Auth client APIs. Navbar drops the avatar/icon. New devices page uses a card grid with drill-down detail pages and ECharts-powered statistics. New Elysia API endpoints provide aggregated hardware stats. + +**Tech Stack:** Next.js 16, Better Auth (client APIs: changePassword, updateUser, listAccounts, unlinkAccount, linkSocialAccount, passkey.*), Elysia, Drizzle ORM, ECharts (echarts-for-react), Framer Motion, Tailwind v4 + +--- + +## File Structure + +### New Files +- `components/profile/settings-profile-tab.tsx` — Profile name/email editing +- `components/profile/settings-security-tab.tsx` — Password + Passkey management +- `components/profile/settings-accounts-tab.tsx` — Account linking/unlinking +- `app/devices/page.tsx` — Devices grid (server component, fetches data) +- `app/devices/page-client.tsx` — Devices grid client (cards with stats) +- `app/devices/[slug]/page.tsx` — Device detail (server component) +- `app/devices/[slug]/device-detail-client.tsx` — Device detail with charts +- `lib/api/hardware-stats.ts` — Aggregated hardware stats endpoints + +### Modified Files +- `app/profile/page.tsx` — Wider layout, sub-tab Settings, no image avatar +- `components/profile/profile-header.tsx` — Remove image/avatar, name-centric +- `components/navbar.tsx` — Remove avatar/icon, text-only "Profile" trigger +- `lib/routes.ts` — Add /devices route +- `lib/api/index.ts` — Export hardware-stats routes +- `app/api/[[...slugs]]/route.ts` — Import hardware stats routes + +--- + +### Task 1: Navbar — Remove Profile Photo/Icon + +**Files:** +- Modify: `components/navbar.tsx` + +**Context:** The navbar currently shows the user's image (or a fallback User icon) alongside "Profile" text in a dropdown trigger. Per the spec, we remove the image/icon entirely and show only the text "Profile". The dropdown menu logic stays (Profile, Saved Games, Sign out), just the trigger loses the avatar. + +- [ ] **Step 1: Remove avatar from desktop dropdown trigger** + +In `components/navbar.tsx`, find the desktop ` +``` + +Replace with text-only trigger: + +```tsx + +``` + +- [ ] **Step 2: Remove avatar from mobile sidebar auth section** + +In the mobile sidebar section, find where auth routes render user-specific links. Remove the image/icon display next to each auth route link, but keep the lucide icons (User, Bookmark) from `authRoutes` — those are menu item icons, not avatars. The mobile sidebar currently has no explicit avatar rendering either, so this step is mostly a verification that no image rendering exists in the mobile section. If it does, remove it. + +- [ ] **Step 3: Remove the unused Image and User imports if no longer needed** + +Check if `Image` from `next/image` and `User` from `lucide-react` are still used elsewhere in the file. If `Image` is only used for the avatar, remove the import. Keep `User` if it's used in the auth routes icon mapping. + +- [ ] **Step 4: Verify the navbar renders correctly** + +Run `bun run build 2>&1 | tail -20` and check for no type errors. Manually verify the dropdown still works by checking the code logic is intact. + +- [ ] **Step 5: Commit** + +```bash +git add components/navbar.tsx +git commit -m "refactor: remove profile avatar from navbar dropdown trigger" +``` + +--- + +### Task 2: Profile Page Layout + ProfileHeader Redesign + +**Files:** +- Modify: `app/profile/page.tsx` +- Modify: `components/profile/profile-header.tsx` + +**Context:** The profile page needs to widen from `max-w-3xl` to `max-w-7xl` with the game-page-style section padding (`px-4 md:px-[10svw]`). The ProfileHeader must remove the avatar/image display and switch to a name-centric layout with role badge and member-since info. The overall styling should match the game page and search page — card-based sections with `rounded-xl border border-border bg-text/[0.03]` styling. + +- [ ] **Step 1: Update ProfileHeader to remove avatar** + +In `components/profile/profile-header.tsx`, refactor the component to: +- Remove the `image` prop entirely from the interface +- Remove the avatar/image section (the `
` block with Image/User icon) +- Make the layout a simple left-aligned block: + - Name (h1) + role badge inline + - "Member since {date}" below + - Email address if provided (read-only, subtle text) + +Updated component: + +```tsx +"use client" + +import { Shield, Crown, CheckCircle, Mail } from "lucide-react" +import { motion } from "motion/react" + +interface ProfileHeaderProps { + name: string + email?: string + role: string | null + verified: boolean + createdAt: string +} + +const roleConfig: Record = { + admin: { label: "Admin", color: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", icon: Crown }, + contributor: { label: "Contributor", color: "bg-blue-500/20 text-blue-400 border-blue-500/30", icon: Shield }, + user: { label: "Member", color: "bg-text/10 text-text/60 border-text/20", icon: Shield }, +} + +export function ProfileHeader({ name, email, role, verified, createdAt }: ProfileHeaderProps) { + const config = roleConfig[role || "user"] || roleConfig.user + const RoleIcon = config.icon + + const joinDate = new Date(createdAt).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }) + + return ( + +
+

{name}

+ + + {config.label} + + {verified && ( + + + Verified + + )} +
+
+ Member since {joinDate} + {email && ( + <> + · + + + {email} + + + )} +
+
+ ) +} +``` + +- [ ] **Step 2: Widen the profile page and update ProfileHeader call sites** + +In `app/profile/page.tsx`, make these changes: + +1. Change `max-w-3xl` to `max-w-7xl` in the main container div +2. Wrap the content in the game-page-style section pattern with `px-4 md:px-[10svw]` padding +3. Update the `ProfileHeader` call to remove the `image` prop and add `email` prop +4. Add `motion.div` section wrappers with staggered animations (matching the game page pattern) + +The page layout should use section wrappers like the game page: + +```tsx +// Top-level wrapper +
+ {/* Profile header section */} + + + + + {/* Stats section */} + + + + + {/* Tabs + content section */} + + {/* tabs and content */} + +
+``` + +5. Update the `ProfileHeader` invocation — remove `image`, add `email`: + +```tsx + +``` + +- [ ] **Step 3: Update public profile page header** + +In `app/profile/[id]/profile-page-client.tsx`, the `ProfileHeader` call also passes `image`. Remove the `image` prop. The public profile doesn't show email, so this is a simple prop removal. + +- [ ] **Step 4: Build check** + +Run `bun run build 2>&1 | tail -30` and verify no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add app/profile/page.tsx app/profile/[id]/profile-page-client.tsx components/profile/profile-header.tsx +git commit -m "refactor: widen profile to max-w-7xl and remove avatar from header" +``` + +--- + +### Task 3: Settings — Profile Sub-Tab + +**Files:** +- Create: `components/profile/settings-profile-tab.tsx` +- Modify: `app/profile/page.tsx` (wire up the sub-tab later in Task 6) + +**Context:** This component renders the Profile settings sub-tab inside the Settings tab. It allows the user to change their display name using Better Auth's `updateUser` API. Email is shown read-only (email change is a separate, more complex flow requiring verification). Role and member-since are also read-only. + +- [ ] **Step 1: Create the settings-profile-tab component** + +Create `components/profile/settings-profile-tab.tsx`: + +```tsx +"use client" + +import { useState } from "react" +import { authClient } from "@/lib/auth-client" +import { Loader2, Save, User } from "lucide-react" +import { motion } from "motion/react" + +interface SettingsProfileTabProps { + name: string + email: string + role: string | null + createdAt: string +} + +export function SettingsProfileTab({ name, email, role, createdAt }: SettingsProfileTabProps) { + const [displayName, setDisplayName] = useState(name) + const [isSaving, setIsSaving] = useState(false) + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) + + const handleSaveName = async () => { + if (!displayName.trim()) { + setMessage({ type: "error", text: "Name cannot be empty" }) + return + } + + setIsSaving(true) + setMessage(null) + + const { error } = await authClient.updateUser({ + name: displayName.trim(), + }) + + if (error) { + setMessage({ type: "error", text: error.message || "Failed to update name" }) + } else { + setMessage({ type: "success", text: "Name updated successfully" }) + } + setIsSaving(false) + } + + const joinDate = new Date(createdAt).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }) + + return ( + + {/* Display Name */} +
+

Display Name

+
+
+ setDisplayName(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors" + placeholder="Your display name" + /> +
+ +
+ {message && ( +

+ {message.text} +

+ )} +
+ + {/* Email (read-only) */} +
+

Email

+

{email}

+

Email changes require verification. Contact support if needed.

+
+ + {/* Account Info (read-only) */} +
+

Account Info

+
+
+ Role +

{role || "user"}

+
+
+ Member since +

{joinDate}

+
+
+
+
+ ) +} +``` + +- [ ] **Step 2: Verify the component compiles** + +Run `bun run build 2>&1 | tail -20` — it won't be imported yet, but verify no syntax/type errors in the file itself by checking the build output. + +- [ ] **Step 3: Commit** + +```bash +git add components/profile/settings-profile-tab.tsx +git commit -m "feat: add settings profile sub-tab with name editing" +``` + +--- + +### Task 4: Settings — Security Sub-Tab (Password + Passkeys) + +**Files:** +- Create: `components/profile/settings-security-tab.tsx` + +**Context:** This component has two sections: Password management (change or set password) and Passkey management (list, add, rename, delete). Better Auth provides `changePassword`, `setPassword`, `passkey.addPasskey`, and the passkey list atom. We need to check if the user has a password (by checking if their account has a password credential) — we can infer this from whether `password` is set on their account. + +We'll add a new API endpoint to check auth methods for safety (prevent removing the last auth method). + +- [ ] **Step 1: Add a /user/me/auth-methods API endpoint** + +In `lib/api/user.ts`, add a new route that returns the user's authentication methods and their counts: + +```ts +.get( + "/me/auth-methods", + async ({ request, set }) => { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + set.status = 401 + return { error: "Unauthorized" } + } + + // Count accounts by provider + const accounts = await db + .select({ providerId: account.providerId, id: account.id }) + .from(account) + .where(eq(account.userId, session.user.id)) + + // Count passkeys + const [passkeyRow] = await db + .select({ count: sql`count(*)::int` }) + .from(passkey) + .where(eq(passkey.userId, session.user.id)) + + // Check if user has a password (from accounts where providerId is "credential") + const hasPassword = accounts.some((a) => a.providerId === "credential") + + // OAuth providers + const oauthProviders = accounts + .filter((a) => a.providerId !== "credential") + .map((a) => ({ + providerId: a.providerId, + id: a.id, + })) + + // Total auth methods = passwords + passkeys + oauth accounts + const totalAuthMethods = + (hasPassword ? 1 : 0) + (passkeyRow?.count ?? 0) + oauthProviders.length + + return { + hasPassword, + passkeyCount: passkeyRow?.count ?? 0, + oauthProviders, + totalAuthMethods, + } + }, +) +``` + +This requires the `account` and `passkey` imports (already in schema), and `eq` + `sql` from drizzle (already imported). + +- [ ] **Step 2: Create the settings-security-tab component** + +Create `components/profile/settings-security-tab.tsx` with two sections: + +**Password Section:** +- Fetch `/api/user/me/auth-methods` to determine if user has a password +- If user has password: show "Change Password" form (current + new + confirm) +- If user has no password: show "Set Password" form (new + confirm only) +- Uses `authClient.changePassword({ newPassword, currentPassword })` or `authClient.setPassword({ newPassword })` +- Success/error feedback + +**Passkey Section:** +- List passkeys using Better Auth's client (`useStore` with `authClient.$listPasskeys` — we'll use `fetch("/api/auth/passkey/list-user-passkeys")` for simplicity) +- "Add Passkey" button using `authClient.passkey.addPasskey()` +- Each passkey shows: name (or "Unnamed"), device type, created date +- Delete button per passkey — calls `fetch("/api/auth/passkey/delete-passkey", { method: "POST", body: JSON.stringify({ id }) })` +- Rename button per passkey — calls `fetch("/api/auth/passkey/update-passkey", { method: "POST", body: JSON.stringify({ id, name }) })` +- Safety check: disable delete if `totalAuthMethods <= 1` + +```tsx +"use client" + +import { useState, useEffect, useCallback } from "react" +import { authClient } from "@/lib/auth-client" +import { Loader2, Key, Fingerprint, Plus, Trash2, Pencil, Check, X, Shield } from "lucide-react" +import { motion, AnimatePresence } from "motion/react" + +interface AuthMethods { + hasPassword: boolean + passkeyCount: number + oauthProviders: { providerId: string; id: string }[] + totalAuthMethods: number +} + +interface PasskeyInfo { + id: string + name: string | null + deviceType: string + createdAt: string | null +} + +export function SettingsSecurityTab() { + // ── Auth methods state ──────────────────────────────────────── + const [authMethods, setAuthMethods] = useState(null) + const [loadingMethods, setLoadingMethods] = useState(true) + + const fetchAuthMethods = useCallback(async () => { + try { + const res = await fetch("/api/user/me/auth-methods") + if (res.ok) { + const data = await res.json() + setAuthMethods(data) + } + } catch (err) { + console.error("Failed to fetch auth methods:", err) + } finally { + setLoadingMethods(false) + } + }, []) + + useEffect(() => { + fetchAuthMethods() + }, [fetchAuthMethods]) + + // ── Password state ──────────────────────────────────────────── + const [currentPassword, setCurrentPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [passwordLoading, setPasswordLoading] = useState(false) + const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) + + const handleChangePassword = async () => { + setPasswordLoading(true) + setPasswordMessage(null) + + if (newPassword !== confirmPassword) { + setPasswordMessage({ type: "error", text: "Passwords do not match" }) + setPasswordLoading(false) + return + } + + if (authMethods?.hasPassword) { + // Change password + const { error } = await authClient.changePassword({ + currentPassword, + newPassword, + }) + if (error) { + setPasswordMessage({ type: "error", text: error.message || "Failed to change password" }) + } else { + setPasswordMessage({ type: "success", text: "Password changed successfully" }) + setCurrentPassword("") + setNewPassword("") + setConfirmPassword("") + } + } else { + // Set password + const { error } = await authClient.setPassword({ + newPassword, + }) + if (error) { + setPasswordMessage({ type: "error", text: error.message || "Failed to set password" }) + } else { + setPasswordMessage({ type: "success", text: "Password set successfully" }) + setNewPassword("") + setConfirmPassword("") + await fetchAuthMethods() + } + } + setPasswordLoading(false) + } + + // ── Passkey state ───────────────────────────────────────────── + const [passkeys, setPasskeys] = useState([]) + const [passkeysLoading, setPasskeysLoading] = useState(true) + const [addingPasskey, setAddingPasskey] = useState(false) + const [renamingId, setRenamingId] = useState(null) + const [renameValue, setRenameValue] = useState("") + const [passkeyMessage, setPasskeyMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) + + const fetchPasskeys = useCallback(async () => { + try { + const res = await fetch("/api/auth/passkey/list-user-passkeys", { + headers: { "Content-Type": "application/json" }, + credentials: "include", + }) + if (res.ok) { + const data = await res.json() + setPasskeys(Array.isArray(data) ? data : []) + } + } catch (err) { + console.error("Failed to fetch passkeys:", err) + } finally { + setPasskeysLoading(false) + } + }, []) + + useEffect(() => { + fetchPasskeys() + }, [fetchPasskeys]) + + const handleAddPasskey = async () => { + setAddingPasskey(true) + setPasskeyMessage(null) + const { error } = await authClient.passkey.addPasskey() + if (error) { + setPasskeyMessage({ type: "error", text: error.message || "Failed to add passkey" }) + } else { + setPasskeyMessage({ type: "success", text: "Passkey added successfully" }) + await fetchPasskeys() + await fetchAuthMethods() + } + setAddingPasskey(false) + } + + const handleDeletePasskey = async (id: string) => { + if (authMethods && authMethods.totalAuthMethods <= 1) return + setPasskeyMessage(null) + try { + const res = await fetch("/api/auth/passkey/delete-passkey", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ id }), + }) + if (res.ok) { + setPasskeyMessage({ type: "success", text: "Passkey removed" }) + await fetchPasskeys() + await fetchAuthMethods() + } else { + const data = await res.json() + setPasskeyMessage({ type: "error", text: data.message || "Failed to remove passkey" }) + } + } catch { + setPasskeyMessage({ type: "error", text: "Failed to remove passkey" }) + } + } + + const handleRenamePasskey = async (id: string) => { + setPasskeyMessage(null) + try { + const res = await fetch("/api/auth/passkey/update-passkey", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ id, name: renameValue }), + }) + if (res.ok) { + setPasskeyMessage({ type: "success", text: "Passkey renamed" }) + setRenamingId(null) + await fetchPasskeys() + } else { + const data = await res.json() + setPasskeyMessage({ type: "error", text: data.message || "Failed to rename passkey" }) + } + } catch { + setPasskeyMessage({ type: "error", text: "Failed to rename passkey" }) + } + } + + const isOnlyAuthMethod = authMethods ? authMethods.totalAuthMethods <= 1 : true + + return ( + + {/* ── Warning if only one auth method ────────────────── */} + {isOnlyAuthMethod && ( +
+ +
+

Single authentication method

+

+ You only have one way to sign in. Consider adding a passkey or linking a social account to avoid losing access. +

+
+
+ )} + + {/* ── Password Section ───────────────────────────────── */} +
+

+ + {authMethods?.hasPassword ? "Change Password" : "Set Password"} +

+ + {loadingMethods ? ( +
+ +
+ ) : ( +
+ {authMethods?.hasPassword && ( +
+ + setCurrentPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors mt-1" + /> +
+ )} +
+ + setNewPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors mt-1" + /> +
+
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors mt-1" + /> +
+ + {passwordMessage && ( +

+ {passwordMessage.text} +

+ )} +
+ )} +
+ + {/* ── Passkeys Section ───────────────────────────────── */} +
+
+

+ + Passkeys +

+ +
+ + {passkeyMessage && ( +

+ {passkeyMessage.text} +

+ )} + + {passkeysLoading ? ( +
+ +
+ ) : passkeys.length === 0 ? ( +
+ +

No passkeys registered

+

Add a passkey for passwordless sign-in

+
+ ) : ( +
+ + {passkeys.map((pk) => ( + +
+ +
+ {renamingId === pk.id ? ( +
+ setRenameValue(e.target.value)} + className="px-2 py-0.5 rounded bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleRenamePasskey(pk.id) + if (e.key === "Escape") setRenamingId(null) + }} + /> + + +
+ ) : ( +

{pk.name || "Unnamed passkey"}

+ )} +

+ {pk.deviceType} + {pk.createdAt && ` · ${new Date(pk.createdAt).toLocaleDateString()}`} +

+
+
+
+ {renamingId !== pk.id && ( + + )} + +
+
+ ))} +
+
+ )} +
+
+ ) +} +``` + +- [ ] **Step 3: Build check** + +Run `bun run build 2>&1 | tail -30` and verify no type errors. + +- [ ] **Step 4: Commit** + +```bash +git add lib/api/user.ts components/profile/settings-security-tab.tsx +git commit -m "feat: add security settings tab with password and passkey management" +``` + +--- + +### Task 5: Settings — Linked Accounts Sub-Tab + +**Files:** +- Create: `components/profile/settings-accounts-tab.tsx` + +**Context:** This component handles linking and unlinking social accounts (Google, Discord) and showing the password status. It uses Better Auth's `listUserAccounts` (via `GET /api/auth/list-accounts`) and `unlinkAccount` (`POST /api/auth/unlink-account`). For linking, it uses `authClient.linkSocialAccount` which redirects to OAuth. + +- [ ] **Step 1: Create the settings-accounts-tab component** + +Create `components/profile/settings-accounts-tab.tsx`: + +```tsx +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Loader2, Link as LinkIcon, Unlink, Shield } from "lucide-react" +import { motion } from "motion/react" +import { FaGoogle, FaDiscord } from "react-icons/fa" + +interface LinkedAccount { + id: string + providerId: string + accountId: string + createdAt: string +} + +interface AuthMethods { + hasPassword: boolean + passkeyCount: number + oauthProviders: { providerId: string; id: string }[] + totalAuthMethods: number +} + +const providerConfig: Record; color: string; bgColor: string }> = { + google: { name: "Google", icon: FaGoogle, color: "text-red-400", bgColor: "bg-red-500/10 border-red-500/20" }, + discord: { name: "Discord", icon: FaDiscord, color: "text-indigo-400", bgColor: "bg-indigo-500/10 border-indigo-500/20" }, + credential: { name: "Password", icon: null, color: "text-text/60", bgColor: "bg-text/5 border-border" }, +} + +export function SettingsAccountsTab() { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [authMethods, setAuthMethods] = useState(null) + const [unlinking, setUnlinking] = useState(null) + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) + + const fetchData = useCallback(async () => { + try { + const [accountsRes, methodsRes] = await Promise.all([ + fetch("/api/auth/list-accounts", { credentials: "include" }), + fetch("/api/user/me/auth-methods"), + ]) + + if (accountsRes.ok) { + const data = await accountsRes.json() + setAccounts(Array.isArray(data) ? data : []) + } + + if (methodsRes.ok) { + const data = await methodsRes.json() + setAuthMethods(data) + } + } catch (err) { + console.error("Failed to fetch account data:", err) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const handleLink = async (provider: "google" | "discord") => { + try { + const { data, error } = await authClient.linkSocialAccount({ + provider, + callbackURL: window.location.href, + }) + + if (error) { + setMessage({ type: "error", text: error.message || "Failed to link account" }) + return + } + + // If the API returns a URL, redirect to it + if (data && (data as { url?: string }).url) { + window.location.href = (data as { url: string }).url + } + } catch (err) { + setMessage({ type: "error", text: "Failed to initiate account linking" }) + } + } + + const handleUnlink = async (providerId: string) => { + if (authMethods && authMethods.totalAuthMethods <= 1) return + + setUnlinking(providerId) + setMessage(null) + + try { + const res = await fetch("/api/auth/unlink-account", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ providerId }), + }) + + if (res.ok) { + setMessage({ type: "success", text: `${providerConfig[providerId]?.name || providerId} account unlinked` }) + await fetchData() + } else { + const data = await res.json() + setMessage({ type: "error", text: data.message || "Failed to unlink account" }) + } + } catch { + setMessage({ type: "error", text: "Failed to unlink account" }) + } finally { + setUnlinking(null) + } + } + + const linkedProviders = new Set(accounts.map((a) => a.providerId)) + const availableProviders = ["google", "discord"].filter((p) => !linkedProviders.has(p)) + const isOnlyAuthMethod = authMethods ? authMethods.totalAuthMethods <= 1 : true + + return ( + + {/* ── Warning if only one auth method ────────────────── */} + {isOnlyAuthMethod && ( +
+ +
+

Single authentication method

+

+ You only have one way to sign in. Consider linking a social account or adding a passkey. +

+
+
+ )} + + {message && ( +

+ {message.text} +

+ )} + + {/* ── Linked Accounts ────────────────────────────────── */} +
+

+ + Linked Accounts +

+ + {loading ? ( +
+ +
+ ) : ( +
+ {/* Password status */} + {authMethods && ( +
+
+
+ ● +
+
+

Password

+

+ {authMethods.hasPassword ? "Configured" : "Not set"} +

+
+
+ + {authMethods.hasPassword ? "Active" : "Inactive"} + +
+ )} + + {/* OAuth accounts */} + {accounts.map((account) => { + const config = providerConfig[account.providerId] || { + name: account.providerId, + icon: null, + color: "text-text/60", + bgColor: "bg-text/5 border-border", + } + const canUnlink = !isOnlyAuthMethod + + return ( +
+
+
+ {config.icon ? : } +
+
+

{config.name}

+

Linked

+
+
+ +
+ ) + })} + + {/* Passkeys count */} + {authMethods && authMethods.passkeyCount > 0 && ( +
+
+
+ +
+
+

Passkeys

+

{authMethods.passkeyCount} registered

+
+
+ + Active + +
+ )} +
+ )} +
+ + {/* ── Link New Account ────────────────────────────────── */} + {availableProviders.length > 0 && ( +
+

Link a Social Account

+
+ {availableProviders.map((provider) => { + const config = providerConfig[provider] + if (!config) return null + return ( + + ) + })} +
+
+ )} +
+ ) +} +``` + +Note: This requires `react-icons/fa` (already used in the project for `FaSteam` and `FaDiscord`). + +- [ ] **Step 2: Build check** + +Run `bun run build 2>&1 | tail -30` and verify no type errors. + +- [ ] **Step 3: Commit** + +```bash +git add components/profile/settings-accounts-tab.tsx +git commit -m "feat: add linked accounts settings tab with link/unlink" +``` + +--- + +### Task 6: Profile Page — Wire Up Settings Sub-Tabs + +**Files:** +- Modify: `app/profile/page.tsx` + +**Context:** Now we wire the three settings sub-tab components into the profile page. The Settings tab formerly showed a placeholder. It now shows three inner sub-tabs: Profile, Security, Linked Accounts. The page state needs a `SettingsTab` type to track which sub-tab is active. + +- [ ] **Step 1: Update profile page types and imports** + +In `app/profile/page.tsx`, add imports for the three new settings components and update the `Tab` type: + +```tsx +import { SettingsProfileTab } from "@/components/profile/settings-profile-tab" +import { SettingsSecurityTab } from "@/components/profile/settings-security-tab" +import { SettingsAccountsTab } from "@/components/profile/settings-accounts-tab" + +type Tab = "overview" | "saved" | "settings" +type SettingsTab = "profile" | "security" | "accounts" +``` + +- [ ] **Step 2: Add settingsSubTab state** + +Add `const [settingsSubTab, setSettingsSubTab] = useState("profile")` to the component. + +- [ ] **Step 3: Replace the Settings tab placeholder content** + +Replace the settings tab content (the `
` block) with the actual sub-tabs: + +```tsx +{activeTab === "settings" && ( +
+ {/* Settings sub-tabs */} +
+ {([ + { id: "profile" as SettingsTab, label: "Profile" }, + { id: "security" as SettingsTab, label: "Security" }, + { id: "accounts" as SettingsTab, label: "Linked Accounts" }, + ]).map((subTab) => ( + + ))} +
+ + {/* Settings sub-tab content */} + {settingsSubTab === "profile" && ( + + )} + {settingsSubTab === "security" && } + {settingsSubTab === "accounts" && } +
+)} +``` + +- [ ] **Step 4: Update the `Overview` tab icon** + +The current tabs array uses `Settings` icon for both Overview and Settings tabs. Fix the icon mapping: + +```tsx +const tabs: { id: Tab; label: string; icon: typeof Bookmark }[] = [ + { id: "overview", label: "Overview", icon: TrendingUp }, + { id: "saved", label: "Saved Games", icon: Bookmark }, + { id: "settings", label: "Settings", icon: Settings }, +] +``` + +Make sure `TrendingUp` is imported from lucide-react (it's already imported via StatsRow). + +- [ ] **Step 5: Build check** + +Run `bun run build 2>&1 | tail -30` and verify no type errors. + +- [ ] **Step 6: Commit** + +```bash +git add app/profile/page.tsx +git commit -m "feat: wire up settings sub-tabs in profile page" +``` + +--- + +### Task 7: Hardware Stats API Endpoints + +**Files:** +- Create: `lib/api/hardware-stats.ts` +- Modify: `lib/api/index.ts` +- Modify: `app/api/[[...slugs]]/route.ts` + +**Context:** Create two new API endpoints: +1. `GET /api/hardware/stats` — Returns all devices with aggregated benchmark data +2. `GET /api/hardware/:slug/stats` — Returns detailed stats for a single device (similar structure to game-stats) + +These endpoints query `performanceEntries` joined with `games`, `gameVersions`, and `hardware` tables. + +- [ ] **Step 1: Create the hardware-stats API file** + +Create `lib/api/hardware-stats.ts`: + +```ts +import { Elysia, t } from "elysia" +import { db } from "@/lib/db/index" +import { + hardware, + performanceEntries, + gameVersions, + games, +} from "@/lib/db/schema" +import { eq, and, sql, desc } from "drizzle-orm" + +export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" }) + // ── All devices with aggregated stats ────────────────────── + .get( + "/stats", + async () => { + // Get all hardware devices + const devices = await db + .select({ + slug: hardware.slug, + name: hardware.name, + deviceType: hardware.deviceType, + sortOrder: hardware.sortOrder, + }) + .from(hardware) + .orderBy(hardware.sortOrder) + + // Get aggregated stats per device + const statsPerDevice = await db + .select({ + hardwareSlug: performanceEntries.hardwareSlug, + totalBenchmarks: sql`count(*)::int`, + avgFps: sql`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`, + verifiedCount: sql`count(*) filter (where ${performanceEntries.verifiedAt} is not null)::int`, + gameCount: sql`count(distinct ${gameVersions.gameId})::int`, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(games, eq(gameVersions.gameId, games.id)) + .innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug)) + .where(eq(performanceEntries.isRemoved, false)) + .groupBy(performanceEntries.hardwareSlug) + + const statsMap = new Map(statsPerDevice.map((s) => [s.hardwareSlug, s])) + + // Best game per device + const bestGames = await db + .select({ + hardwareSlug: performanceEntries.hardwareSlug, + gameId: games.id, + gameTitle: games.title, + gameHeaderImage: games.headerImage, + fpsAvg: sql`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(games, eq(gameVersions.gameId, games.id)) + .where(eq(performanceEntries.isRemoved, false)) + .groupBy(performanceEntries.hardwareSlug, games.id, games.title, games.headerImage) + .orderBy(desc(sql`avg(${performanceEntries.fpsAvg})`)) + + // For each device, pick the best game + const bestGameMap = new Map() + for (const bg of bestGames) { + if (!bestGameMap.has(bg.hardwareSlug)) { + bestGameMap.set(bg.hardwareSlug, { + id: bg.gameId, + title: bg.gameTitle, + headerImage: bg.gameHeaderImage, + fpsAvg: Number(bg.fpsAvg), + }) + } + } + + return devices.map((device) => { + const stats = statsMap.get(device.slug) + const bestGame = bestGameMap.get(device.slug) + return { + slug: device.slug, + name: device.name, + deviceType: device.deviceType, + sortOrder: device.sortOrder, + totalBenchmarks: stats?.totalBenchmarks ?? 0, + avgFps: stats?.avgFps ? Number(stats.avgFps) : null, + gameCount: stats?.gameCount ?? 0, + verifiedCount: stats?.verifiedCount ?? 0, + bestGame: bestGame ?? null, + } + }) + }, + ) + // ── Single device detailed stats ─────────────────────────── + .get( + "/:slug/stats", + async ({ params, set }) => { + const { slug } = params + + // Verify hardware exists + const [device] = await db + .select({ + slug: hardware.slug, + name: hardware.name, + deviceType: hardware.deviceType, + }) + .from(hardware) + .where(eq(hardware.slug, slug)) + .limit(1) + + if (!device) { + set.status = 404 + return { error: "Device not found" } + } + + // All entries for this device + const entries = await db + .select({ + id: performanceEntries.id, + gameId: games.id, + gameTitle: games.title, + gameHeaderImage: games.headerImage, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsHigh: performanceEntries.fpsHigh, + fsrVersion: performanceEntries.fsrVersion, + frameGenMethod: performanceEntries.frameGenMethod, + protonVersion: performanceEntries.protonVersion, + osVersion: performanceEntries.osVersion, + upvotes: performanceEntries.upvotes, + downvotes: performanceEntries.downvotes, + verifiedAt: performanceEntries.verifiedAt, + createdAt: performanceEntries.createdAt, + genres: games.genres, + }) + .from(performanceEntries) + .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id)) + .innerJoin(games, eq(gameVersions.gameId, games.id)) + .where( + and( + eq(performanceEntries.hardwareSlug, slug), + eq(performanceEntries.isRemoved, false), + ) + ) + + if (entries.length === 0) { + return { + ...device, + totalBenchmarks: 0, + avgFps: null, + verifiedCount: 0, + gameCount: 0, + boxplot: [], + historical: [], + topGames: [], + genreBreakdown: [], + protonBreakdown: [], + fsrBreakdown: [], + } + } + + const totalBenchmarks = entries.length + const avgFps = Math.round( + (entries.reduce((s, e) => s + (e.fpsAvg ?? 0), 0) / totalBenchmarks) * 10 + ) / 10 + const verifiedCount = entries.filter((e) => e.verifiedAt !== null).length + + // Unique game count + const gameIds = new Set(entries.map((e) => e.gameId)) + const gameCount = gameIds.size + + // ── Boxplot: FPS distribution per game ─────────────── + const gameFpsMap = new Map() + for (const e of entries) { + if (!gameFpsMap.has(e.gameId)) { + gameFpsMap.set(e.gameId, { title: e.gameTitle, values: [] }) + } + gameFpsMap.get(e.gameId)!.values.push(e.fpsAvg ?? 0) + } + + // Top 10 games by benchmark count for boxplot + const topGameEntries = [...gameFpsMap.entries()] + .sort((a, b) => b[1].values.length - a[1].values.length) + .slice(0, 10) + + const boxplot = topGameEntries.map(([gameId, { title, values }]) => { + const sorted = [...values].sort((a, b) => a - b) + const n = sorted.length + return { + gameId, + gameTitle: title, + min: sorted[0], + q1: sorted[Math.floor(n * 0.25)] ?? sorted[0], + median: sorted[Math.floor(n * 0.5)] ?? sorted[0], + q3: sorted[Math.floor(n * 0.75)] ?? sorted[n - 1], + max: sorted[n - 1], + count: n, + } + }) + + // ── Historical: avg FPS per month ─────────────────── + const monthMap = new Map() + for (const e of entries) { + const month = `${e.createdAt.getFullYear()}-${String(e.createdAt.getMonth() + 1).padStart(2, "0")}` + if (!monthMap.has(month)) monthMap.set(month, { sum: 0, count: 0 }) + const m = monthMap.get(month)! + m.sum += e.fpsAvg ?? 0 + m.count++ + } + + const historical = [...monthMap.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([period, { sum, count }]) => ({ + period, + avgFps: Math.round((sum / count) * 10) / 10, + count, + })) + + // ── Top Games by avg FPS ──────────────────────────── + const topGames = [...gameFpsMap.entries()] + .map(([gameId, { title, values }]) => ({ + gameId, + gameTitle: title, + headerImage: entries.find((e) => e.gameId === gameId)?.gameHeaderImage ?? null, + avgFps: Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 10) / 10, + benchmarkCount: values.length, + })) + .sort((a, b) => b.avgFps - a.avgFps) + .slice(0, 20) + + // ── Genre breakdown ───────────────────────────────── + const genreMap = new Map() + for (const e of entries) { + if (e.genres && Array.isArray(e.genres)) { + for (const g of e.genres) { + genreMap.set(g, (genreMap.get(g) || 0) + 1) + } + } + } + const genreBreakdown = [...genreMap.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([genre, count]) => ({ genre, count })) + + // ── Proton breakdown ──────────────────────────────── + const protonMap = new Map() + for (const e of entries) { + if (e.protonVersion) { + protonMap.set(e.protonVersion, (protonMap.get(e.protonVersion) || 0) + 1) + } + } + const protonBreakdown = [...protonMap.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([version, count]) => ({ version, count })) + + // ── FSR breakdown ─────────────────────────────────── + const fsrMap = new Map() + for (const e of entries) { + const key = e.fsrVersion ?? "none" + if (!fsrMap.has(key)) fsrMap.set(key, { count: 0, avgFps: 0 }) + const f = fsrMap.get(key)! + f.count++ + f.avgFps += e.fpsAvg ?? 0 + } + const fsrBreakdown = [...fsrMap.entries()].map(([version, data]) => ({ + version, + count: data.count, + avgFps: Math.round((data.avgFps / data.count) * 10) / 10, + })) + + return { + ...device, + totalBenchmarks, + avgFps, + verifiedCount, + gameCount, + boxplot, + historical, + topGames, + genreBreakdown, + protonBreakdown, + fsrBreakdown, + } + }, + { + params: t.Object({ slug: t.String() }), + }, + ) +``` + +- [ ] **Step 2: Export the new routes** + +Add the export to `lib/api/index.ts`: + +```ts +export { hardwareStatsRoutes } from "./hardware-stats" +``` + +- [ ] **Step 3: Mount the routes in the API entry point** + +In `app/api/[[...slugs]]/route.ts`, add the import and `.use(hardwareStatsRoutes)`: + +```ts +import { hardwareStatsRoutes } from "@/lib/api" + +// In the app chain, add after .use(hardwareRoutes): +.use(hardwareStatsRoutes) +``` + +- [ ] **Step 4: Verify API compiles** + +Run `bun run build 2>&1 | tail -30` and check for no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add lib/api/hardware-stats.ts lib/api/index.ts app/api/\\[\\[...slugs\\]\\]/route.ts +git commit -m "feat: add hardware stats API endpoints (list + detail)" +``` + +--- + +### Task 8: Devices Grid Page + +**Files:** +- Create: `app/devices/page.tsx` (server component) +- Create: `app/devices/page-client.tsx` (client component with device cards) + +**Context:** Create the `/devices` page that shows a grid of device cards. Each card displays the device name, type, benchmark count, avg FPS, and game count. Clicking a card navigates to `/devices/[slug]`. The page fetches data from `/api/hardware/stats`. + +- [ ] **Step 1: Create the server component** + +Create `app/devices/page.tsx`: + +```tsx +import { DevicesPageClient } from "./page-client" + +export const metadata = { + title: "Devices — DeckyVault", + description: "Browse handheld and console devices with benchmark data on DeckyVault", +} + +export default function DevicesPage() { + return +} +``` + +- [ ] **Step 2: Create the client component** + +Create `app/devices/page-client.tsx`: + +```tsx +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { motion } from "motion/react" +import { Gamepad2Icon, TrendingUpIcon, DatabaseIcon, CheckCircleIcon, ArrowRightIcon, Loader2 } from "lucide-react" + +interface DeviceStats { + slug: string + name: string + deviceType: string + sortOrder: number + totalBenchmarks: number + avgFps: number | null + gameCount: number + verifiedCount: number + bestGame: { + id: string + title: string + headerImage: string | null + fpsAvg: number + } | null +} + +export function DevicesPageClient() { + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function fetchDevices() { + try { + const res = await fetch("/api/hardware/stats") + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + setDevices(data) + } catch (err) { + setError("Failed to load devices") + console.error(err) + } finally { + setLoading(false) + } + } + fetchDevices() + }, []) + + const deviceTypeLabel: Record = { + handled: "Handheld", + console: "Console", + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+
+ +

{error}

+
+
+ ) + } + + if (devices.length === 0) { + return ( +
+
+ +

No devices found

+

Benchmark data will appear as devices are added

+
+
+ ) + } + + return ( +
+ {/* Header */} + +
+

Devices

+

+ Browse benchmark data for handheld and console devices +

+
+
+ + {/* Device Grid */} + +
+ {devices.map((device, i) => ( + + + {/* Header */} +
+
+

+ {device.name} +

+ + + {deviceTypeLabel[device.deviceType] || device.deviceType} + +
+ +
+ + {/* Stats */} +
+
+ + {device.totalBenchmarks} + Benchmarks +
+
+ + + {device.avgFps !== null ? device.avgFps : "—"} + + Avg FPS +
+
+ + {device.gameCount} + Games +
+
+ + {/* Best game */} + {device.bestGame && ( +
+ Top: {device.bestGame.title} · {device.bestGame.fpsAvg} FPS +
+ )} + +
+ ))} +
+
+
+ ) +} +``` + +- [ ] **Step 3: Update routes** + +In `lib/routes.ts`, confirm the `/devices` route already exists. It does — it's already there as: + +```ts +{ + title: "Devices", + href: "/devices", +}, +``` + +- [ ] **Step 4: Build check** + +Run `bun run build 2>&1 | tail -30` and verify no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add app/devices/page.tsx app/devices/page-client.tsx lib/routes.ts +git commit -m "feat: add devices grid page with device cards" +``` + +--- + +### Task 9: Devices Detail Page with Charts + +**Files:** +- Create: `app/devices/[slug]/page.tsx` (server component) +- Create: `app/devices/[slug]/device-detail-client.tsx` (client component with charts) + +**Context:** The device detail page shows comprehensive stats for a single device, following the game page layout pattern. It reuses the `EChartWrapper` chart components and theme. Charts include: Historical FPS trend, FPS Distribution (boxplot by game), Genre Breakdown (donut), Proton Version Breakdown, and Top Games list. + +- [ ] **Step 1: Create the server component** + +Create `app/devices/[slug]/page.tsx`: + +```tsx +import { db } from "@/lib/db/index" +import { hardware } from "@/lib/db/schema" +import { eq } from "drizzle-orm" +import { notFound } from "next/navigation" +import { DeviceDetailClient } from "./device-detail-client" + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const [device] = await db + .select({ name: hardware.name }) + .from(hardware) + .where(eq(hardware.slug, slug)) + .limit(1) + + if (!device) return { title: "Device Not Found — DeckyVault" } + + return { + title: `${device.name} — DeckyVault`, + description: `Benchmark data and performance stats for ${device.name} on DeckyVault`, + } +} + +export default async function DevicePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + + const [device] = await db + .select({ + slug: hardware.slug, + name: hardware.name, + deviceType: hardware.deviceType, + }) + .from(hardware) + .where(eq(hardware.slug, slug)) + .limit(1) + + if (!device) { + notFound() + } + + return +} +``` + +- [ ] **Step 2: Create the device detail client component** + +Create `app/devices/[slug]/device-detail-client.tsx`: + +This component fetches `/api/hardware/:slug/stats` and renders a page similar to the game page. It includes: +- Hero header (device name, type badge, stats) +- Historical FPS chart (using EChartWrapper directly, matching the HistoricalAreaChart pattern) +- FPS Distribution boxplot (by game, using EChartWrapper) +- Genre Breakdown donut chart +- Top Games list +- Proton/FSR breakdown sections + +The component will be structured similarly to `GamePageClient` with `max-w-7xl` sections, `motion.div` animations, and card-based layout. + +```tsx +"use client" + +import { useEffect, useMemo, useState, useCallback } from "react" +import Link from "next/link" +import { motion } from "motion/react" +import { Gamepad2Icon, TrendingUpIcon, DatabaseIcon, CheckCircleIcon, ExternalLinkIcon, ArrowRightIcon, Loader2 } from "lucide-react" +import { EChartWrapper, CHART_THEME, getDeviceColor } from "@/components/charts/EChartWrapper" +import type { EChartsOption } from "echarts" + +interface DeviceInfo { + slug: string + name: string + deviceType: string +} + +interface DeviceStats { + slug: string + name: string + deviceType: string + totalBenchmarks: number + avgFps: number | null + verifiedCount: number + gameCount: number + boxplot: Array<{ + gameId: string + gameTitle: string + min: number + q1: number + median: number + q3: number + max: number + count: number + }> + historical: Array<{ + period: string + avgFps: number + count: number + }> + topGames: Array<{ + gameId: string + gameTitle: string + headerImage: string | null + avgFps: number + benchmarkCount: number + }> + genreBreakdown: Array<{ genre: string; count: number }> + protonBreakdown: Array<{ version: string; count: number }> + fsrBreakdown: Array<{ version: string; count: number; avgFps: number }> +} + +const deviceTypeLabel: Record = { + handled: "Handheld", + console: "Console", +} + +export function DeviceDetailClient({ device }: { device: DeviceInfo }) { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + async function fetchStats() { + setLoading(true) + try { + const res = await fetch(`/api/hardware/${device.slug}/stats`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + if (!cancelled) setStats(data) + } catch (err) { + console.error("Failed to fetch device stats:", err) + } finally { + if (!cancelled) setLoading(false) + } + } + fetchStats() + return () => { cancelled = true } + }, [device.slug]) + + const deviceColor = getDeviceColor(0) + + // ── Chart Options ────────────────────────────────────────── + const historicalOption = useMemo(() => { + if (!stats || stats.historical.length === 0) return {} + return { + tooltip: { + trigger: "axis", + backgroundColor: "#1a1020", + borderColor: CHART_THEME.border, + textStyle: { color: CHART_THEME.text }, + }, + grid: { left: 50, right: 20, top: 10, bottom: 30 }, + xAxis: { + type: "category", + data: stats.historical.map((h) => h.period), + axisLine: { lineStyle: { color: CHART_THEME.border } }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 }, + }, + yAxis: { + type: "value", + axisLine: { lineStyle: { color: CHART_THEME.border } }, + splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 }, + }, + series: [ + { + type: "line", + data: stats.historical.map((h) => h.avgFps), + smooth: true, + lineStyle: { color: deviceColor, width: 2 }, + areaStyle: { + color: { + type: "linear", + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: deviceColor + "40" }, + { offset: 1, color: deviceColor + "05" }, + ], + }, + }, + symbol: "circle", + symbolSize: 4, + itemStyle: { color: deviceColor }, + }, + ], + } + }, [stats, deviceColor]) + + const boxplotOption = useMemo(() => { + if (!stats || stats.boxplot.length === 0) return {} + return { + tooltip: { + trigger: "item", + backgroundColor: "#1a1020", + borderColor: CHART_THEME.border, + textStyle: { color: CHART_THEME.text }, + }, + grid: { left: 80, right: 20, top: 10, bottom: 40 }, + xAxis: { + type: "category", + data: stats.boxplot.map((b) => b.gameTitle), + axisLine: { lineStyle: { color: CHART_THEME.border } }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 10, rotate: 30 }, + }, + yAxis: { + type: "value", + name: "FPS", + axisLine: { lineStyle: { color: CHART_THEME.border } }, + splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } }, + axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 }, + nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 11 }, + }, + series: [ + { + type: "boxplot", + data: stats.boxplot.map((b) => [b.min, b.q1, b.median, b.q3, b.max]), + itemStyle: { color: deviceColor + "30", borderColor: deviceColor }, + }, + ], + } + }, [stats, deviceColor]) + + const genreOption = useMemo(() => { + if (!stats || stats.genreBreakdown.length === 0) return {} + return { + tooltip: { + trigger: "item", + backgroundColor: "#1a1020", + borderColor: CHART_THEME.border, + textStyle: { color: CHART_THEME.text }, + }, + series: [ + { + type: "pie", + radius: ["40%", "70%"], + center: ["50%", "50%"], + data: stats.genreBreakdown.map((g, i) => ({ + name: g.genre, + value: g.count, + itemStyle: { color: CHART_THEME.deviceColors[i % CHART_THEME.deviceColors.length] }, + })), + label: { + color: CHART_THEME.textMuted, + fontSize: 10, + }, + emphasis: { + itemStyle: { shadowBlur: 10, shadowColor: "rgba(0,0,0,0.5)" }, + }, + }, + ], + ] + }, [stats]) + + return ( +
+ {/* ── Hero Header ──────────────────────────────────── */} + +
+
+

{device.name}

+ + + {deviceTypeLabel[device.deviceType] || device.deviceType} + +
+

+ Performance benchmarks and statistics +

+
+
+ + {/* ── Overview Stats ────────────────────────────────── */} + {stats && ( + +
+ + + + +
+
+ )} + + {/* ── Loading State ─────────────────────────────────── */} + {loading && ( +
+ +
+ )} + + {/* ── Charts Section ────────────────────────────────── */} + {stats && !loading && ( + +
+ {/* Row 1: Historical FPS */} + {stats.historical.length > 0 && ( +
+

Historical Performance

+ +
+ )} + + {/* Row 2: FPS Distribution + Genre Breakdown */} +
+ {stats.boxplot.length > 0 && ( +
+

FPS Distribution by Game

+ +
+ )} + {stats.genreBreakdown.length > 0 && ( +
+

Genre Breakdown

+ +
+ )} +
+ + {/* Row 3: Proton & FSR Breakdown */} + {(stats.protonBreakdown.length > 0 || stats.fsrBreakdown.length > 0) && ( +
+ {stats.protonBreakdown.length > 0 && ( +
+

Proton Version Distribution

+
+ {stats.protonBreakdown.map((p) => ( +
+ {p.version} +
+
+
+
+ {p.count} +
+
+ ))} +
+
+ )} + {stats.fsrBreakdown.length > 0 && ( +
+

FSR Version Performance

+
+ {stats.fsrBreakdown.map((f) => ( +
+ {f.version === "none" ? "Native" : f.version.toUpperCase()} +
+ {f.avgFps} FPS + ({f.count}) +
+
+ ))} +
+
+ )} +
+ )} + + {/* Row 4: Top Games */} + {stats.topGames.length > 0 && ( +
+

Top Games by Average FPS

+
+ {stats.topGames.slice(0, 8).map((game, i) => ( + +
{i + 1}
+
+

{game.gameTitle}

+
+ {game.avgFps} FPS + {game.benchmarkCount} runs +
+
+ + + ))} +
+
+ )} +
+ + )} + + {/* ── Empty state ──────────────────────────────────── */} + {stats && !loading && stats.totalBenchmarks === 0 && ( +
+
+ +

No benchmark data yet for this device

+

Data will appear as benchmarks are submitted

+
+
+ )} +
+ ) +} + +function StatCard({ icon: Icon, label, value }: { icon: React.ElementType; label: string; value: string }) { + return ( +
+
+ +
+
+ {label} + {value} +
+
+ ) +} +``` + +- [ ] **Step 3: Build check** + +Run `bun run build 2>&1 | tail -30` and verify no type errors. + +- [ ] **Step 4: Commit** + +```bash +git add app/devices/\\[slug\\]/page.tsx app/devices/\\[slug\\]/device-detail-client.tsx +git commit -m "feat: add device detail page with charts and stats" +``` + +--- + +### Task 10: Final Polish — Build & Lint Check + +**Files:** +- Possibly modify: any files with type errors or lint warnings + +**Context:** Run a full build and lint pass, fix all issues found. + +- [ ] **Step 1: Run the full build** + +```bash +cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && bun run build 2>&1 +``` + +- [ ] **Step 2: Fix any build errors** + +Check the output for TypeScript errors, missing imports, or type mismatches. Fix each error. Most likely issues: +- `authClient.linkSocialAccount` might not be available on the client type — check if it needs to be imported differently +- The `FaDiscord` and `FaGoogle` imports from `react-icons/fa` — verify these exist +- `CheckCircleIcon` in devices page-client should be `CheckCircle` from lucide-react +- EChartWrapper option types might need adjustment + +- [ ] **Step 3: Run lint check** + +```bash +cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && bun run lint 2>&1 +``` + +- [ ] **Step 4: Fix lint warnings** + +Address any eslint warnings, especially: +- Unused imports +- Missing dependencies in useEffect/useCallback +- Any `any` types that should be more specific + +- [ ] **Step 5: Verify all pages load** + +Start the dev server and manually verify: +- `/profile` loads with wider layout, no avatar, settings sub-tabs work +- `/profile` Settings → Profile (name editing) +- `/profile` Settings → Security (password + passkeys) +- `/profile` Settings → Linked Accounts (link/unlink) +- `/devices` shows device grid +- `/devices/[slug]` shows device detail with charts +- Navbar no longer shows avatar, just "Profile" text + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "chore: build and lint fixes for profile and devices pages" +``` \ No newline at end of file