= {}
+
+ for (const res of results) {
+ if (res.status === "fulfilled") {
+ newMemberMap[res.value.id] = res.value.members
+ newActivityMap[res.value.id] = res.value.activities
+ }
+ }
+ setMemberMap(newMemberMap)
+ setActivityMap(newActivityMap)
+ setDataLoading(false)
+ }
+
+ fetchHealthData()
+ return () => {
+ cancelled = true
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pools.map((p) => p.id).join(",")])
+
+ // Compute health scores for all pools
+ const poolsWithHealth: PoolWithHealth[] = useMemo(() => {
+ if (dataLoading) return []
+ return pools.map((pool) => ({
+ pool,
+ score: calculatePoolHealth(pool, memberMap[pool.id] ?? [], activityMap[pool.id] ?? []),
+ }))
+ }, [pools, memberMap, activityMap, dataLoading])
+
+ // Sort by score descending; declining pools get a visual penalty to surface them
+ const sortedPools = useMemo(() => {
+ return [...poolsWithHealth].sort((a, b) => {
+ // Declining pools have urgency — show them higher when scores are close
+ if (a.score.trend === "declining" && b.score.trend !== "declining") return -1
+ if (b.score.trend === "declining" && a.score.trend !== "declining") return 1
+ return b.score.score - a.score.score
+ })
+ }, [poolsWithHealth])
+
+ const averageScore = useMemo(() => {
+ if (poolsWithHealth.length === 0) return 0
+ return Math.round(
+ poolsWithHealth.reduce((sum, { score }) => sum + score.score, 0) / poolsWithHealth.length
+ )
+ }, [poolsWithHealth])
+
+ const hasMoreThanMax = pools.length > MAX_VISIBLE
+ const visiblePools = showAll ? sortedPools : sortedPools.slice(0, MAX_VISIBLE)
+
+ // Don't render widget when there are no pools
+ if (!loading && pools.length === 0) return null
+
+ const isLoading = loading || dataLoading
+
+ return (
+
+ {/* Header */}
+
+
+ {hasMoreThanMax && !isLoading && (
+
+ )}
+
+
+ {/* Scrollable card row */}
+
+ {isLoading
+ ? // Skeleton state
+ Array.from({ length: Math.min(pools.length || 3, MAX_VISIBLE) }).map((_, i) => (
+
+ ))
+ : // Loaded state
+ [
+ // Overall summary card first
+
,
+ // Per-pool cards
+ ...visiblePools.map(({ pool, score }) => (
+
+ )),
+ ]}
+
+
+ {/* Suggestion list — rendered below the scroll row when data is ready */}
+
+ {!isLoading && poolsWithHealth.length > 0 && (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/e2e/fixtures/mock-pools.ts b/frontend/e2e/fixtures/mock-pools.ts
index cdf3e7c..bc11590 100644
--- a/frontend/e2e/fixtures/mock-pools.ts
+++ b/frontend/e2e/fixtures/mock-pools.ts
@@ -95,13 +95,17 @@ export async function mockPoolsApi(page: Page, seed: MockPool[] = []): Promise p.id === poolId || p.contract_address === poolId)
if (!pool) return json(route, { error: "Pool not found" }, 404)
+ if (endpoint === "members" && method === "GET") {
+ return json(route, pool.pool_members ?? [])
+ }
+
if (endpoint === "index-events" && method === "POST") {
return json(route, {
eventsFound: 0,
diff --git a/frontend/lib/pool-health.ts b/frontend/lib/pool-health.ts
index 7bb2f96..cca30de 100644
--- a/frontend/lib/pool-health.ts
+++ b/frontend/lib/pool-health.ts
@@ -64,6 +64,284 @@ function bandFor(score: number): { band: PoolHealthBand; label: string } {
return { band: "at-risk", label: "At risk" }
}
+// ── New aggregate health score (Issue #224) ──────────────────────────────────
+
+/**
+ * A pool member as seen by the health calculator.
+ * Matches the shape returned by /api/pools/[id]/members (Supabase row).
+ */
+export interface PoolMember {
+ member_address: string
+ /** ISO timestamp of the member's most recent deposit, if any. */
+ last_deposit_at?: string | null
+ /** Total number of completed deposits for this member in this pool. */
+ deposits_count?: number
+ /** Whether the member has deposited in the current round (rotational). */
+ paid_current_round?: boolean
+}
+
+/**
+ * A single pool activity entry as seen by the health calculator.
+ * Matches the shape returned by /api/pools/[id]/activity.
+ */
+export interface PoolActivity {
+ /** ISO timestamp */
+ created_at: string
+ activity_type: string
+ /** Amount deposited (raw number; 0 / null for non-deposit activities). */
+ amount?: number | null
+}
+
+/** Weights and thresholds used in calculatePoolHealth. */
+const WEIGHTS = {
+ depositCompliance: 0.35,
+ memberActivity: 0.25,
+ tvlTrend: 0.2,
+ deadlineProximity: 0.1,
+ disputeCount: 0.1,
+} as const
+
+/** Grade bands for the 0–100 health score. */
+const GRADE_BANDS: Array<{ min: number; grade: HealthGrade }> = [
+ { min: 90, grade: "A" },
+ { min: 70, grade: "B" },
+ { min: 50, grade: "C" },
+ { min: 30, grade: "D" },
+ { min: 0, grade: "F" },
+]
+
+export type HealthGrade = "A" | "B" | "C" | "D" | "F"
+export type HealthTrend = "improving" | "stable" | "declining"
+
+export interface HealthFactors {
+ /** 0–100 — share of members who deposited in the current round. */
+ depositCompliance: number
+ /** 0–100 — share of members active in the last 30 days. */
+ memberActivity: number
+ /** 0–100 — TVL growth direction encoded as a score. */
+ tvlTrend: number
+ /** 0–100 — how far from deadline (100 = plenty of time, 0 = overdue). */
+ deadlineProximity: number
+ /** 0–100 — penalty for dispute/removal events. */
+ disputeCount: number
+}
+
+export interface PoolHealthScore {
+ /** Composite 0–100 score. */
+ score: number
+ grade: HealthGrade
+ factors: HealthFactors
+ trend: HealthTrend
+ /** Prioritised list of actionable improvement suggestions. */
+ suggestions: string[]
+}
+
+// ── Cache ─────────────────────────────────────────────────────────────────────
+
+const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
+
+interface CacheEntry {
+ result: PoolHealthScore
+ expiresAt: number
+}
+
+const scoreCache = new Map()
+
+/** Clear a specific cache entry (useful for tests). */
+export function clearHealthCache(poolId: string) {
+ scoreCache.delete(poolId)
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function gradeFor(score: number): HealthGrade {
+ return GRADE_BANDS.find((b) => score >= b.min)?.grade ?? "F"
+}
+
+/**
+ * Returns a number from 0 (no activities recently) to 100 (lots of recent
+ * activity) based on deposit events in two consecutive 7-day windows.
+ */
+function computeTvlTrend(activities: PoolActivity[]): { score: number; trend: HealthTrend } {
+ const now = Date.now()
+ const window7 = 7 * 24 * 60 * 60 * 1000
+ const deposits = activities.filter((a) => a.activity_type === "deposit" && (a.amount ?? 0) > 0)
+
+ const recent = deposits.filter((a) => now - new Date(a.created_at).getTime() <= window7).length
+ const prior = deposits.filter((a) => {
+ const age = now - new Date(a.created_at).getTime()
+ return age > window7 && age <= 2 * window7
+ }).length
+
+ let trend: HealthTrend
+ let score: number
+ if (recent > prior) {
+ trend = "improving"
+ score = 80 + Math.min(20, (recent - prior) * 5)
+ } else if (recent === prior) {
+ trend = "stable"
+ score = 60
+ } else {
+ trend = "declining"
+ score = Math.max(0, 40 - (prior - recent) * 10)
+ }
+ return { score, trend }
+}
+
+/**
+ * Scores proximity to next payout / deadline.
+ * Returns 100 when no deadline is set, 0 when already overdue.
+ */
+function computeDeadlineScore(nextPayout?: string | null): number {
+ if (!nextPayout) return 100
+ const msLeft = new Date(nextPayout).getTime() - Date.now()
+ if (msLeft <= 0) return 0
+ const daysLeft = msLeft / (24 * 60 * 60 * 1000)
+ if (daysLeft >= 14) return 100
+ if (daysLeft >= 7) return 75
+ if (daysLeft >= 3) return 50
+ return 25
+}
+
+/**
+ * Generates actionable, human-readable suggestions based on the computed
+ * factors. Returned list is ordered from most to least urgent.
+ */
+function buildSuggestions(
+ factors: HealthFactors,
+ pool: { members_count: number; next_payout?: string | null },
+ activities: PoolActivity[],
+ trend: HealthTrend
+): string[] {
+ const suggestions: string[] = []
+
+ if (factors.depositCompliance < 80) {
+ const inactive = Math.round(pool.members_count * (1 - factors.depositCompliance / 100))
+ suggestions.push(
+ `${inactive} member${inactive !== 1 ? "s" : ""} haven't deposited for the current round`
+ )
+ }
+
+ if (pool.next_payout) {
+ const daysLeft = Math.ceil(
+ (new Date(pool.next_payout).getTime() - Date.now()) / (24 * 60 * 60 * 1000)
+ )
+ if (daysLeft > 0 && factors.deadlineProximity < 30) {
+ suggestions.push(
+ `Deposit deadline is approaching in ${daysLeft} day${daysLeft !== 1 ? "s" : ""}`
+ )
+ }
+ }
+
+ if (trend === "declining") {
+ suggestions.push("TVL has decreased over the last 7 days")
+ }
+
+ if (factors.memberActivity < 50) {
+ const inactive = Math.round(pool.members_count * (1 - factors.memberActivity / 100))
+ suggestions.push(
+ `${inactive} member${inactive !== 1 ? "s" : ""} have been inactive for 30+ days`
+ )
+ }
+
+ const daysSinceActivity =
+ activities.length > 0
+ ? (Date.now() - new Date(activities[0].created_at).getTime()) / (24 * 60 * 60 * 1000)
+ : Infinity
+ if (daysSinceActivity >= 14) {
+ suggestions.push("No activity in 2 weeks")
+ }
+
+ if (pool.members_count < 3) {
+ suggestions.push("Pool has very few members — invite more to improve health")
+ }
+
+ return suggestions
+}
+
+/**
+ * Compute a composite health score for a pool using its snapshot data,
+ * current members, and recent activities.
+ *
+ * Results are cached client-side for 5 minutes keyed by pool ID.
+ *
+ * @param pool The pool snapshot (Pool interface from pool-card.tsx)
+ * @param members Current member list from the DB
+ * @param activities Recent activity entries (newest first)
+ */
+export function calculatePoolHealth(
+ pool: {
+ id: string
+ members_count: number
+ next_payout?: string | null
+ },
+ members: PoolMember[],
+ activities: PoolActivity[]
+): PoolHealthScore {
+ // Return cached result if still valid
+ const cached = scoreCache.get(pool.id)
+ if (cached && Date.now() < cached.expiresAt) {
+ return cached.result
+ }
+
+ const now = Date.now()
+ const days30 = 30 * 24 * 60 * 60 * 1000
+
+ // ── depositCompliance ──────────────────────────────────────────────────────
+ // Share of members who have paid_current_round (rotational) or deposited
+ // recently enough that their last_deposit_at is within the current window.
+ const totalMembers = Math.max(members.length, pool.members_count, 1)
+ const compliantCount = members.filter(
+ (m) =>
+ m.paid_current_round === true ||
+ (m.last_deposit_at != null && now - new Date(m.last_deposit_at).getTime() < days30)
+ ).length
+ const depositCompliance = Math.round((compliantCount / totalMembers) * 100)
+
+ // ── memberActivity ─────────────────────────────────────────────────────────
+ const activeCount = members.filter(
+ (m) => m.last_deposit_at != null && now - new Date(m.last_deposit_at).getTime() < days30
+ ).length
+ const memberActivity = Math.round((activeCount / totalMembers) * 100)
+
+ // ── tvlTrend ───────────────────────────────────────────────────────────────
+ const { score: tvlTrendScore, trend } = computeTvlTrend(activities)
+
+ // ── deadlineProximity ──────────────────────────────────────────────────────
+ const deadlineProximity = computeDeadlineScore(pool.next_payout)
+
+ // ── disputeCount ──────────────────────────────────────────────────────────
+ const removals = activities.filter((a) => a.activity_type === "member_removed").length
+ const disputeCount = Math.max(0, 100 - removals * 20)
+
+ const factors: HealthFactors = {
+ depositCompliance,
+ memberActivity,
+ tvlTrend: tvlTrendScore,
+ deadlineProximity,
+ disputeCount,
+ }
+
+ // ── Weighted composite score ───────────────────────────────────────────────
+ const score = Math.round(
+ factors.depositCompliance * WEIGHTS.depositCompliance +
+ factors.memberActivity * WEIGHTS.memberActivity +
+ factors.tvlTrend * WEIGHTS.tvlTrend +
+ factors.deadlineProximity * WEIGHTS.deadlineProximity +
+ factors.disputeCount * WEIGHTS.disputeCount
+ )
+
+ const grade = gradeFor(score)
+ const suggestions = buildSuggestions(factors, pool, activities, trend)
+
+ const result: PoolHealthScore = { score, grade, factors, trend, suggestions }
+
+ scoreCache.set(pool.id, { result, expiresAt: now + CACHE_TTL_MS })
+ return result
+}
+
+// ── Original reputation-based health (preserved) ─────────────────────────────
+
/**
* Compute a pool's health from its current members' reputations.
*