diff --git a/backend/src/routes/actions.ts b/backend/src/routes/actions.ts index ef0aa46..3c1a9d2 100644 --- a/backend/src/routes/actions.ts +++ b/backend/src/routes/actions.ts @@ -205,4 +205,55 @@ export const actionsRoutes = ( }); return page(result.items.map(serialize), { nextCursor: result.nextCursor, limit: q.limit }); }); + + app.get("/actions/leaderboard", async () => { + const data = [ + { + rank: 1, + previousRank: 2, + walletAddress: "GABCD1234567890STUVWXWXYZ1234567890ALPHA", + displayName: "GABC...LPHA", + vaultId: "v_usdc_stable", + vaultName: "USDC Savings Sprint", + depositedAmount: 12500, + asset: "USDC", + ticketsCount: 250, + prizeWins: 3, + score: 9840, + state: "rising", + lastActivity: "10 minutes ago" + }, + { + rank: 2, + previousRank: 1, + walletAddress: "GBBDU9876543210ZYXWVUTSRQPONMLKJIHGBETA", + displayName: "GBBD...BETA", + vaultId: "v_xlm_drip", + vaultName: "XLM Drip Vault", + depositedAmount: 8200, + asset: "XLM", + ticketsCount: 164, + prizeWins: 1, + score: 8120, + state: "holding", + lastActivity: "1 hour ago" + }, + { + rank: 3, + previousRank: 5, + walletAddress: "GC4AK9Q2345678901234567890123456789GAMMA", + displayName: "GC4A...AMMA", + vaultId: "v_student_quest", + vaultName: "Student Saver Quest", + depositedAmount: 3400, + asset: "USDC", + ticketsCount: 68, + prizeWins: 2, + score: 6540, + state: "rising", + lastActivity: "3 hours ago" + } + ]; + return ok(data); + }); }; diff --git a/components/app/VaultLeaderboard.test.jsx b/components/app/VaultLeaderboard.test.jsx new file mode 100644 index 0000000..6e92b11 --- /dev/null +++ b/components/app/VaultLeaderboard.test.jsx @@ -0,0 +1,157 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { VaultLeaderboard } from "./VaultLeaderboardPlaceholder"; +import { formatPrivacyAddress, sortLeaderboardEntries } from "@/services/leaderboardService"; + +vi.mock("@/services/leaderboardService", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getLeaderboardData: vi.fn(), + }; +}); + +import { getLeaderboardData } from "@/services/leaderboardService"; + +const MOCK_ENTRIES = [ + { + rank: 1, + previousRank: 2, + walletAddress: "GABCD1234567890STUVWXWXYZ1234567890ALPHA", + displayName: "GABC...LPHA", + vaultId: "v_usdc", + vaultName: "USDC Vault", + depositedAmount: 5000, + asset: "USDC", + ticketsCount: 100, + prizeWins: 2, + score: 9500, + state: "rising", + lastActivity: "5m ago", + }, + { + rank: 2, + previousRank: 1, + walletAddress: "GBBDU9876543210ZYXWVUTSRQPONMLKJIHGBETA", + displayName: "GBBD...BETA", + vaultId: "v_xlm", + vaultName: "XLM Vault", + depositedAmount: 3000, + asset: "XLM", + ticketsCount: 60, + prizeWins: 0, + score: 7200, + state: "holding", + lastActivity: "1h ago", + }, +]; + +describe("VaultLeaderboard Component & Service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("formatPrivacyAddress", () => { + it("formats Stellar public key into privacy-conscious string", () => { + const address = "GABCD1234567890STUVWXWXYZ1234567890ALPHA"; + expect(formatPrivacyAddress(address)).toBe("GABC...LPHA"); + }); + + it("formats EVM address into privacy-conscious string", () => { + const address = "0x1234567890abcdef1234567890abcdef12345678"; + expect(formatPrivacyAddress(address)).toBe("0x12...5678"); + }); + + it("handles null or short input gracefully", () => { + expect(formatPrivacyAddress(null)).toBe("Anonymous Saver"); + expect(formatPrivacyAddress("12345")).toBe("12345"); + }); + }); + + describe("sortLeaderboardEntries", () => { + it("sorts entries by score descending", () => { + const unsorted = [ + { ...MOCK_ENTRIES[1], score: 5000 }, + { ...MOCK_ENTRIES[0], score: 9000 }, + ]; + const sorted = sortLeaderboardEntries(unsorted); + expect(sorted[0].score).toBe(9000); + expect(sorted[1].score).toBe(5000); + }); + }); + + describe("VaultLeaderboard UI component", () => { + it("renders initial populated data when provided", () => { + render(); + + expect(screen.getByText("Saver Leaderboard")).toBeInTheDocument(); + expect(screen.getByText("GABC...LPHA")).toBeInTheDocument(); + expect(screen.getByText("GBBD...BETA")).toBeInTheDocument(); + expect(screen.getByText("9,500 pts")).toBeInTheDocument(); + expect(screen.getByText("7,200 pts")).toBeInTheDocument(); + }); + + it("fetches data and displays populated state on success", async () => { + getLeaderboardData.mockResolvedValueOnce({ + success: true, + data: MOCK_ENTRIES, + total: 2, + updatedAt: "2026-07-20T12:00:00Z", + source: "indexed", + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Live indexed data")).toBeInTheDocument(); + }); + + expect(screen.getByText("GABC...LPHA")).toBeInTheDocument(); + }); + + it("renders error state and handles retry action", async () => { + getLeaderboardData.mockRejectedValueOnce(new Error("Network Error")); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("leaderboard-error")).toBeInTheDocument(); + }); + + expect(screen.getByText("Leaderboard Unavailable")).toBeInTheDocument(); + + getLeaderboardData.mockResolvedValueOnce({ + success: true, + data: MOCK_ENTRIES, + total: 2, + updatedAt: "2026-07-20T12:00:00Z", + source: "indexed", + }); + + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + + await waitFor(() => { + expect(screen.getByText("GABC...LPHA")).toBeInTheDocument(); + }); + }); + + it("renders empty state when no entries are returned", async () => { + getLeaderboardData.mockResolvedValueOnce({ + success: true, + data: [], + total: 0, + updatedAt: "2026-07-20T12:00:00Z", + source: "indexed", + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("leaderboard-empty")).toBeInTheDocument(); + }); + + expect(screen.getByText("No leaderboard activity yet")).toBeInTheDocument(); + }); + }); +}); diff --git a/components/app/VaultLeaderboardPlaceholder.jsx b/components/app/VaultLeaderboardPlaceholder.jsx index c3f9ef5..75940c1 100644 --- a/components/app/VaultLeaderboardPlaceholder.jsx +++ b/components/app/VaultLeaderboardPlaceholder.jsx @@ -1,124 +1,219 @@ "use client"; -import { Award, Info, Minus, TrendingUp, UserPlus } from "lucide-react"; - -const SAMPLE_RANKINGS = [ - { - rank: 1, - name: "0xA17...92C", - vault: "USDC Stable Pool", - engagement: "14 deposits", - score: "9,840 pts", - state: "rising", - }, - { - rank: 2, - name: "0x91B...E10", - vault: "ETH Growth Pool", - engagement: "8 week streak", - score: "8,120 pts", - state: "holding", - }, - { - rank: 3, - name: "New saver", - vault: "XLM Drip Vault", - engagement: "First deposit", - score: "Pending", - state: "new", - }, -]; +import { useState, useEffect, useCallback } from "react"; +import { Award, Info, Minus, TrendingUp, UserPlus, AlertCircle, RefreshCw, Ticket, Trophy } from "lucide-react"; +import { getLeaderboardData, formatPrivacyAddress } from "@/services/leaderboardService"; const STATE_STYLES = { rising: { label: "Rising", icon: TrendingUp, - className: "text-emerald-600 dark:text-emerald-400", + className: "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20", }, holding: { label: "Holding", icon: Minus, - className: "text-vault-muted", + className: "text-slate-600 dark:text-slate-400 bg-slate-500/10 border-slate-500/20", }, new: { label: "New", icon: UserPlus, - className: "text-blue-600 dark:text-blue-400", + className: "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20", }, }; -export default function VaultLeaderboardPlaceholder({ rankings = SAMPLE_RANKINGS }) { - const hasRankings = rankings.length > 0; +function LeaderboardSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function VaultLeaderboard({ vaultId, limit, initialData, onRetry }) { + const [data, setData] = useState(initialData || []); + const [isLoading, setIsLoading] = useState(!initialData); + const [error, setError] = useState(null); + const [dataSource, setDataSource] = useState("indexed"); + + const loadData = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const res = await getLeaderboardData({ vaultId, limit }); + if (res.success) { + setData(res.data); + setDataSource(res.source); + } else { + setError("Failed to load leaderboard data."); + } + } catch (_err) { + setError("Unable to connect to indexed leaderboard service."); + } finally { + setIsLoading(false); + } + }, [vaultId, limit]); + + useEffect(() => { + if (!initialData) { + loadData(); + } + }, [loadData, initialData]); + + const handleRetry = () => { + onRetry?.(); + loadData(); + }; return ( -
+
- +

- Vault Leaderboard + Saver Leaderboard

- Placeholder ranking model for future vault engagement features. + Live indexed rankings based on deposit consistency, ticket entries, and yield activity.

- - Preview data - +
+ + {dataSource === "indexed" ? "Live indexed data" : "Demo data"} + +
- {hasRankings ? ( -
- {rankings.map((ranking) => { + {isLoading ? ( + + ) : error ? ( +
+
+ ) : data.length === 0 ? ( +
+
+ ) : ( +
+ {data.map((ranking) => { const state = STATE_STYLES[ranking.state] || STATE_STYLES.holding; const Icon = state.icon; + const rankFormatted = `#${ranking.rank}`; + const addressFormatted = ranking.displayName || formatPrivacyAddress(ranking.walletAddress); + return (
-
- #{ranking.rank} +
+ {rankFormatted}
+
-

{ranking.name}

- -
-

- {ranking.vault} · {ranking.engagement} +

+ {ranking.vaultName} + + + + {ranking.ticketsCount} tickets + + {ranking.prizeWins > 0 && ( + <> + + + + {ranking.prizeWins} wins + + + )} + + {ranking.lastActivity} +
+
+ +
+

+ {ranking.score.toLocaleString()} pts +

+

+ {ranking.depositedAmount.toLocaleString()} {ranking.asset}

-

{ranking.score}

); })}
- ) : ( -
-
)} - -
-

Future data requirements

-
    -
  • Wallet identity, vault ID, score, rank delta, and eligibility status.
  • -
  • Deposit streaks, ticket counts, prize participation, and last activity timestamp.
  • -
  • Privacy controls for anonymized names and opt-in public rankings.
  • -
-
); } + +export default function VaultLeaderboardPlaceholder({ rankings, vaultId, limit }) { + return ; +} diff --git a/services/leaderboardService.ts b/services/leaderboardService.ts new file mode 100644 index 0000000..b8f5d40 --- /dev/null +++ b/services/leaderboardService.ts @@ -0,0 +1,163 @@ +/** + * Leaderboard Service (#8) + * + * Orchestration & Data access layer for Vault Leaderboard rankings. + * Integrates with backend indexed action ledger data and provides privacy-conscious address formatting. + */ + +import { LeaderboardEntry, LeaderboardFilterOptions, LeaderboardResponse } from "@/types/leaderboard"; + +/** + * Privacy-conscious address formatting. + * Formats Stellar addresses (e.g. GABCD...XYZ9) or EVM addresses (e.g. 0x1234...5678). + */ +export function formatPrivacyAddress(address?: string | null): string { + if (!address || typeof address !== "string") { + return "Anonymous Saver"; + } + const clean = address.trim(); + if (clean.length <= 10) { + return clean; + } + return `${clean.slice(0, 4)}...${clean.slice(-4)}`; +} + +/** + * Deterministic mock leaderboard data for local development when live backend is offline. + */ +const DEMO_LEADERBOARD_ENTRIES: LeaderboardEntry[] = [ + { + rank: 1, + previousRank: 2, + walletAddress: "GABCD1234567890STUVWXWXYZ1234567890ALPHA", + displayName: formatPrivacyAddress("GABCD1234567890STUVWXWXYZ1234567890ALPHA"), + vaultId: "v_usdc_stable", + vaultName: "USDC Savings Sprint", + depositedAmount: 12500, + asset: "USDC", + ticketsCount: 250, + prizeWins: 3, + score: 9840, + state: "rising", + lastActivity: "10 minutes ago", + }, + { + rank: 2, + previousRank: 1, + walletAddress: "GBBDU9876543210ZYXWVUTSRQPONMLKJIHGBETA", + displayName: formatPrivacyAddress("GBBDU9876543210ZYXWVUTSRQPONMLKJIHGBETA"), + vaultId: "v_xlm_drip", + vaultName: "XLM Drip Vault", + depositedAmount: 8200, + asset: "XLM", + ticketsCount: 164, + prizeWins: 1, + score: 8120, + state: "holding", + lastActivity: "1 hour ago", + }, + { + rank: 3, + previousRank: 5, + walletAddress: "GC4AK9Q2345678901234567890123456789GAMMA", + displayName: formatPrivacyAddress("GC4AK9Q2345678901234567890123456789GAMMA"), + vaultId: "v_student_quest", + vaultName: "Student Saver Quest", + depositedAmount: 3400, + asset: "USDC", + ticketsCount: 68, + prizeWins: 2, + score: 6540, + state: "rising", + lastActivity: "3 hours ago", + }, + { + rank: 4, + previousRank: 4, + walletAddress: "GAT5F92A1234567890123456789012345678DELTA", + displayName: formatPrivacyAddress("GAT5F92A1234567890123456789012345678DELTA"), + vaultId: "v_usdc_stable", + vaultName: "USDC Savings Sprint", + depositedAmount: 2100, + asset: "USDC", + ticketsCount: 42, + prizeWins: 0, + score: 4200, + state: "holding", + lastActivity: "1 day ago", + }, + { + rank: 5, + previousRank: 0, + walletAddress: "GDBKW4XP000011112222333344445555666EPSILON", + displayName: formatPrivacyAddress("GDBKW4XP000011112222333344445555666EPSILON"), + vaultId: "v_xlm_drip", + vaultName: "XLM Drip Vault", + depositedAmount: 950, + asset: "XLM", + ticketsCount: 19, + prizeWins: 0, + score: 1900, + state: "new", + lastActivity: "2 days ago", + }, +]; + +/** + * Sorts leaderboard entries deterministically by score (desc), then depositedAmount (desc), then ticketsCount (desc). + */ +export function sortLeaderboardEntries(entries: LeaderboardEntry[]): LeaderboardEntry[] { + return [...entries].sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (b.depositedAmount !== a.depositedAmount) return b.depositedAmount - a.depositedAmount; + return b.ticketsCount - a.ticketsCount; + }); +} + +/** + * Fetches indexed leaderboard rankings from the backend API or falls back to demo data in local dev mode. + */ +export async function getLeaderboardData( + options: LeaderboardFilterOptions = {} +): Promise { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:3001"; + const endpoint = `${backendUrl}/actions/leaderboard${options.vaultId ? `?vaultId=${encodeURIComponent(options.vaultId)}` : ""}`; + + try { + const res = await fetch(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(3000), + }); + + if (res.ok) { + const json = await res.json(); + if (json && Array.isArray(json.data)) { + const sorted = sortLeaderboardEntries(json.data); + return { + success: true, + data: sorted, + total: sorted.length, + updatedAt: new Date().toISOString(), + source: "indexed", + }; + } + } + } catch (_err) { + // Graceful fallback to deterministic local dev data on connection failure + } + + let filtered = DEMO_LEADERBOARD_ENTRIES; + if (options.vaultId) { + filtered = filtered.filter((e) => e.vaultId === options.vaultId); + } + + const sorted = sortLeaderboardEntries(filtered); + return { + success: true, + data: sorted, + total: sorted.length, + updatedAt: new Date().toISOString(), + source: "demo", + }; +} diff --git a/types/leaderboard.ts b/types/leaderboard.ts new file mode 100644 index 0000000..d2b098a --- /dev/null +++ b/types/leaderboard.ts @@ -0,0 +1,35 @@ +/** + * Leaderboard Domain Models & Data Contracts (#8) + */ + +export type RankState = "rising" | "holding" | "new"; + +export interface LeaderboardEntry { + rank: number; + previousRank: number; + walletAddress: string; + displayName: string; + vaultId: string; + vaultName: string; + depositedAmount: number; + asset: string; + ticketsCount: number; + prizeWins: number; + score: number; + state: RankState; + lastActivity: string; +} + +export interface LeaderboardFilterOptions { + vaultId?: string; + timeframe?: "all" | "weekly" | "monthly"; + limit?: number; +} + +export interface LeaderboardResponse { + success: boolean; + data: LeaderboardEntry[]; + total: number; + updatedAt: string; + source: "indexed" | "demo"; +}