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 3973950..b37e086 100644 --- a/frontend/components/dashboard/my-groups.tsx +++ b/frontend/components/dashboard/my-groups.tsx @@ -21,6 +21,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 @@ -125,6 +126,8 @@ export function MyGroups({ onCreateClick }: MyGroupsProps) {

{t("title")}

+ {/* 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 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. *