From 802d43ca6af44773149c287c906f9701120dc4bc Mon Sep 17 00:00:00 2001 From: YerimahOfTimes Date: Sun, 19 Jul 2026 10:37:32 +0100 Subject: [PATCH 1/2] feat: add TVL/balance trend sparkline to pool cards --- .../api/cron/snapshot-pool-metrics/route.ts | 78 +++++++++++++++++++ frontend/app/api/pools/history/route.ts | 33 ++++++++ frontend/components/dashboard/pool-card.tsx | 4 +- .../components/dashboard/pool-sparkline.tsx | 41 ++++++++++ frontend/lib/data-layer/PoolHistoryCache.ts | 53 +++++++++++++ vercel.json | 8 ++ 6 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 frontend/app/api/cron/snapshot-pool-metrics/route.ts create mode 100644 frontend/app/api/pools/history/route.ts create mode 100644 frontend/components/dashboard/pool-sparkline.tsx create mode 100644 frontend/lib/data-layer/PoolHistoryCache.ts create mode 100644 vercel.json diff --git a/frontend/app/api/cron/snapshot-pool-metrics/route.ts b/frontend/app/api/cron/snapshot-pool-metrics/route.ts new file mode 100644 index 0000000..82dd9af --- /dev/null +++ b/frontend/app/api/cron/snapshot-pool-metrics/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAdminClient } from "@/lib/supabase-admin" + +// Snapshots each pool's current balance into pool_daily_metrics. +// Triggered daily by Vercel Cron (see vercel.json). Reuses the same +// deposit/withdrawal aggregation logic as /api/analytics. +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization") + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const supabase = getAdminClient() + + const { data: pools, error: poolsError } = await supabase + .from("pools") + .select("id") + .returns<{ id: string }[]>() + if (poolsError || !pools) { + return NextResponse.json({ error: "Failed to fetch pools" }, { status: 500 }) + } + + const today = new Date().toISOString().slice(0, 10) + let updated = 0 + const errors: string[] = [] + + for (const pool of pools) { + const { data: activities, error: actError } = await supabase + .from("pool_activity") + .select("activity_type, amount") + .eq("pool_id", pool.id) + .returns<{ activity_type: string; amount: number | null }[]>() + + if (actError) { + errors.push(`${pool.id}: ${actError.message}`) + continue + } + + const acts = activities || [] + const totalDeposits = acts + .filter((a) => a.activity_type?.toLowerCase() === "deposit") + .reduce((sum, a) => sum + (a.amount || 0), 0) + const totalWithdrawals = acts + .filter( + (a) => + a.activity_type?.toLowerCase() === "withdraw" || + a.activity_type?.toLowerCase() === "payout" + ) + .reduce((sum, a) => sum + (a.amount || 0), 0) + + const { count: activeMembersCount } = await supabase + .from("pool_members") + .select("*", { count: "exact", head: true }) + .eq("pool_id", pool.id) + .eq("status", "paid") + + const payload = { + pool_id: pool.id, + date: today, + total_balance: totalDeposits - totalWithdrawals, + total_deposits: totalDeposits, + total_withdrawals: totalWithdrawals, + active_members_count: activeMembersCount ?? 0, + } + + const { error: upsertError } = await supabase + .from("pool_daily_metrics") + .upsert(payload as never, { onConflict: "pool_id,date" }) + + if (upsertError) { + errors.push(`${pool.id}: ${upsertError.message}`) + } else { + updated++ + } + } + + return NextResponse.json({ updated, total: pools.length, errors }) +} \ No newline at end of file diff --git a/frontend/app/api/pools/history/route.ts b/frontend/app/api/pools/history/route.ts new file mode 100644 index 0000000..e1887da --- /dev/null +++ b/frontend/app/api/pools/history/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server" +import { supabase } from "@/lib/supabase" +import { readLimiter } from "@/lib/rate-limit" + +// Batched sparkline data for many pools in one request, so the +// Explore/My Groups grid doesn't fire one fetch per card. +export async function GET(req: NextRequest) { + const limited = readLimiter(req) + if (limited) return limited + + const idsParam = req.nextUrl.searchParams.get("poolIds") + if (!idsParam) return NextResponse.json({ error: "poolIds required" }, { status: 400 }) + const poolIds = idsParam.split(",").filter(Boolean).slice(0, 100) + + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + + const { data, error } = await supabase + .from("pool_daily_metrics") + .select("pool_id, date, total_balance") + .in("pool_id", poolIds) + .gte("date", thirtyDaysAgo) + .order("date", { ascending: true }) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + const byPool: Record = {} + for (const row of data || []) { + if (!byPool[row.pool_id]) byPool[row.pool_id] = [] + byPool[row.pool_id].push(row.total_balance) + } + + return NextResponse.json({ history: byPool }) +} \ No newline at end of file diff --git a/frontend/components/dashboard/pool-card.tsx b/frontend/components/dashboard/pool-card.tsx index 7ef46b9..8f66bd7 100644 --- a/frontend/components/dashboard/pool-card.tsx +++ b/frontend/components/dashboard/pool-card.tsx @@ -16,6 +16,7 @@ import { } from "@/hooks/useJointSaveContracts" import { usePoolHealth } from "@/hooks/usePoolHealth" import { PoolHealthBadge } from "@/components/dashboard/pool-health-badge" +import { PoolSparkline } from "@/components/dashboard/pool-sparkline" export interface Pool { id: string @@ -176,12 +177,13 @@ export function PoolCard({ pool }: { pool: Pool }) { Total Saved - + {isLoading && !data?.onchain ? ( ) : ( formatXlm(totalSaved) )} +
diff --git a/frontend/components/dashboard/pool-sparkline.tsx b/frontend/components/dashboard/pool-sparkline.tsx new file mode 100644 index 0000000..e859594 --- /dev/null +++ b/frontend/components/dashboard/pool-sparkline.tsx @@ -0,0 +1,41 @@ +"use client" + +import { useEffect, useState } from "react" +import { requestPoolHistory, subscribePoolHistory } from "@/lib/data-layer/PoolHistoryCache" + +export function PoolSparkline({ poolId }: { poolId: string }) { + const [, forceRerender] = useState(0) + + useEffect(() => subscribePoolHistory(() => forceRerender((n) => n + 1)), []) + + const history = requestPoolHistory(poolId) + + // Degrade gracefully: no data yet, or not enough points to be meaningful. + if (!history || history.length < 2) return null + + const min = Math.min(...history) + const max = Math.max(...history) + const range = max - min || 1 + const width = 80 + const height = 24 + const step = width / (history.length - 1) + + const points = history + .map((v, i) => `${i * step},${height - ((v - min) / range) * height}`) + .join(" ") + + const trendingUp = history[history.length - 1] >= history[0] + + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/lib/data-layer/PoolHistoryCache.ts b/frontend/lib/data-layer/PoolHistoryCache.ts new file mode 100644 index 0000000..3aed0eb --- /dev/null +++ b/frontend/lib/data-layer/PoolHistoryCache.ts @@ -0,0 +1,53 @@ +"use client" + +// Collects pool IDs requested during the same render tick and fires a +// single batched /api/pools/history call, so a grid of N cards makes +// 1 request instead of N. Simple module-level cache, mirrors the +// registerInterest pattern in PoolDataProvider. + +type Listener = () => void + +const cache = new Map() +const pending = new Set() +const listeners = new Set() +let flushScheduled = false + +function notify() { + listeners.forEach((l) => l()) +} + +async function flush() { + flushScheduled = false + const ids = Array.from(pending) + pending.clear() + if (ids.length === 0) return + + try { + const res = await fetch(`/api/pools/history?poolIds=${ids.join(",")}`) + if (!res.ok) return + const { history } = await res.json() + for (const id of ids) { + cache.set(id, history[id] || []) + } + notify() + } catch { + // Silent fail — sparkline just won't render for these pools. + } +} + +export function requestPoolHistory(poolId: string): number[] | undefined { + if (cache.has(poolId)) return cache.get(poolId) + pending.add(poolId) + if (!flushScheduled) { + flushScheduled = true + queueMicrotask(flush) + } + return undefined +} + +export function subscribePoolHistory(listener: Listener) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} \ No newline at end of file diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..86b6b0d --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/cron/snapshot-pool-metrics", + "schedule": "0 0 * * *" + } + ] +} \ No newline at end of file From a3d6eadf98ff51378f8914d8310370f126181f63 Mon Sep 17 00:00:00 2001 From: YerimahOfTimes Date: Sun, 19 Jul 2026 10:46:35 +0100 Subject: [PATCH 2/2] style: apply prettier formatting --- frontend/app/api/cron/snapshot-pool-metrics/route.ts | 2 +- frontend/app/api/pools/history/route.ts | 2 +- frontend/components/dashboard/pool-sparkline.tsx | 2 +- frontend/lib/data-layer/PoolHistoryCache.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/app/api/cron/snapshot-pool-metrics/route.ts b/frontend/app/api/cron/snapshot-pool-metrics/route.ts index 82dd9af..cfc317c 100644 --- a/frontend/app/api/cron/snapshot-pool-metrics/route.ts +++ b/frontend/app/api/cron/snapshot-pool-metrics/route.ts @@ -75,4 +75,4 @@ export async function GET(req: NextRequest) { } return NextResponse.json({ updated, total: pools.length, errors }) -} \ No newline at end of file +} diff --git a/frontend/app/api/pools/history/route.ts b/frontend/app/api/pools/history/route.ts index e1887da..7896e70 100644 --- a/frontend/app/api/pools/history/route.ts +++ b/frontend/app/api/pools/history/route.ts @@ -30,4 +30,4 @@ export async function GET(req: NextRequest) { } return NextResponse.json({ history: byPool }) -} \ No newline at end of file +} diff --git a/frontend/components/dashboard/pool-sparkline.tsx b/frontend/components/dashboard/pool-sparkline.tsx index e859594..34253bc 100644 --- a/frontend/components/dashboard/pool-sparkline.tsx +++ b/frontend/components/dashboard/pool-sparkline.tsx @@ -38,4 +38,4 @@ export function PoolSparkline({ poolId }: { poolId: string }) { /> ) -} \ No newline at end of file +} diff --git a/frontend/lib/data-layer/PoolHistoryCache.ts b/frontend/lib/data-layer/PoolHistoryCache.ts index 3aed0eb..209bfe1 100644 --- a/frontend/lib/data-layer/PoolHistoryCache.ts +++ b/frontend/lib/data-layer/PoolHistoryCache.ts @@ -50,4 +50,4 @@ export function subscribePoolHistory(listener: Listener) { return () => { listeners.delete(listener) } -} \ No newline at end of file +}