From c1c015ef78d96a51aa4066744d5b64b44727d245 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 6 Aug 2026 22:46:47 +0700 Subject: [PATCH] feat : add profile page --- app/globals.css | 4 +- app/layout.tsx | 13 +- app/profile/page.tsx | 91 ++++++++ components/discord-avatar.tsx | 45 ++++ components/discord-server-tag.tsx | 28 +++ components/dropdown-menu-avatar.tsx | 128 ++++++----- components/profile/profile-login-card.tsx | 38 ++++ components/profile/profile-passport.tsx | 261 ++++++++++++++++++++++ components/realtime-refresh.tsx | 10 +- components/ui/alert.tsx | 76 +++++++ components/ui/avatar.tsx | 112 ++++++++++ docker-compose.yml | 18 +- docs/adr/0003-personal-guild-profile.md | 42 ++++ docs/glossary.md | 12 + docs/integrations/leaderboard-bot.md | 22 ++ lib/discord/discord.ts | 56 ++++- lib/discord/profile-appearance.test.ts | 26 +++ lib/discord/profile-appearance.ts | 18 ++ lib/leaderboard/database.ts | 41 ++++ lib/leaderboard/level-progress.test.ts | 45 ++++ lib/leaderboard/level-progress.ts | 69 ++++++ lib/leaderboard/ranking.test.ts | 36 +++ lib/realtime/realtime-refresh.test.ts | 58 +++++ lib/realtime/realtime-refresh.ts | 10 +- scripts/apply-0011-migration.ts | 65 ------ scripts/repair-form-migration-ledger.ts | 155 ------------- 26 files changed, 1169 insertions(+), 310 deletions(-) create mode 100644 app/profile/page.tsx create mode 100644 components/discord-avatar.tsx create mode 100644 components/discord-server-tag.tsx create mode 100644 components/profile/profile-login-card.tsx create mode 100644 components/profile/profile-passport.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 docs/adr/0003-personal-guild-profile.md create mode 100644 lib/discord/profile-appearance.test.ts create mode 100644 lib/discord/profile-appearance.ts create mode 100644 lib/leaderboard/level-progress.test.ts create mode 100644 lib/leaderboard/level-progress.ts create mode 100644 lib/leaderboard/ranking.test.ts create mode 100644 lib/realtime/realtime-refresh.test.ts delete mode 100644 scripts/apply-0011-migration.ts delete mode 100644 scripts/repair-form-migration-ledger.ts diff --git a/app/globals.css b/app/globals.css index dbe6728..052b7cb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -8,7 +8,7 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-sans); + --font-sans: var(--font-inter), var(--font-anuphan); --font-mono: var(--font-geist-mono); --font-heading: var(--font-sans); --color-sidebar-ring: var(--sidebar-ring); @@ -128,4 +128,4 @@ html { @apply font-sans; } -} \ No newline at end of file +} diff --git a/app/layout.tsx b/app/layout.tsx index 0719b9a..94b6a0e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next"; -import { Geist, Anuphan } from "next/font/google"; +import { Inter, Anuphan } from "next/font/google"; import "./globals.css"; import { cn } from "@/lib/utils"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], + display: "swap", }); const anuphan = Anuphan({ @@ -79,10 +80,10 @@ export default function RootLayout({ return ( - + - + {children} diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..b7eda7d --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,91 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getServerSession } from "next-auth"; +import { ArrowLeft } from "lucide-react"; +import { authOptions } from "@/lib/auth/auth-options"; +import { getSessionDiscordId } from "@/lib/auth/auth"; +import { getDiscordMemberProfile } from "@/lib/discord/discord"; +import { getCurrentDiscordVoiceChannels } from "@/lib/leaderboard/discord"; +import { + getMemberVoiceStats, + getMemberXpStats, +} from "@/lib/leaderboard/database"; +import { buildLevelProgress } from "@/lib/leaderboard/level-progress"; +import { ProfileLoginCard } from "@/components/profile/profile-login-card"; +import { ProfilePassport } from "@/components/profile/profile-passport"; +import { RealtimeRefresh } from "@/components/realtime-refresh"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Button } from "@/components/ui/button"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "โปรไฟล์ของฉัน", + description: "ดูเลเวล XP เวลาในห้องเสียง และอันดับ Discord ของคุณใน Erika!", + alternates: { canonical: "/profile" }, + robots: { index: false, follow: false }, +}; + +async function loadVoiceStats(discordId: string) { + const channels = await getCurrentDiscordVoiceChannels(); + return getMemberVoiceStats(discordId, channels.map((channel) => channel.id)); +} + +export default async function ProfilePage() { + const session = await getServerSession(authOptions); + const discordId = getSessionDiscordId(session); + + let content = ; + + if (session?.user && discordId) { + const [profileResult, xpResult, voiceResult] = await Promise.allSettled([ + getDiscordMemberProfile(discordId), + getMemberXpStats(discordId), + loadVoiceStats(discordId), + ]); + const profile = profileResult.status === "fulfilled" ? profileResult.value : null; + const xpStats = xpResult.status === "fulfilled" ? xpResult.value : null; + const voiceStats = voiceResult.status === "fulfilled" ? voiceResult.value : null; + + if (profileResult.status === "rejected") { + console.error("Failed to load own Discord profile", profileResult.reason); + } + if (xpResult.status === "rejected") { + console.error("Failed to load own XP stats", xpResult.reason); + } + if (voiceResult.status === "rejected") { + console.error("Failed to load own voice stats", voiceResult.reason); + } + + content = ( + <> + + + + ); + } + + return ( +
+
+ + {content} +
+
+ ); +} diff --git a/components/discord-avatar.tsx b/components/discord-avatar.tsx new file mode 100644 index 0000000..bb86aeb --- /dev/null +++ b/components/discord-avatar.tsx @@ -0,0 +1,45 @@ +import Image from "next/image"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; + +type DiscordAvatarProps = { + avatarUrl: string | null; + decorationUrl?: string | null; + alt: string; + fallback: string; + className?: string; + imageError?: boolean; + onImageError?: () => void; +}; + +export function DiscordAvatar({ + avatarUrl, + decorationUrl, + alt, + fallback, + className, + imageError = false, + onImageError, +}: DiscordAvatarProps) { + return ( +
+ + {!imageError && avatarUrl && ( + + )} + {fallback} + + {decorationUrl && ( + + )} +
+ ); +} diff --git a/components/discord-server-tag.tsx b/components/discord-server-tag.tsx new file mode 100644 index 0000000..c46b4bc --- /dev/null +++ b/components/discord-server-tag.tsx @@ -0,0 +1,28 @@ +import Image from "next/image"; +import { Badge } from "@/components/ui/badge"; +import type { DiscordMemberProfile } from "@/lib/discord/discord"; + +export function DiscordServerTag({ + primaryGuild, +}: Pick) { + if (!primaryGuild) return null; + + return ( + + {primaryGuild.badgeUrl && ( + + )} + {primaryGuild.tag} + + ); +} diff --git a/components/dropdown-menu-avatar.tsx b/components/dropdown-menu-avatar.tsx index c06d008..859e4d8 100644 --- a/components/dropdown-menu-avatar.tsx +++ b/components/dropdown-menu-avatar.tsx @@ -2,33 +2,40 @@ import { useState, type ReactNode } from "react"; import { usePathname } from "next/navigation"; -import Image from "next/image"; -import { LogOut } from "lucide-react"; +import Link from "next/link"; +import { LogOut, UserRound } from "lucide-react"; import { signOut } from "next-auth/react"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; -import type { DiscordRole } from "@/lib/discord/discord"; +import { DiscordAvatar } from "@/components/discord-avatar"; +import { DiscordServerTag } from "@/components/discord-server-tag"; +import type { + DiscordMemberProfile as DiscordMemberProfileData, + DiscordRole, +} from "@/lib/discord/discord"; +import { + discordColorToHex, +} from "@/lib/discord/profile-appearance"; -export interface DiscordMemberProfile { - id: string; - username: string; - globalName: string | null; - avatarUrl: string | null; - bannerUrl: string | null; - accentColor: number | null; - roles: DiscordRole[]; -} +export type DiscordMemberProfile = DiscordMemberProfileData; -const toHex = (n: number) => `#${n.toString(16).padStart(6, "0")}`; const DEFAULT_ROLE_COLOR = "#99aab5"; +function initials(value: string) { + return value.trim().slice(0, 2).toLocaleUpperCase("th-TH") || "?"; +} + function RolePill({ role }: { role: DiscordRole }) { - const colorHex = role.color === 0 ? DEFAULT_ROLE_COLOR : toHex(role.color); + const colorHex = role.color === 0 + ? DEFAULT_ROLE_COLOR + : discordColorToHex(role.color); return (
-
+
+
-
-
- {!imgError && displayAvatar && ( - {displayName} - )} -
+
+
-
- {displayName} +
+
+ {displayName} +
+ {profile && }
{username} @@ -116,7 +116,7 @@ function DiscordProfileCard({ )}
- +
); } @@ -136,7 +136,11 @@ export function DiscordProfileDropdown({ return ( {children} - + - + setImgError(true)} /> - {/* Footer / Sign Out */} -
+ + + + + + โปรไฟล์ของฉัน + + signOut({ callbackUrl: pathname })} className="text-destructive" > - + ออกจากระบบ -
+
); diff --git a/components/profile/profile-login-card.tsx b/components/profile/profile-login-card.tsx new file mode 100644 index 0000000..db129f2 --- /dev/null +++ b/components/profile/profile-login-card.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import { DiscordIcon } from "@/lib/icons"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export function ProfileLoginCard() { + return ( + + + ดูโปรไฟล์ของคุณ + + เข้าสู่ระบบด้วย Discord เพื่อดูเลเวล XP เวลาในห้องเสียง และอันดับของคุณ + + + + ระบบใช้บัญชี Discord เพื่อจับคู่คุณกับข้อมูลกิจกรรมในเซิร์ฟเวอร์ Erika! + + + + + + ); +} diff --git a/components/profile/profile-passport.tsx b/components/profile/profile-passport.tsx new file mode 100644 index 0000000..ebccf4b --- /dev/null +++ b/components/profile/profile-passport.tsx @@ -0,0 +1,261 @@ +import Image from "next/image"; +import { Award, Clock3, DatabaseZap, RadioTower } from "lucide-react"; +import type { DiscordMemberProfile, DiscordRole } from "@/lib/discord/discord"; +import type { LevelProgress } from "@/lib/leaderboard/level-progress"; +import { formatInteger, formatVoiceDuration } from "@/lib/leaderboard/ranking"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { DiscordAvatar } from "@/components/discord-avatar"; +import { DiscordServerTag } from "@/components/discord-server-tag"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Separator } from "@/components/ui/separator"; +import { + discordColorToHex, +} from "@/lib/discord/profile-appearance"; + +type RankedXp = LevelProgress & { rank: number | null }; +type RankedVoice = { totalSeconds: string; rank: number | null }; + +type ProfilePassportProps = { + userName: string; + userImage: string; + profile: DiscordMemberProfile | null; + profileUnavailable: boolean; + xp: RankedXp | null; + voice: RankedVoice | null; +}; + +const DEFAULT_ROLE_COLOR = "#99aab5"; + +function toHex(value: number) { + return discordColorToHex(value); +} + +function initials(value: string) { + return value.trim().slice(0, 2).toLocaleUpperCase("th-TH") || "?"; +} + +function rankLabel(rank: number | null) { + return rank ? `#${formatInteger(String(rank))}` : "ยังไม่ติดอันดับ"; +} + +function RoleBadge({ role }: { role: DiscordRole }) { + const color = role.color === 0 ? DEFAULT_ROLE_COLOR : toHex(role.color); + + return ( + + + {role.name} + + ); +} + +function UnavailableAlert({ kind }: { kind: "xp" | "voice" }) { + const Icon = kind === "xp" ? DatabaseZap : RadioTower; + + return ( + + + + {kind === "xp" ? "โหลดข้อมูล XP ไม่สำเร็จ" : "โหลดเวลาในห้องเสียงไม่สำเร็จ"} + + + ลองเปิดหน้านี้ใหม่อีกครั้งเมื่อระบบพร้อมใช้งาน + + + ); +} + +function XpCard({ xp }: { xp: RankedXp | null }) { + return ( + + + + + + ความก้าวหน้า XP + + {xp && อันดับ {rankLabel(xp.rank)}} + + เลเวลและ XP สะสมจากกิจกรรมใน Discord + + + {xp ? ( +
+
+
+

เลเวลปัจจุบัน

+

{xp.level}

+
+
+

XP สะสม

+

+ {formatInteger(xp.totalXp)} XP +

+
+
+ + {xp.isMaxLevel ? ( +
+ เลเวลสูงสุด +

+ คุณถึงเลเวลสูงสุดของระบบแล้ว +

+
+ ) : ( +
+
+ + {formatInteger(xp.xpIntoLevel)} / {formatInteger(xp.xpRequired)} XP + + + อีก {formatInteger(xp.xpToNextLevel)} XP + +
+ +
+ )} +
+ ) : ( + + )} +
+ + อันดับคำนวณจาก XP สะสมของสมาชิกเลเวล 1 ขึ้นไป + +
+ ); +} + +function VoiceCard({ voice }: { voice: RankedVoice | null }) { + const duration = voice?.totalSeconds === "0" + ? "0 นาที" + : voice + ? formatVoiceDuration(voice.totalSeconds) + : null; + + return ( + + + + + + เวลาในห้องเสียง + + {voice && อันดับ {rankLabel(voice.rank)}} + + + เวลารวมจากห้องเสียงและห้องเวทีที่ยังอยู่ในเซิร์ฟเวอร์ + + + + {voice ? ( +

{duration}

+ ) : ( + + )} +
+ + ห้องที่ถูกลบหรือไม่อยู่ในเซิร์ฟเวอร์แล้วจะไม่รวมในยอดนี้ + +
+ ); +} + +export function ProfilePassport({ + userName, + userImage, + profile, + profileUnavailable, + xp, + voice, +}: ProfilePassportProps) { + const displayName = profile?.globalName || userName || profile?.username || "สมาชิก Erika!"; + const username = profile?.username || userName; + const avatarUrl = profile?.avatarUrl || userImage; + + return ( +
+ +
+ {profile?.bannerUrl && ( + + )} +
+ +
+ +
+
+ {displayName} + {profile && } +
+ @{username} +
+
+ {xp && เลเวล {xp.level}} +
+ + {profileUnavailable && ( + + + ข้อมูล Discord บางส่วนยังไม่พร้อม + + กำลังแสดงชื่อและรูปจากบัญชีที่เข้าสู่ระบบ บทบาทและแบนเนอร์อาจยังไม่ครบ + + + )} + + {profile && profile.roles.length > 0 && ( +
+

+ บทบาทในเซิร์ฟเวอร์ +

+
+ {profile.roles.map((role) => )} +
+
+ )} +
+ + + โปรไฟล์นี้เห็นได้เฉพาะเมื่อคุณเข้าสู่ระบบด้วยบัญชี Discord ของตัวเอง + +
+ +
+ + +
+
+ ); +} diff --git a/components/realtime-refresh.tsx b/components/realtime-refresh.tsx index bf43bb2..f8f6d7b 100644 --- a/components/realtime-refresh.tsx +++ b/components/realtime-refresh.tsx @@ -31,6 +31,12 @@ type LeaderboardRealtimeRefreshProps = leaderboard: "vc"; channelId?: string; debounceMs?: number; + } + | { + topic: "leaderboards"; + leaderboard: "profile"; + channelId?: never; + debounceMs?: number; }; type RealtimeRefreshProps = @@ -48,7 +54,9 @@ export function RealtimeRefresh(props: RealtimeRefreshProps) { useEffect(() => { const filter = topic === "leaderboards" - ? leaderboard === "xp" + ? leaderboard === "profile" + ? { kind: "profile" as const } + : leaderboard === "xp" ? { kind: "xp" as const } : { kind: "vc" as const, channelId } : topic === "uploads" diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..2d8ecac --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,76 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..47ff2e2 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,112 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/docker-compose.yml b/docker-compose.yml index f7ce98c..8982a02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,12 +21,12 @@ services: network_mode: "service:app" restart: unless-stopped - worker: - build: - context: . - target: video-upload-worker - restart: unless-stopped - env_file: - - .env - volumes: - - /nfs/erika:/nfs/erika + # worker: + # build: + # context: . + # target: video-upload-worker + # restart: unless-stopped + # env_file: + # - .env + # volumes: + # - /nfs/erika:/nfs/erika diff --git a/docs/adr/0003-personal-guild-profile.md b/docs/adr/0003-personal-guild-profile.md new file mode 100644 index 0000000..76d23d0 --- /dev/null +++ b/docs/adr/0003-personal-guild-profile.md @@ -0,0 +1,42 @@ +# ADR 0003: Personal guild profile read model + +- Status: Accepted +- Date: 2026-08-06 + +## Context + +Signed-in members need one private page that combines their Discord identity with XP, +level progress, voice time, and leaderboard standing. The Discord bot remains the +authoritative writer for `user_levels` and `voice_time`, while the website has only a +read-only connection to those tables. XP values use `numeric(40,0)`, which may exceed +JavaScript's safe integer range. + +## Decision + +1. `/profile` identifies the current member from the server-side NextAuth session and + Discord provider account. Anonymous visitors receive a Discord login prompt that + returns them to `/profile`; profiles cannot be selected by URL parameter. +2. Discord identity, XP, and voice data load independently. Session identity is the + fallback when Discord member metadata is unavailable, and unavailable activity + sources report section-level errors. +3. The stored bot level remains authoritative. Next-level progress duplicates the + bot's documented XP formula and performs all XP arithmetic with `bigint` before + converting the bounded percentage to a number. Level 200 is the maximum and does + not display a progress bar. +4. XP and voice ranks use the public leaderboards' shared competition ranking. XP + rank excludes level-0 members. Voice time and rank include only voice and Stage + channels that currently exist in the guild. +5. Missing activity rows display zero activity and no rank. The website does not + create rows or write inferred values back to the bot database. +6. One public leaderboard SSE subscription refreshes an open profile after either an + XP event or a voice-time event. Redis remains a notification path rather than the + source of truth. + +## Consequences + +- A signed-in member sees only the profile associated with their own Discord account. +- Profile totals and public leaderboard values share ranking and voice-channel scope. +- The bot formula is a cross-service contract. Formula or maximum-level changes must + update the bot integration document and website helper together. +- Large cumulative XP values remain exact through database reads, calculations, and + formatting. diff --git a/docs/glossary.md b/docs/glossary.md index 25071ac..fc96f0d 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -63,3 +63,15 @@ tables, combined with current Discord channel metadata. A minimal Redis notification on `erika:sse:leaderboards` that tells an open XP or VC page to refresh its Server Component data after a bot transaction commits. + +## Guild profile + +The private `/profile` view for the currently authenticated Discord member. It joins +Discord identity and roles with that member's level, XP, current-channel voice time, +and leaderboard ranks without exposing a public member-profile URL. + +## Level progress + +The member's XP accumulated since reaching their stored level, measured against the +bot's XP requirement for the next level. Level 200 is the maximum and has no next-level +progress bar. diff --git a/docs/integrations/leaderboard-bot.md b/docs/integrations/leaderboard-bot.md index 6d49a91..b53e1ea 100644 --- a/docs/integrations/leaderboard-bot.md +++ b/docs/integrations/leaderboard-bot.md @@ -3,6 +3,28 @@ The Discord bot owns all writes to `user_levels` and `voice_time`. The website only subscribes to notifications and then reads the committed database state. +## Level formula contract + +The website's personal profile calculates next-level progress from the bot's stored +level and cumulative XP. Both services must use this exact formula: + +```text +required(0) = 500 +required(level) = required(level - 1) + 100 + (level * 25) +total(0) = 0 +total(level) = total(level - 1) + required(level - 1) +``` + +`required(level)` is the XP needed to advance from that level to the next. `total(level)` +is the cumulative XP needed to reach that level. Level 200 is the maximum; the XP +requirement caps at the level-199 value and the profile does not show next-level +progress at the cap. + +Store cumulative XP as an integer value and update `xp` and `level` in the same bot +transaction. Changing the formula or maximum level requires a coordinated website +update because the website intentionally does not infer the formula from database +metadata. + ## Redis channel Publish leaderboard notifications to exactly: diff --git a/lib/discord/discord.ts b/lib/discord/discord.ts index 986f32f..9c2a5e6 100644 --- a/lib/discord/discord.ts +++ b/lib/discord/discord.ts @@ -1,4 +1,8 @@ import "server-only"; +import { + discordAvatarDecorationUrl, + discordGuildTagBadgeUrl, +} from "@/lib/discord/profile-appearance"; export interface DiscordRole { id: string; @@ -12,8 +16,10 @@ export interface DiscordMemberProfile { username: string; globalName: string | null; avatarUrl: string | null; + avatarDecorationUrl: string | null; bannerUrl: string | null; accentColor: number | null; + primaryGuild: { tag: string; badgeUrl: string | null } | null; roles: DiscordRole[]; } @@ -136,48 +142,78 @@ export async function getDiscordMemberProfile(discordId: string): Promise allRoles.find((role) => role.id === id)) .filter((role): role is DiscordRole => Boolean(role)) .sort((a, b) => b.position - a.position); - const { user } = member; + const user = { ...member.user, ...publicUser }; const avatarUrl = user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${user.avatar.startsWith("a_") ? "gif" : "png"}?size=128` : null; const bannerUrl = user.banner ? `https://cdn.discordapp.com/banners/${user.id}/${user.banner}.${user.banner.startsWith("a_") ? "gif" : "png"}?size=512` : null; + const primaryGuild = user.primary_guild?.identity_enabled && + user.primary_guild.identity_guild_id && user.primary_guild.tag + ? { + tag: user.primary_guild.tag, + badgeUrl: discordGuildTagBadgeUrl( + user.primary_guild.identity_guild_id, + user.primary_guild.badge, + ), + } + : null; const result = { id: user.id, username: user.username, globalName: user.global_name ?? null, avatarUrl, + avatarDecorationUrl: discordAvatarDecorationUrl( + user.avatar_decoration_data?.asset, + ), bannerUrl, accentColor: user.accent_color ?? null, + primaryGuild, roles, }; diff --git a/lib/discord/profile-appearance.test.ts b/lib/discord/profile-appearance.test.ts new file mode 100644 index 0000000..3d416ce --- /dev/null +++ b/lib/discord/profile-appearance.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { + discordAvatarDecorationUrl, + discordColorToHex, + discordGuildTagBadgeUrl, +} from "@/lib/discord/profile-appearance"; + +describe("Discord profile appearance", () => { + test("formats Discord accent colors", () => { + expect(discordColorToHex(0xff00aa)).toBe("#ff00aa"); + }); + + test("builds the documented avatar decoration CDN URL", () => { + expect(discordAvatarDecorationUrl("a_frame_asset")).toBe( + "https://cdn.discordapp.com/avatar-decoration-presets/a_frame_asset.png?size=256", + ); + expect(discordAvatarDecorationUrl(null)).toBeNull(); + }); + + test("builds the documented primary guild badge CDN URL", () => { + expect(discordGuildTagBadgeUrl("123", "badge_hash")).toBe( + "https://cdn.discordapp.com/guild-tag-badges/123/badge_hash.png?size=64", + ); + expect(discordGuildTagBadgeUrl("123", null)).toBeNull(); + }); +}); diff --git a/lib/discord/profile-appearance.ts b/lib/discord/profile-appearance.ts new file mode 100644 index 0000000..69c3124 --- /dev/null +++ b/lib/discord/profile-appearance.ts @@ -0,0 +1,18 @@ +export function discordColorToHex(value: number) { + return `#${value.toString(16).padStart(6, "0")}`; +} + +export function discordAvatarDecorationUrl(asset: string | null | undefined) { + return asset + ? `https://cdn.discordapp.com/avatar-decoration-presets/${encodeURIComponent(asset)}.png?size=256` + : null; +} + +export function discordGuildTagBadgeUrl( + guildId: string | null | undefined, + badge: string | null | undefined, +) { + return guildId && badge + ? `https://cdn.discordapp.com/guild-tag-badges/${encodeURIComponent(guildId)}/${encodeURIComponent(badge)}.png?size=64` + : null; +} diff --git a/lib/leaderboard/database.ts b/lib/leaderboard/database.ts index 5dc3848..66d0ad9 100644 --- a/lib/leaderboard/database.ts +++ b/lib/leaderboard/database.ts @@ -69,6 +69,29 @@ export async function getXpLeaderboardPage( return buildXpLeaderboard(rows, requestedPage, searchQuery); } +export async function getMemberXpStats(userId: string) { + const database = getLeaderboardDatabase(); + const [sourceRows, leaderboard] = await Promise.all([ + database + .select({ + level: userLevels.level, + totalXp: userLevels.xp, + }) + .from(userLevels) + .where(eq(userLevels.userId, userId)) + .limit(1), + getXpLeaderboardPage(1, userId), + ]); + const rankedRow = leaderboard.rows.find((row) => row.userId === userId); + const sourceRow = sourceRows[0]; + + return { + level: sourceRow?.level ?? 0, + totalXp: sourceRow?.totalXp ?? "0", + rank: rankedRow?.rank ?? null, + }; +} + export async function getVoiceLeaderboardPage( currentChannelIds: string[], requestedPage: number, @@ -109,6 +132,24 @@ export async function getVoiceLeaderboardPage( ); } +export async function getMemberVoiceStats( + userId: string, + currentChannelIds: string[], +) { + const leaderboard = await getVoiceLeaderboardPage( + currentChannelIds, + 1, + undefined, + userId, + ); + const rankedRow = leaderboard.rows.find((row) => row.userId === userId); + + return { + totalSeconds: rankedRow?.totalSeconds ?? "0", + rank: rankedRow?.rank ?? null, + }; +} + export async function getLeaderboardDatabaseReadOnlyStatus() { const result = await getLeaderboardDatabase().execute<{ transaction_read_only: string; diff --git a/lib/leaderboard/level-progress.test.ts b/lib/leaderboard/level-progress.test.ts new file mode 100644 index 0000000..97b93b0 --- /dev/null +++ b/lib/leaderboard/level-progress.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { + buildLevelProgress, + totalXpForLevel, + xpRequiredForLevel, +} from "@/lib/leaderboard/level-progress"; + +describe("leaderboard level formula", () => { + test("matches the bot thresholds", () => { + expect(xpRequiredForLevel(0)).toBe(BigInt(500)); + expect(xpRequiredForLevel(1)).toBe(BigInt(625)); + expect(totalXpForLevel(1)).toBe(BigInt(500)); + expect(totalXpForLevel(2)).toBe(BigInt(1_125)); + }); + + test("calculates progress at level boundaries", () => { + expect(buildLevelProgress(0, "499")).toMatchObject({ + xpIntoLevel: "499", + xpRequired: "500", + xpToNextLevel: "1", + percentage: 99.8, + }); + expect(buildLevelProgress(1, "500")).toMatchObject({ + xpIntoLevel: "0", + xpRequired: "625", + xpToNextLevel: "625", + percentage: 0, + }); + }); + + test("clamps inconsistent and very large totals without losing precision", () => { + expect(buildLevelProgress(12, "1").percentage).toBe(0); + + const huge = buildLevelProgress(12, "9999999999999999999999999999999999999999"); + expect(huge.percentage).toBe(100); + expect(huge.totalXp).toBe("9999999999999999999999999999999999999999"); + }); + + test("marks level 200 as max level and caps the formula", () => { + const maxLevel = buildLevelProgress(200, totalXpForLevel(200).toString()); + + expect(maxLevel.isMaxLevel).toBeTrue(); + expect(xpRequiredForLevel(200)).toBe(xpRequiredForLevel(199)); + }); +}); diff --git a/lib/leaderboard/level-progress.ts b/lib/leaderboard/level-progress.ts new file mode 100644 index 0000000..b360bcf --- /dev/null +++ b/lib/leaderboard/level-progress.ts @@ -0,0 +1,69 @@ +export const MAX_LEVEL = 200; + +export type LevelProgress = { + level: number; + totalXp: string; + xpIntoLevel: string; + xpRequired: string; + xpToNextLevel: string; + percentage: number; + isMaxLevel: boolean; +}; + +function asNonNegativeBigInt(value: string) { + try { + const parsed = BigInt(value); + return parsed > BigInt(0) ? parsed : BigInt(0); + } catch { + return BigInt(0); + } +} + +function normalizeLevel(level: number) { + if (!Number.isFinite(level)) return 0; + return Math.min(MAX_LEVEL, Math.max(0, Math.trunc(level))); +} + +/** XP needed to advance from level n to n + 1. Levels 200+ use the level-199 cap. */ +export function xpRequiredForLevel(level: number) { + const n = BigInt(Math.min(MAX_LEVEL - 1, normalizeLevel(level))); + return BigInt(500) + + (BigInt(100) * n) + + (BigInt(25) * n * (n + BigInt(1))) / BigInt(2); +} + +/** Cumulative XP needed to reach a level from level 0. */ +export function totalXpForLevel(level: number) { + const targetLevel = normalizeLevel(level); + let total = BigInt(0); + + for (let currentLevel = 0; currentLevel < targetLevel; currentLevel += 1) { + total += xpRequiredForLevel(currentLevel); + } + + return total; +} + +export function buildLevelProgress(level: number, rawTotalXp: string): LevelProgress { + const normalizedLevel = normalizeLevel(level); + const totalXp = asNonNegativeBigInt(rawTotalXp); + const required = xpRequiredForLevel(normalizedLevel); + const levelStart = totalXpForLevel(normalizedLevel); + const unboundedProgress = totalXp > levelStart + ? totalXp - levelStart + : BigInt(0); + const xpIntoLevel = unboundedProgress > required ? required : unboundedProgress; + const basisPoints = required === BigInt(0) + ? BigInt(0) + : (xpIntoLevel * BigInt(10_000)) / required; + + return { + level: normalizedLevel, + totalXp: totalXp.toString(), + xpIntoLevel: xpIntoLevel.toString(), + xpRequired: required.toString(), + xpToNextLevel: (required - xpIntoLevel).toString(), + percentage: Number(basisPoints) / 100, + isMaxLevel: normalizedLevel >= MAX_LEVEL, + }; +} diff --git a/lib/leaderboard/ranking.test.ts b/lib/leaderboard/ranking.test.ts new file mode 100644 index 0000000..41e21a2 --- /dev/null +++ b/lib/leaderboard/ranking.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { + buildVoiceLeaderboard, + buildXpLeaderboard, +} from "@/lib/leaderboard/ranking"; + +describe("member leaderboard standing", () => { + test("keeps shared XP rank when filtering to one Discord ID", () => { + const leaderboard = buildXpLeaderboard([ + { userId: "one", username: "one", displayName: null, level: 2, totalXp: "1000" }, + { userId: "two", username: "two", displayName: null, level: 2, totalXp: "1000" }, + { userId: "three", username: "three", displayName: null, level: 1, totalXp: "500" }, + { userId: "zero", username: "zero", displayName: null, level: 0, totalXp: "499" }, + ], 1, "two"); + + expect(leaderboard.rows).toHaveLength(1); + expect(leaderboard.rows[0]?.rank).toBe(1); + expect(leaderboard.rows.some((row) => row.userId === "zero")).toBeFalse(); + }); + + test("sums only current voice channels before assigning rank", () => { + const leaderboard = buildVoiceLeaderboard([ + { userId: "one", username: "one", displayName: null, channelId: "current-a", totalSeconds: "30" }, + { userId: "one", username: "one", displayName: null, channelId: "current-b", totalSeconds: "45" }, + { userId: "one", username: "one", displayName: null, channelId: "deleted", totalSeconds: "999" }, + { userId: "two", username: "two", displayName: null, channelId: "current-a", totalSeconds: "60" }, + ], ["current-a", "current-b"], 1, undefined, "one"); + + expect(leaderboard.rows).toHaveLength(1); + expect(leaderboard.rows[0]).toMatchObject({ + userId: "one", + totalSeconds: "75", + rank: 1, + }); + }); +}); diff --git a/lib/realtime/realtime-refresh.test.ts b/lib/realtime/realtime-refresh.test.ts new file mode 100644 index 0000000..6a71f5a --- /dev/null +++ b/lib/realtime/realtime-refresh.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { + startRealtimeRefresh, + type RealtimeRefreshHandlers, +} from "@/lib/realtime/realtime-refresh"; + +function waitForRefresh() { + return new Promise((resolve) => setTimeout(resolve, 5)); +} + +describe("profile realtime refresh", () => { + test("refreshes for both XP and voice events", async () => { + let handlers: RealtimeRefreshHandlers | undefined; + let refreshes = 0; + const clean = startRealtimeRefresh({ + filter: { kind: "profile" }, + debounceMs: 0, + refresh: () => { refreshes += 1; }, + subscribe: (nextHandlers) => { + handlers = nextHandlers; + return { clean() {} }; + }, + }); + + handlers?.onXp(); + await waitForRefresh(); + handlers?.onVc({ channelId: "1234567890" }); + await waitForRefresh(); + + expect(refreshes).toBe(2); + clean(); + }); + + test("debounces profile event bursts and refreshes after reconnect", async () => { + let handlers: RealtimeRefreshHandlers | undefined; + let refreshes = 0; + const clean = startRealtimeRefresh({ + filter: { kind: "profile" }, + debounceMs: 1, + refresh: () => { refreshes += 1; }, + subscribe: (nextHandlers) => { + handlers = nextHandlers; + return { clean() {} }; + }, + }); + + handlers?.onXp(); + handlers?.onVc({ channelId: "1234567890" }); + await waitForRefresh(); + expect(refreshes).toBe(1); + + handlers?.onOpen(); + handlers?.onOpen(); + await waitForRefresh(); + expect(refreshes).toBe(2); + clean(); + }); +}); diff --git a/lib/realtime/realtime-refresh.ts b/lib/realtime/realtime-refresh.ts index 9dccfe3..f213e6c 100644 --- a/lib/realtime/realtime-refresh.ts +++ b/lib/realtime/realtime-refresh.ts @@ -1,6 +1,7 @@ export type RealtimeRefreshFilter = | { kind: "form"; formId?: string } | { kind: "uploads" } + | { kind: "profile" } | { kind: "xp" } | { kind: "vc"; channelId?: string }; @@ -50,15 +51,18 @@ export function startRealtimeRefresh({ } }, onXp() { - if (filter.kind === "xp") scheduleRefresh(); + if (filter.kind === "xp" || filter.kind === "profile") scheduleRefresh(); }, onUpload() { if (filter.kind === "uploads") scheduleRefresh(); }, onVc(event) { if ( - filter.kind === "vc" - && (!filter.channelId || event.channelId === filter.channelId) + filter.kind === "profile" + || ( + filter.kind === "vc" + && (!filter.channelId || event.channelId === filter.channelId) + ) ) { scheduleRefresh(); } diff --git a/scripts/apply-0011-migration.ts b/scripts/apply-0011-migration.ts deleted file mode 100644 index 0e63355..0000000 --- a/scripts/apply-0011-migration.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { readFile } from "fs/promises"; -import postgres from "postgres"; - -const migrationTag = "0011_unique_submission_user"; -const migrationPath = "drizzle/0011_unique_submission_user.sql"; - -const databaseUrl = process.env.DATABASE_URL; -if (!databaseUrl) { - throw new Error("DATABASE_URL is not set"); -} - -const sql = postgres(databaseUrl, { max: 1 }); - -try { - const duplicates = await sql<{ form_id: string; user_discord_id: string; count: string }[]>` - select form_id, user_discord_id, count(*)::text as count - from form.submission - where user_discord_id is not null - group by form_id, user_discord_id - having count(*) > 1 - limit 20 - `; - - if (duplicates.length > 0) { - console.error("Duplicate submissions would violate the unique index:"); - for (const duplicate of duplicates) { - console.error( - `form_id=${duplicate.form_id} user_discord_id=${duplicate.user_discord_id} count=${duplicate.count}` - ); - } - process.exit(1); - } - - await sql` - create unique index if not exists "submission_form_user_discord_id_unique" - on "form"."submission" using btree ("form_id", "user_discord_id") - `; - - const migrationTable = await sql<{ exists: boolean }[]>` - select to_regclass('drizzle.__drizzle_migrations') is not null as exists - `; - - if (migrationTable[0]?.exists) { - const existing = await sql<{ count: string }[]>` - select count(*)::text as count - from drizzle.__drizzle_migrations - where hash = ${migrationTag} - `; - - if (existing[0]?.count === "0") { - const migrationSql = await readFile(migrationPath, "utf8"); - await sql` - insert into drizzle.__drizzle_migrations (hash, created_at) - values (${migrationTag}, ${Date.now()}) - `; - console.log(`Applied ${migrationTag}: ${migrationSql.trim()}`); - } else { - console.log(`${migrationTag} was already recorded.`); - } - } else { - console.log("Applied index. Drizzle migration table was not found, so no migration row was recorded."); - } -} finally { - await sql.end(); -} diff --git a/scripts/repair-form-migration-ledger.ts b/scripts/repair-form-migration-ledger.ts deleted file mode 100644 index 5e2b60c..0000000 --- a/scripts/repair-form-migration-ledger.ts +++ /dev/null @@ -1,155 +0,0 @@ -import postgres from "postgres"; - -const databaseUrl = process.env.DATABASE_URL; -if (!databaseUrl) { - throw new Error("DATABASE_URL is not set"); -} - -const expectedMigrations = [ - { - hash: "418d5afb86b4866e697a0abfc992b913da1533e8df41cc0b618a9e23cbb70ba5", - createdAt: 1782731728406, - }, - { - hash: "5e060f0fdac0296f4953f707182eb1c872f69d44e9ef89ba75c69fbc9c895851", - createdAt: 1782731936083, - }, - { - hash: "b3d35909be61ab7fbffc6c36916d6444833385db517a302abe28cfea2b0ef544", - createdAt: 1782731954122, - }, - { - hash: "1ad2b86e381f0d8ef17839a952c30f0ea6f0452367d1dcd19aa11a65d8eeecbd", - createdAt: 1783089081925, - }, - { - hash: "f2c279f21375de159981c439e622e4ddcd8415384f7ca7287c3f1598bcb42fc4", - createdAt: 1783613113217, - }, - { - hash: "38e26a4313f207b04bb1646f6a303c0f216ad687062d16100e4ca01db5fa74c2", - createdAt: 1785152117830, - }, - { - hash: "8a65ae9a4fe4c7933d0d40182dab6da9654c8cb0f1275a8ebb43e1e0f8719256", - createdAt: 1785169071234, - }, -] as const; - -const sql = postgres(databaseUrl, { max: 1 }); - -try { - const [schemaState] = await sql<{ - form_tables: number; - allowed_roles: boolean; - denied_roles: boolean; - cdn_table: boolean; - image_cdn_id: boolean; - allow_multiple_submissions: boolean; - image_answer_mode: boolean; - submitted_at_index: boolean; - answer_image_fk: boolean; - unique_submission_index: boolean; - legacy_public_tables: boolean; - }[]>` - select - ( - select count(*)::integer - from information_schema.tables - where table_schema = 'form' - and table_name in ('answer', 'form', 'question', 'submission') - ) as form_tables, - exists ( - select 1 from information_schema.columns - where table_schema = 'form' - and table_name = 'form' - and column_name = 'allowed_roles' - ) as allowed_roles, - exists ( - select 1 from information_schema.columns - where table_schema = 'form' - and table_name = 'form' - and column_name = 'denied_roles' - ) as denied_roles, - to_regclass('form.cdn') is not null as cdn_table, - exists ( - select 1 from information_schema.columns - where table_schema = 'form' - and table_name = 'answer' - and column_name = 'image_cdn_id' - ) as image_cdn_id, - exists ( - select 1 from information_schema.columns - where table_schema = 'form' - and table_name = 'form' - and column_name = 'allow_multiple_submissions' - ) as allow_multiple_submissions, - exists ( - select 1 from information_schema.columns - where table_schema = 'form' - and table_name = 'question' - and column_name = 'image_answer_mode' - ) as image_answer_mode, - to_regclass('form.submission_form_submitted_at_id_idx') is not null - as submitted_at_index, - exists ( - select 1 - from information_schema.table_constraints - where constraint_schema = 'form' - and table_name = 'answer' - and constraint_name = 'answer_image_cdn_id_cdn_id_fk' - ) as answer_image_fk, - to_regclass('form.submission_form_user_discord_id_unique') is not null - as unique_submission_index, - exists ( - select 1 - from information_schema.tables - where table_schema = 'public' - and table_name in ('answer', 'form', 'question', 'submission') - ) as legacy_public_tables - `; - - const matchesExpectedSchema = - schemaState?.form_tables === 4 && - schemaState.allowed_roles && - schemaState.denied_roles && - schemaState.cdn_table && - schemaState.image_cdn_id && - schemaState.allow_multiple_submissions && - schemaState.image_answer_mode && - schemaState.submitted_at_index && - schemaState.answer_image_fk && - !schemaState.unique_submission_index && - !schemaState.legacy_public_tables; - - if (!matchesExpectedSchema) { - throw new Error( - "The database does not match the expected schema through migration 0013. Refusing to change the migration ledger." - ); - } - - await sql.begin(async (transaction) => { - await transaction`lock table drizzle.__drizzle_migrations in exclusive mode`; - - for (const migration of expectedMigrations) { - await transaction` - insert into drizzle.__drizzle_migrations (hash, created_at) - select ${migration.hash}, ${migration.createdAt} - where not exists ( - select 1 - from drizzle.__drizzle_migrations - where hash = ${migration.hash} - ) - `; - await transaction` - update drizzle.__drizzle_migrations - set created_at = ${migration.createdAt} - where hash = ${migration.hash} - `; - } - }); - - console.log("Reconciled the Drizzle migration ledger through 0013."); -} finally { - await sql.end(); -}