Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions frontend/__tests__/leaderboard-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
getAllTimeRank,
sortLeaderboardRows,
toLeaderboardEntries,
type LeaderboardProfileRow,
} from "@/app/api/leaderboard/helpers";

function makeRow(overrides: Partial<LeaderboardProfileRow>): 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 }),
]);
});
});
24 changes: 24 additions & 0 deletions frontend/__tests__/profile-dashboard.stats.test.tsx
Original file line number Diff line number Diff line change
@@ -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");
});
});
24 changes: 24 additions & 0 deletions frontend/__tests__/profile-stats.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
69 changes: 69 additions & 0 deletions frontend/app/api/leaderboard/helpers.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
52 changes: 6 additions & 46 deletions frontend/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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<LeaderboardRow[]>();
.returns<LeaderboardProfileRow[]>();

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();
Expand Down Expand Up @@ -156,6 +114,7 @@ export async function GET(req: Request) {
.map((row) => row.resource_id as string),
),
);

const activeUploadRewardResourceIds = new Set<string>();
if (uploadRewardResourceIds.length > 0) {
const { data: activeResources, error: activeResourcesError } = await adminClient
Expand Down Expand Up @@ -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),
Expand Down
31 changes: 31 additions & 0 deletions frontend/app/api/profile/stats/helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading