diff --git a/frontend/__tests__/leaderboard-helpers.test.ts b/frontend/__tests__/leaderboard-helpers.test.ts new file mode 100644 index 0000000..c3917e6 --- /dev/null +++ b/frontend/__tests__/leaderboard-helpers.test.ts @@ -0,0 +1,56 @@ +import { + getAllTimeRank, + sortLeaderboardRows, + toLeaderboardEntries, + type LeaderboardProfileRow, +} from "@/app/api/leaderboard/helpers"; + +function makeRow(overrides: Partial): LeaderboardProfileRow { + return { + id: "user", + handle: "user", + display_name: "User", + uploaded_note_count: 0, + total_credits_earned: 0, + credit_score: 0, + created_at: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("leaderboard helpers", () => { + it("sorts rows by uploads, then credits earned, then net credits, then created_at", () => { + const rows = [ + makeRow({ id: "b", uploaded_note_count: 2, total_credits_earned: 4, credit_score: 5, created_at: "2026-01-02T00:00:00.000Z" }), + makeRow({ id: "a", uploaded_note_count: 3, total_credits_earned: 1, credit_score: 1, created_at: "2026-01-03T00:00:00.000Z" }), + makeRow({ id: "d", uploaded_note_count: 2, total_credits_earned: 4, credit_score: 6, created_at: "2026-01-04T00:00:00.000Z" }), + makeRow({ id: "c", uploaded_note_count: 2, total_credits_earned: 4, credit_score: 6, created_at: "2026-01-01T00:00:00.000Z" }), + ]; + + expect(sortLeaderboardRows(rows).map((row) => row.id)).toEqual(["a", "c", "d", "b"]); + }); + + it("returns null rank for unranked users with no uploads", () => { + const rows = [ + makeRow({ id: "ranked", uploaded_note_count: 1 }), + makeRow({ id: "unranked", uploaded_note_count: 0 }), + ]; + + expect(getAllTimeRank(rows, "unranked")).toEqual({ + rank: null, + totalContributors: 1, + }); + }); + + it("maps ranked entries with 1-based rank", () => { + const rows = [ + makeRow({ id: "u2", uploaded_note_count: 1, credit_score: 5 }), + makeRow({ id: "u1", uploaded_note_count: 2, credit_score: 7 }), + ]; + + expect(toLeaderboardEntries(rows)).toEqual([ + expect.objectContaining({ userId: "u1", rank: 1 }), + expect.objectContaining({ userId: "u2", rank: 2 }), + ]); + }); +}); diff --git a/frontend/__tests__/profile-dashboard.stats.test.tsx b/frontend/__tests__/profile-dashboard.stats.test.tsx new file mode 100644 index 0000000..f27afb0 --- /dev/null +++ b/frontend/__tests__/profile-dashboard.stats.test.tsx @@ -0,0 +1,24 @@ +import { displayStatValue, getRankValue, toProfileStats } from "@/app/dashboard/profile-dashboard/stats"; + +describe("profile dashboard stats helpers", () => { + it("fills missing stats with zero", () => { + expect(toProfileStats({})).toEqual({ + totalUploads: 0, + totalUpvotes: 0, + creditsEarned: 0, + creditsSpent: 0, + netCredits: 0, + }); + }); + + it("returns null rank when rank is missing", () => { + expect(getRankValue({ rank: {} })).toBeNull(); + expect(getRankValue({ rank: { allTime: 3 } })).toBe(3); + }); + + it("displays em dash for invalid values", () => { + expect(displayStatValue(undefined)).toBe("\u2014"); + expect(displayStatValue(null)).toBe("\u2014"); + expect(displayStatValue(12)).toBe("12"); + }); +}); diff --git a/frontend/__tests__/profile-stats.helpers.test.ts b/frontend/__tests__/profile-stats.helpers.test.ts new file mode 100644 index 0000000..67c0023 --- /dev/null +++ b/frontend/__tests__/profile-stats.helpers.test.ts @@ -0,0 +1,24 @@ +import { normalizeNetCredits, sumCreditTotals } from "@/app/api/profile/stats/helpers"; + +describe("profile stats helpers", () => { + it("aggregates positive and negative credit ledger amounts", () => { + const totals = sumCreditTotals([ + { amount: 5 }, + { amount: -3 }, + { amount: 2 }, + { amount: -1 }, + { amount: null }, + ]); + + expect(totals).toEqual({ + creditsEarned: 7, + creditsSpent: 4, + }); + }); + + it("normalizes net credits to 0 when value is null/undefined", () => { + expect(normalizeNetCredits(null)).toBe(0); + expect(normalizeNetCredits(undefined)).toBe(0); + expect(normalizeNetCredits(9)).toBe(9); + }); +}); diff --git a/frontend/app/api/leaderboard/helpers.ts b/frontend/app/api/leaderboard/helpers.ts new file mode 100644 index 0000000..0491055 --- /dev/null +++ b/frontend/app/api/leaderboard/helpers.ts @@ -0,0 +1,69 @@ +export type LeaderboardProfileRow = { + id: string; + handle: string | null; + display_name: string | null; + uploaded_note_count: number | null; + total_credits_earned: number | null; + credit_score: number | null; + created_at: string; +}; + +export type LeaderboardEntry = { + rank: number; + userId: string; + name: string; + uploads: number; + credits: number; + avatar: string; +}; + +export function buildInitials(displayName: string | null, handle: string | null): string { + const source = (displayName?.trim() || handle?.trim() || "U").replace(/^@/, ""); + const parts = source.split(/\s+/).filter(Boolean); + + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + + return source.slice(0, 2).toUpperCase(); +} + +export function sortLeaderboardRows(rows: LeaderboardProfileRow[]): LeaderboardProfileRow[] { + return [...rows].sort((a, b) => { + const uploadDiff = Number(b.uploaded_note_count ?? 0) - Number(a.uploaded_note_count ?? 0); + if (uploadDiff !== 0) return uploadDiff; + + const creditsEarnedDiff = Number(b.total_credits_earned ?? 0) - Number(a.total_credits_earned ?? 0); + if (creditsEarnedDiff !== 0) return creditsEarnedDiff; + + const creditScoreDiff = Number(b.credit_score ?? 0) - Number(a.credit_score ?? 0); + if (creditScoreDiff !== 0) return creditScoreDiff; + + return a.created_at.localeCompare(b.created_at); + }); +} + +export function toAllTimeRankedRows(rows: LeaderboardProfileRow[]): LeaderboardProfileRow[] { + return sortLeaderboardRows(rows).filter((row) => Number(row.uploaded_note_count ?? 0) > 0); +} + +export function toLeaderboardEntries(rows: LeaderboardProfileRow[]): LeaderboardEntry[] { + return toAllTimeRankedRows(rows).map((row, index) => ({ + rank: index + 1, + userId: row.id, + name: row.display_name?.trim() || row.handle || "Anonymous", + uploads: Number(row.uploaded_note_count ?? 0), + credits: Number(row.credit_score ?? 0), + avatar: buildInitials(row.display_name, row.handle), + })); +} + +export function getAllTimeRank(rows: LeaderboardProfileRow[], userId: string): { rank: number | null; totalContributors: number } { + const rankedRows = toAllTimeRankedRows(rows); + const index = rankedRows.findIndex((row) => row.id === userId); + + return { + rank: index >= 0 ? index + 1 : null, + totalContributors: rankedRows.length, + }; +} diff --git a/frontend/app/api/leaderboard/route.ts b/frontend/app/api/leaderboard/route.ts index e79706d..1562e2c 100644 --- a/frontend/app/api/leaderboard/route.ts +++ b/frontend/app/api/leaderboard/route.ts @@ -2,16 +2,7 @@ import { NextResponse } from "next/server"; import { headers } from "next/headers"; import { createClient as createSupabaseClient } from "@supabase/supabase-js"; import { createClient } from "@/utils/supabaseServerClient"; - -type LeaderboardRow = { - id: string; - handle: string | null; - display_name: string | null; - uploaded_note_count: number | null; - total_credits_earned: number | null; - credit_score: number | null; - created_at: string; -}; +import { buildInitials, sortLeaderboardRows, toLeaderboardEntries, type LeaderboardProfileRow } from "./helpers"; type WeeklyResourceRow = { profile_id: string; @@ -30,17 +21,6 @@ type ResourceStatusRow = { type Period = "all_time" | "this_week"; -function buildInitials(displayName: string | null, handle: string | null): string { - const source = (displayName?.trim() || handle?.trim() || "U").replace(/^@/, ""); - const parts = source.split(/\s+/).filter(Boolean); - - if (parts.length >= 2) { - return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); - } - - return source.slice(0, 2).toUpperCase(); -} - export async function GET(req: Request) { const headerStore = await headers(); const authHeader = headerStore.get("authorization"); @@ -83,39 +63,17 @@ export async function GET(req: Request) { const { data, error } = await adminClient .from("profiles") .select("id, handle, display_name, uploaded_note_count, total_credits_earned, credit_score, created_at") - .returns(); + .returns(); if (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } const profiles = data ?? []; - const sortedAllTime = [...profiles].sort((a, b) => { - const uploadDiff = Number(b.uploaded_note_count ?? 0) - Number(a.uploaded_note_count ?? 0); - if (uploadDiff !== 0) return uploadDiff; - - const creditsEarnedDiff = Number(b.total_credits_earned ?? 0) - Number(a.total_credits_earned ?? 0); - if (creditsEarnedDiff !== 0) return creditsEarnedDiff; - - const creditScoreDiff = Number(b.credit_score ?? 0) - Number(a.credit_score ?? 0); - if (creditScoreDiff !== 0) return creditScoreDiff; - - return a.created_at.localeCompare(b.created_at); - }); + const sortedAllTime = sortLeaderboardRows(profiles); if (period === "all_time") { - const entries = sortedAllTime - .filter((row) => Number(row.uploaded_note_count ?? 0) > 0) - .map((row, index) => ({ - rank: index + 1, - userId: row.id, - name: row.display_name?.trim() || row.handle || "Anonymous", - uploads: Number(row.uploaded_note_count ?? 0), - credits: Number(row.credit_score ?? 0), - avatar: buildInitials(row.display_name, row.handle), - })); - - return NextResponse.json({ leaderboard: entries }, { status: 200 }); + return NextResponse.json({ leaderboard: toLeaderboardEntries(profiles) }, { status: 200 }); } const weekStart = new Date(); @@ -156,6 +114,7 @@ export async function GET(req: Request) { .map((row) => row.resource_id as string), ), ); + const activeUploadRewardResourceIds = new Set(); if (uploadRewardResourceIds.length > 0) { const { data: activeResources, error: activeResourcesError } = await adminClient @@ -183,6 +142,7 @@ export async function GET(req: Request) { ) { continue; } + weeklyCreditsEarned.set( row.profile_id, (weeklyCreditsEarned.get(row.profile_id) ?? 0) + Number(row.amount ?? 0), diff --git a/frontend/app/api/profile/stats/helpers.ts b/frontend/app/api/profile/stats/helpers.ts new file mode 100644 index 0000000..85377bb --- /dev/null +++ b/frontend/app/api/profile/stats/helpers.ts @@ -0,0 +1,31 @@ +export type CreditLedgerRow = { + amount: number | null; +}; + +export type CreditTotals = { + creditsEarned: number; + creditsSpent: number; +}; + +export function sumCreditTotals(rows: CreditLedgerRow[]): CreditTotals { + let creditsEarned = 0; + let creditsSpent = 0; + + for (const row of rows) { + const amount = Number(row.amount ?? 0); + if (amount > 0) { + creditsEarned += amount; + continue; + } + + if (amount < 0) { + creditsSpent += Math.abs(amount); + } + } + + return { creditsEarned, creditsSpent }; +} + +export function normalizeNetCredits(value: number | null | undefined): number { + return Number.isFinite(value) ? Number(value) : 0; +} diff --git a/frontend/app/api/profile/stats/route.ts b/frontend/app/api/profile/stats/route.ts new file mode 100644 index 0000000..90a6f4a --- /dev/null +++ b/frontend/app/api/profile/stats/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { createClient } from "@/utils/supabaseServerClient"; +import { getAllTimeRank, type LeaderboardProfileRow } from "@/app/api/leaderboard/helpers"; +import { normalizeNetCredits, sumCreditTotals, type CreditLedgerRow } from "./helpers"; + +type ProfileRow = { + credit_score: number | null; +}; + +type ResourceIdRow = { + id: string; +}; + +export async function GET() { + const headerStore = await headers(); + const authHeader = headerStore.get("authorization"); + const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") + ? authHeader.split(" ")[1]?.trim() + : null; + + const supabase = await createClient(bearerToken); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError) { + const status = userError.status === 401 ? 401 : 500; + const message = + userError.status === 401 || userError.message === "Auth session missing!" + ? "Not authenticated" + : userError.message; + return NextResponse.json({ error: message }, { status }); + } + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const userId = user.id; + + const [ + { data: profileData, error: profileError }, + { count: uploadCount, error: uploadCountError }, + { data: ledgerRows, error: ledgerError }, + { data: leaderboardRows, error: leaderboardError }, + { data: activeResourceRows, error: activeResourcesError }, + ] = await Promise.all([ + supabase + .from("profiles") + .select("credit_score") + .eq("id", userId) + .returns() + .maybeSingle(), + supabase + .from("resources") + .select("id", { count: "exact", head: true }) + .eq("profile_id", userId) + .eq("status", "active"), + supabase + .from("credits_ledger") + .select("amount") + .eq("profile_id", userId) + .returns(), + supabase + .from("profiles") + .select("id, handle, display_name, uploaded_note_count, total_credits_earned, credit_score, created_at") + .returns(), + supabase + .from("resources") + .select("id") + .eq("profile_id", userId) + .eq("status", "active") + .returns(), + ]); + + if (profileError) { + return NextResponse.json({ error: profileError.message }, { status: 500 }); + } + + if (uploadCountError) { + return NextResponse.json({ error: uploadCountError.message }, { status: 500 }); + } + + if (ledgerError) { + return NextResponse.json({ error: ledgerError.message }, { status: 500 }); + } + + if (leaderboardError) { + return NextResponse.json({ error: leaderboardError.message }, { status: 500 }); + } + + if (activeResourcesError) { + return NextResponse.json({ error: activeResourcesError.message }, { status: 500 }); + } + + const activeResourceIds = (activeResourceRows ?? []).map((row) => row.id); + let totalUpvotes = 0; + + if (activeResourceIds.length > 0) { + const { count, error } = await supabase + .from("votes") + .select("id", { count: "exact", head: true }) + .eq("value", 1) + .in("resource_id", activeResourceIds); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + totalUpvotes = count ?? 0; + } + + const { creditsEarned, creditsSpent } = sumCreditTotals(ledgerRows ?? []); + const { rank, totalContributors } = getAllTimeRank(leaderboardRows ?? [], userId); + + return NextResponse.json( + { + stats: { + totalUploads: uploadCount ?? 0, + totalUpvotes, + creditsEarned, + creditsSpent, + netCredits: normalizeNetCredits(profileData?.credit_score), + }, + rank: { + allTime: rank, + totalContributors, + }, + }, + { status: 200 }, + ); +} diff --git a/frontend/app/dashboard/profile-dashboard/page.tsx b/frontend/app/dashboard/profile-dashboard/page.tsx index ca03934..4f5fd7c 100644 --- a/frontend/app/dashboard/profile-dashboard/page.tsx +++ b/frontend/app/dashboard/profile-dashboard/page.tsx @@ -8,6 +8,7 @@ import type { User } from "@supabase/supabase-js"; import { DesignNav } from "@/app/components/DesignNav"; import { ThemeToggle } from "@/app/components/ThemeToggle"; import { useTheme } from "@/app/components/ThemeProvider"; +import { displayStatValue, getRankValue, toProfileStats, type ProfileStats } from "./stats"; import "./profile-dashboard.css"; import "../course-detail.css"; @@ -62,6 +63,10 @@ export default function Page() { const [loadingUploads, setLoadingUploads] = useState(false); const [loadingDownloads, setLoadingDownloads] = useState(false); const [notesError, setNotesError] = useState(null); + const [profileStats, setProfileStats] = useState(null); + const [leaderboardRank, setLeaderboardRank] = useState(null); + const [loadingStats, setLoadingStats] = useState(false); + const [statsError, setStatsError] = useState(null); const [isVisible, setIsVisible] = useState(false); const router = useRouter(); @@ -168,6 +173,45 @@ export default function Page() { void fetchDownloads(); }, [tokenLoaded, accessToken, fetchDownloads]); + const fetchProfileStats = useCallback(async () => { + if (!accessToken) return; + setLoadingStats(true); + setStatsError(null); + + try { + const res = await fetch("/api/profile/stats", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const payload = (await res.json().catch(() => ({}))) as { error?: string }; + setStatsError(payload.error ?? "Failed to load stats"); + setProfileStats(null); + setLeaderboardRank(null); + return; + } + + const payload = (await res.json()) as { + stats?: ProfileStats; + rank?: { allTime?: number | null; totalContributors?: number }; + }; + + setProfileStats(toProfileStats(payload)); + setLeaderboardRank(getRankValue(payload)); + } catch { + setStatsError("Failed to load stats"); + setProfileStats(null); + setLeaderboardRank(null); + } finally { + setLoadingStats(false); + } + }, [accessToken]); + + useEffect(() => { + if (!tokenLoaded || !accessToken) return; + void fetchProfileStats(); + }, [tokenLoaded, accessToken, fetchProfileStats]); + const handleLogout = useCallback(async () => { setLoggingOut(true); await supabase.auth.signOut(); @@ -178,6 +222,7 @@ export default function Page() { const displayName = getDisplayName(user); const handle = getHandle(user); const { theme } = useTheme(); + const displayedStats = profileStats ?? null; return (
@@ -211,7 +256,9 @@ export default function Page() {

Leaderboard Rank

-

+

+ {loadingStats ? "—" : displayStatValue(leaderboardRank)} +

Top contributor this week

@@ -333,26 +380,39 @@ export default function Page() {
Total Uploads - 0 + + {loadingStats ? "—" : displayStatValue(displayedStats?.totalUploads)} +
Total Upvotes - 0 + + {loadingStats ? "—" : displayStatValue(displayedStats?.totalUpvotes)} +
Credits Earned - 0 + + {loadingStats ? "—" : displayStatValue(displayedStats?.creditsEarned)} +
Credits Spent - 0 + + {loadingStats ? "—" : displayStatValue(displayedStats?.creditsSpent)} +
Net Credits - 0 + + {loadingStats ? "—" : displayStatValue(displayedStats?.netCredits)} +
+ {statsError && ( +

{statsError}

+ )}

Appearance

diff --git a/frontend/app/dashboard/profile-dashboard/stats.ts b/frontend/app/dashboard/profile-dashboard/stats.ts new file mode 100644 index 0000000..3ed6a92 --- /dev/null +++ b/frontend/app/dashboard/profile-dashboard/stats.ts @@ -0,0 +1,36 @@ +export type ProfileStats = { + totalUploads: number; + totalUpvotes: number; + creditsEarned: number; + creditsSpent: number; + netCredits: number; +}; + +export type ProfileStatsResponse = { + stats?: Partial; + rank?: { + allTime?: number | null; + totalContributors?: number; + }; +}; + +export function toProfileStats(payload: ProfileStatsResponse | null | undefined): ProfileStats { + return { + totalUploads: Number(payload?.stats?.totalUploads ?? 0), + totalUpvotes: Number(payload?.stats?.totalUpvotes ?? 0), + creditsEarned: Number(payload?.stats?.creditsEarned ?? 0), + creditsSpent: Number(payload?.stats?.creditsSpent ?? 0), + netCredits: Number(payload?.stats?.netCredits ?? 0), + }; +} + +export function getRankValue(payload: ProfileStatsResponse | null | undefined): number | null { + const rawRank = payload?.rank?.allTime; + if (typeof rawRank !== "number" || !Number.isFinite(rawRank)) return null; + return rawRank; +} + +export function displayStatValue(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "\u2014"; + return String(value); +}