diff --git a/app/profile/page.tsx b/app/profile/page.tsx
index b7eda7d..a82572d 100644
--- a/app/profile/page.tsx
+++ b/app/profile/page.tsx
@@ -4,13 +4,7 @@ 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 { loadGuildProfile } from "@/lib/profile/guild-profile";
import { ProfileLoginCard } from "@/components/profile/profile-login-card";
import { ProfilePassport } from "@/components/profile/profile-passport";
import { RealtimeRefresh } from "@/components/realtime-refresh";
@@ -26,11 +20,6 @@ export const metadata: Metadata = {
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);
@@ -38,24 +27,7 @@ export default async function ProfilePage() {
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);
- }
+ const guildProfile = await loadGuildProfile(discordId);
content = (
<>
@@ -63,10 +35,10 @@ export default async function ProfilePage() {
>
);
diff --git a/lib/profile/guild-profile.test.ts b/lib/profile/guild-profile.test.ts
new file mode 100644
index 0000000..a9a1dd9
--- /dev/null
+++ b/lib/profile/guild-profile.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, test } from "bun:test";
+import { createGuildProfileReader } from "@/lib/profile/guild-profile";
+
+describe("Guild profile read module", () => {
+ test("assembles independent sections and current voice scope", async () => {
+ const reader = createGuildProfileReader({
+ getProfile: async () => ({
+ id: "member-1",
+ username: "member",
+ globalName: "Member",
+ avatarUrl: null,
+ avatarDecorationUrl: null,
+ bannerUrl: null,
+ accentColor: null,
+ primaryGuild: null,
+ roles: [],
+ }),
+ getXp: async () => ({ level: 2, totalXp: "1200", rank: 4 }),
+ getCurrentVoiceChannels: async () => [
+ { id: "voice-1", name: "Lounge", type: 2, position: 1 },
+ { id: "stage-1", name: "Stage", type: 13, position: 2 },
+ ],
+ getVoice: async (_discordId, channelIds) => ({
+ totalSeconds: channelIds.join(",") === "voice-1,stage-1" ? "90" : "0",
+ rank: 3,
+ }),
+ });
+
+ const result = await reader("member-1");
+
+ expect(result.profile?.id).toBe("member-1");
+ expect(result.xp).toMatchObject({ level: 2, totalXp: "1200", rank: 4 });
+ expect(result.voice).toEqual({ totalSeconds: "90", rank: 3 });
+ expect(result.profileUnavailable).toBeFalse();
+ });
+
+ test("keeps healthy sections when one source fails", async () => {
+ const failures: string[] = [];
+ const reader = createGuildProfileReader({
+ getProfile: async () => null,
+ getXp: async () => {
+ throw new Error("leaderboard unavailable");
+ },
+ getCurrentVoiceChannels: async () => [],
+ getVoice: async () => ({ totalSeconds: "0", rank: null }),
+ reportFailure: (source) => failures.push(source),
+ });
+
+ const result = await reader("member-1");
+
+ expect(result.profile).toBeNull();
+ expect(result.profileUnavailable).toBeTrue();
+ expect(result.xp).toBeNull();
+ expect(result.voice).toEqual({ totalSeconds: "0", rank: null });
+ expect(failures).toEqual(["xp"]);
+ });
+});
diff --git a/lib/profile/guild-profile.ts b/lib/profile/guild-profile.ts
new file mode 100644
index 0000000..da2c3a2
--- /dev/null
+++ b/lib/profile/guild-profile.ts
@@ -0,0 +1,89 @@
+import type { DiscordMemberProfile } from "@/lib/discord/discord";
+import type { DiscordVoiceChannel } from "@/lib/leaderboard/discord";
+import type {
+ getMemberVoiceStats,
+ getMemberXpStats,
+} from "@/lib/leaderboard/database";
+import { buildLevelProgress, type LevelProgress } from "@/lib/leaderboard/level-progress";
+
+export type GuildProfileXp = LevelProgress & { rank: number | null };
+
+export type GuildProfileVoice = Awaited>;
+
+export type GuildProfileRead = {
+ profile: DiscordMemberProfile | null;
+ profileUnavailable: boolean;
+ xp: GuildProfileXp | null;
+ voice: GuildProfileVoice | null;
+};
+
+export type GuildProfileSources = {
+ getProfile: (discordId: string) => Promise;
+ getXp: (discordId: string) => ReturnType;
+ getVoice: (
+ discordId: string,
+ currentChannelIds: string[],
+ ) => ReturnType;
+ getCurrentVoiceChannels: () => Promise;
+ reportFailure?: (source: "profile" | "xp" | "voice", error: unknown) => void;
+};
+
+function defaultReportFailure(source: string, error: unknown) {
+ console.error(`Failed to load own Guild profile ${source} data`, error);
+}
+
+export function createGuildProfileReader(
+ sources: GuildProfileSources,
+) {
+ return async function loadGuildProfile(discordId: string): Promise {
+ const reportFailure = sources.reportFailure ?? defaultReportFailure;
+ const [profileResult, xpResult, voiceResult] = await Promise.allSettled([
+ sources.getProfile(discordId),
+ sources.getXp(discordId),
+ sources.getCurrentVoiceChannels().then((channels) =>
+ sources.getVoice(discordId, channels.map((channel) => channel.id)),
+ ),
+ ]);
+
+ if (profileResult.status === "rejected") {
+ reportFailure("profile", profileResult.reason);
+ }
+ if (xpResult.status === "rejected") {
+ reportFailure("xp", xpResult.reason);
+ }
+ if (voiceResult.status === "rejected") {
+ reportFailure("voice", voiceResult.reason);
+ }
+
+ const profile = profileResult.status === "fulfilled" ? profileResult.value : null;
+ const xpStats = xpResult.status === "fulfilled" ? xpResult.value : null;
+ const voice = voiceResult.status === "fulfilled" ? voiceResult.value : null;
+
+ return {
+ profile,
+ profileUnavailable: !profile,
+ xp: xpStats
+ ? {
+ ...buildLevelProgress(xpStats.level, xpStats.totalXp),
+ rank: xpStats.rank,
+ }
+ : null,
+ voice,
+ };
+ };
+}
+
+export async function loadGuildProfile(discordId: string) {
+ const [discord, leaderboardDiscord, leaderboardDatabase] = await Promise.all([
+ import("@/lib/discord/discord"),
+ import("@/lib/leaderboard/discord"),
+ import("@/lib/leaderboard/database"),
+ ]);
+
+ return createGuildProfileReader({
+ getProfile: discord.getDiscordMemberProfile,
+ getXp: leaderboardDatabase.getMemberXpStats,
+ getVoice: leaderboardDatabase.getMemberVoiceStats,
+ getCurrentVoiceChannels: leaderboardDiscord.getCurrentDiscordVoiceChannels,
+ })(discordId);
+}