From ef1b1a394507f7462fd362ba8da925535baccd8f Mon Sep 17 00:00:00 2001 From: Bezaleel Akogwu Date: Tue, 25 Aug 2026 02:27:19 +0000 Subject: [PATCH] feat: add pool health score widget to dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the pool health score widget on the My Groups tab as described in issue #224. Changes: - frontend/lib/pool-health.ts: Add calculatePoolHealth() utility that computes a 0-100 composite score from depositCompliance, memberActivity, tvlTrend, deadlineProximity, and disputeCount factors. Includes grade mapping (A-F), trend detection (improving/stable/declining), suggestion engine, and a 5-minute client-side score cache. Existing computePoolHealth() (reputation system) is preserved unchanged. - frontend/components/dashboard/pool-health-widget.tsx: New PoolHealthWidget component. Renders a horizontal-scrollable row of per-pool health cards (circular SVG ring, letter grade, trend arrow, top suggestion, View Details link) plus an OverallHealthCard showing the average score. Includes skeleton loaders for the loading state. For users with >20 pools only the top-5 are shown with a 'View All' toggle. Widget is hidden when the user has no pools. - frontend/components/dashboard/health-suggestion-list.tsx: New HealthSuggestionList component. Expandable accordion below the health cards listing all suggestions across all pools, sorted by urgency (high → medium → low). Each row shows the suggestion, pool name, and an urgency badge. Animated expand/collapse via framer-motion. - frontend/components/dashboard/my-groups.tsx: Integrate PoolHealthWidget at the top of the My Groups tab, rendered above the search input when pools are present. Skeleton variant shown while data loads. - frontend/e2e/fixtures/mock-pools.ts: Add mock handler for /api/pools/:id/members so the health widget's member fetch resolves correctly in Playwright tests. All 168 existing unit tests pass. No TypeScript errors in modified files. Prettier formatting applied. Closes #224 --- .../dashboard/health-suggestion-list.tsx | 193 ++++++++ frontend/components/dashboard/my-groups.tsx | 6 + .../dashboard/pool-health-widget.tsx | 440 ++++++++++++++++++ frontend/e2e/fixtures/mock-pools.ts | 10 +- frontend/lib/pool-health.ts | 278 +++++++++++ 5 files changed, 924 insertions(+), 3 deletions(-) create mode 100644 frontend/components/dashboard/health-suggestion-list.tsx create mode 100644 frontend/components/dashboard/pool-health-widget.tsx diff --git a/frontend/components/dashboard/health-suggestion-list.tsx b/frontend/components/dashboard/health-suggestion-list.tsx new file mode 100644 index 0000000..e9dc493 --- /dev/null +++ b/frontend/components/dashboard/health-suggestion-list.tsx @@ -0,0 +1,193 @@ +"use client" + +import { useState } from "react" +import { motion, AnimatePresence } from "framer-motion" +import { ChevronDown, ChevronUp, Lightbulb, TrendingDown, AlertTriangle, Info } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import type { HealthTrend, HealthGrade } from "@/lib/pool-health" +import type { PoolWithHealth } from "@/components/dashboard/pool-health-widget" + +// ── Urgency classification ───────────────────────────────────────────────────── + +type SuggestionUrgency = "high" | "medium" | "low" + +function urgencyFor(grade: HealthGrade, trend: HealthTrend): SuggestionUrgency { + if (grade === "F" || grade === "D" || trend === "declining") return "high" + if (grade === "C") return "medium" + return "low" +} + +const URGENCY_STYLES: Record< + SuggestionUrgency, + { icon: React.ReactNode; badge: string; dot: string } +> = { + high: { + icon: , + badge: "bg-rose-500/10 text-rose-700 dark:text-rose-400 border-rose-500/20", + dot: "bg-rose-500", + }, + medium: { + icon: , + badge: "bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20", + dot: "bg-amber-500", + }, + low: { + icon: , + badge: "bg-sky-500/10 text-sky-700 dark:text-sky-400 border-sky-500/20", + dot: "bg-sky-500", + }, +} + +// ── Flat suggestion item ─────────────────────────────────────────────────────── + +interface SuggestionItem { + poolId: string + poolName: string + suggestion: string + urgency: SuggestionUrgency +} + +function buildSuggestionItems(poolsWithHealth: PoolWithHealth[]): SuggestionItem[] { + const items: SuggestionItem[] = [] + + for (const { pool, score } of poolsWithHealth) { + if (score.suggestions.length === 0) continue + const urgency = urgencyFor(score.grade, score.trend) + for (const suggestion of score.suggestions) { + items.push({ + poolId: pool.id, + poolName: pool.name, + suggestion, + urgency, + }) + } + } + + // Sort: high → medium → low, then alphabetically by pool name + const ORDER: Record = { high: 0, medium: 1, low: 2 } + return items.sort((a, b) => { + const urgencyDiff = ORDER[a.urgency] - ORDER[b.urgency] + if (urgencyDiff !== 0) return urgencyDiff + return a.poolName.localeCompare(b.poolName) + }) +} + +// ── Row component ────────────────────────────────────────────────────────────── + +function SuggestionRow({ item }: { item: SuggestionItem }) { + const styles = URGENCY_STYLES[item.urgency] + + return ( + + {/* Urgency icon */} + {styles.icon} + + {/* Content */} +
+

{item.suggestion}

+

{item.poolName}

+
+ + {/* Urgency badge */} + + + {item.urgency} + +
+ ) +} + +// ── Main component ───────────────────────────────────────────────────────────── + +interface HealthSuggestionListProps { + poolsWithHealth: PoolWithHealth[] +} + +/** + * Expandable section listing actionable suggestions across all pools, + * sorted by urgency (declining / low-grade pools first). + */ +export function HealthSuggestionList({ poolsWithHealth }: HealthSuggestionListProps) { + const [expanded, setExpanded] = useState(false) + + const items = buildSuggestionItems(poolsWithHealth) + + // Don't render if there are no suggestions + if (items.length === 0) return null + + const highCount = items.filter((i) => i.urgency === "high").length + + return ( +
+ {/* Toggle header */} + + + {/* Expandable list */} + + {expanded && ( + +
    + + {items.map((item, idx) => ( + + ))} + +
+
+ )} +
+
+ ) +} diff --git a/frontend/components/dashboard/my-groups.tsx b/frontend/components/dashboard/my-groups.tsx index 5552c04..6d67a49 100644 --- a/frontend/components/dashboard/my-groups.tsx +++ b/frontend/components/dashboard/my-groups.tsx @@ -19,6 +19,7 @@ import { EmptyState } from "@/components/dashboard/empty-state" import { FirstPoolTooltip } from "@/components/dashboard/first-pool-tooltip" import { PoolCard, PoolCardSkeleton, type Pool } from "@/components/dashboard/pool-card" import { useDebouncedValue } from "@/hooks/use-debounced-value" +import { PoolHealthWidget } from "@/components/dashboard/pool-health-widget" const PAGE_SIZE = 6 @@ -122,6 +123,8 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) {

My Groups

+ {/* Show widget skeleton while pools load */} +
+ {/* Pool health score widget — hidden when no pools are loaded */} + {pools.length > 0 && } + {/* Search input */}
diff --git a/frontend/components/dashboard/pool-health-widget.tsx b/frontend/components/dashboard/pool-health-widget.tsx new file mode 100644 index 0000000..d05739b --- /dev/null +++ b/frontend/components/dashboard/pool-health-widget.tsx @@ -0,0 +1,440 @@ +"use client" + +import { useMemo, useState, useEffect } from "react" +import { motion, AnimatePresence } from "framer-motion" +import Link from "next/link" +import { TrendingUp, TrendingDown, Minus, ArrowRight, Activity } from "lucide-react" +import { Card } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { + calculatePoolHealth, + type PoolHealthScore, + type HealthGrade, + type HealthTrend, + type PoolMember, + type PoolActivity, +} from "@/lib/pool-health" +import type { Pool } from "@/components/dashboard/pool-card" +import { HealthSuggestionList } from "@/components/dashboard/health-suggestion-list" + +// ── Constants ────────────────────────────────────────────────────────────────── + +const MAX_VISIBLE = 5 + +// ── Grade styling ────────────────────────────────────────────────────────────── + +const GRADE_STYLES: Record = + { + A: { + ring: "stroke-emerald-500", + text: "text-emerald-600 dark:text-emerald-400", + bg: "bg-emerald-500/10", + badge: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-emerald-500/20", + }, + B: { + ring: "stroke-sky-500", + text: "text-sky-600 dark:text-sky-400", + bg: "bg-sky-500/10", + badge: "bg-sky-500/15 text-sky-700 dark:text-sky-400 border-sky-500/20", + }, + C: { + ring: "stroke-amber-500", + text: "text-amber-600 dark:text-amber-400", + bg: "bg-amber-500/10", + badge: "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/20", + }, + D: { + ring: "stroke-orange-500", + text: "text-orange-600 dark:text-orange-400", + bg: "bg-orange-500/10", + badge: "bg-orange-500/15 text-orange-700 dark:text-orange-400 border-orange-500/20", + }, + F: { + ring: "stroke-rose-500", + text: "text-rose-600 dark:text-rose-400", + bg: "bg-rose-500/10", + badge: "bg-rose-500/15 text-rose-700 dark:text-rose-400 border-rose-500/20", + }, + } + +// ── Trend icon ───────────────────────────────────────────────────────────────── + +function TrendIcon({ trend }: { trend: HealthTrend }) { + if (trend === "improving") + return + if (trend === "declining") + return + return +} + +// ── Circular progress ring ───────────────────────────────────────────────────── + +function CircularProgress({ + score, + grade, + size = 64, +}: { + score: number + grade: HealthGrade + size?: number +}) { + const radius = (size - 8) / 2 + const circumference = 2 * Math.PI * radius + const offset = circumference - (score / 100) * circumference + const styles = GRADE_STYLES[grade] + + return ( +
+ + {/* Track */} + + {/* Progress */} + + + {/* Label */} +
+ {score} + + {grade} + +
+
+ ) +} + +// ── Individual pool health card ──────────────────────────────────────────────── + +function PoolHealthCard({ pool, healthScore }: { pool: Pool; healthScore: PoolHealthScore }) { + const styles = GRADE_STYLES[healthScore.grade] + const topSuggestion = healthScore.suggestions[0] + + return ( + + + {/* Header */} +
+
+

+ {pool.name} +

+ + {pool.type} + +
+ +
+ + {/* Trend */} +
+ + {healthScore.trend} +
+ + {/* Top suggestion */} + {topSuggestion && ( +

+ 💡 {topSuggestion} +

+ )} + + {/* CTA */} + +
+
+ ) +} + +// ── Summary "overall health" card ────────────────────────────────────────────── + +function OverallHealthCard({ + averageScore, + totalPools, +}: { + averageScore: number + totalPools: number +}) { + const grade: HealthGrade = + averageScore >= 90 + ? "A" + : averageScore >= 70 + ? "B" + : averageScore >= 50 + ? "C" + : averageScore >= 30 + ? "D" + : "F" + const styles = GRADE_STYLES[grade] + + return ( + + + +

+ Overall Health +

+ +

+ Across {totalPools} pool{totalPools !== 1 ? "s" : ""} +

+ Grade {grade} +
+
+ ) +} + +// ── Skeleton loaders ─────────────────────────────────────────────────────────── + +function PoolHealthCardSkeleton() { + return ( +
+ +
+
+ + +
+ +
+ + + +
+
+ ) +} + +// ── Main widget ──────────────────────────────────────────────────────────────── + +export interface PoolWithHealth { + pool: Pool + score: PoolHealthScore +} + +interface PoolHealthWidgetProps { + pools: Pool[] + /** Pass true while the parent is fetching pool data. */ + loading?: boolean +} + +/** + * Horizontal-scrollable row of per-pool health cards with an overall summary + * card. Hidden when no pools are present. Scores are computed client-side + * using `calculatePoolHealth` with a 5-minute cache. + * + * For users with >20 pools only the top-5 (by score) are shown inline, with a + * "View All" link below. + */ +export function PoolHealthWidget({ pools, loading = false }: PoolHealthWidgetProps) { + const [memberMap, setMemberMap] = useState>({}) + const [activityMap, setActivityMap] = useState>({}) + const [dataLoading, setDataLoading] = useState(true) + const [showAll, setShowAll] = useState(false) + + // Fetch lightweight member + activity data for health calculation. + useEffect(() => { + if (pools.length === 0) { + setDataLoading(false) + return + } + let cancelled = false + + async function fetchHealthData() { + setDataLoading(true) + const results = await Promise.allSettled( + pools.map(async (pool) => { + const [membersRes, activityRes] = await Promise.allSettled([ + fetch(`/api/pools/${pool.id}/members`).then((r) => (r.ok ? r.json() : [])), + fetch(`/api/pools/${pool.id}/activity?page=1`).then((r) => (r.ok ? r.json() : [])), + ]) + const members: PoolMember[] = + membersRes.status === "fulfilled" + ? Array.isArray(membersRes.value) + ? membersRes.value + : (membersRes.value?.data ?? []) + : [] + const rawActivity = + activityRes.status === "fulfilled" + ? Array.isArray(activityRes.value) + ? activityRes.value + : (activityRes.value?.data ?? []) + : [] + const activities: PoolActivity[] = rawActivity + return { id: pool.id, members, activities } + }) + ) + + if (cancelled) return + + const newMemberMap: Record = {} + const newActivityMap: Record = {} + + 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 */} +
+
+ +

Pool Health

+
+ {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 9428f13..9b4f9bd 100644 --- a/frontend/e2e/fixtures/mock-pools.ts +++ b/frontend/e2e/fixtures/mock-pools.ts @@ -92,16 +92,20 @@ 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. *