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
78 changes: 78 additions & 0 deletions frontend/app/api/cron/snapshot-pool-metrics/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
33 changes: 33 additions & 0 deletions frontend/app/api/pools/history/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, number[]> = {}
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 })
}
4 changes: 3 additions & 1 deletion frontend/components/dashboard/pool-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -176,12 +177,13 @@ export function PoolCard({ pool }: { pool: Pool }) {
<TrendingUp className="h-4 w-4" />
Total Saved
</span>
<span className="font-medium">
<span className="font-medium flex items-center gap-2">
{isLoading && !data?.onchain ? (
<Skeleton className="h-4 w-16 inline-block" />
) : (
formatXlm(totalSaved)
)}
<PoolSparkline poolId={pool.id} />
</span>
</div>
<div className="flex items-center justify-between text-sm">
Expand Down
41 changes: 41 additions & 0 deletions frontend/components/dashboard/pool-sparkline.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="shrink-0">
<polyline
points={points}
fill="none"
stroke={trendingUp ? "#22c55e" : "#ef4444"}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
53 changes: 53 additions & 0 deletions frontend/lib/data-layer/PoolHistoryCache.ts
Original file line number Diff line number Diff line change
@@ -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<string, number[]>()
const pending = new Set<string>()
const listeners = new Set<Listener>()
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)
}
}
8 changes: 8 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/cron/snapshot-pool-metrics",
"schedule": "0 0 * * *"
}
]
}
Loading