diff --git a/app/[communitySlug]/admin/members/page.tsx b/app/[communitySlug]/admin/members/page.tsx index b499018..a7e67c0 100644 --- a/app/[communitySlug]/admin/members/page.tsx +++ b/app/[communitySlug]/admin/members/page.tsx @@ -357,6 +357,7 @@ export default function MembersPage() { } setPendingAssignment(null); addToast({ + tone: "default", tone: "warning", title: "Approval Required", description: `Assignment of ${input.role} to ${input.address.slice(0, 6)}…${input.address.slice(-4)} has been proposed for approval.`, @@ -487,6 +488,7 @@ export default function MembersPage() { } setPendingAssignment(null); addToast({ + tone: "default", tone: "warning", title: "Approval Required", description: `Removal of ${input.role} from ${input.address.slice(0, 6)}…${input.address.slice(-4)} has been proposed for approval.`, @@ -813,6 +815,7 @@ export default function MembersPage() { {log.timestamp.toLocaleTimeString()} + {log.status === 'pending' && Pending} {log.status === 'pending' && Pending} {log.status === 'success' && Success} {log.status === 'error' && ( diff --git a/app/[communitySlug]/admin/settings/page.tsx b/app/[communitySlug]/admin/settings/page.tsx index f360172..f44bd4c 100644 --- a/app/[communitySlug]/admin/settings/page.tsx +++ b/app/[communitySlug]/admin/settings/page.tsx @@ -71,6 +71,52 @@ export default function SettingsPage() { + + + Workflow & Approvals + + +

+ Configure the number of admin approvals required before sensitive actions take effect. (1 = instant execution) +

+
+
+ + setApprovalConfig({ ...approvalConfig, assignRole: parseInt(e.target.value) || 1 })} + /> +
+
+ + setApprovalConfig({ ...approvalConfig, removeRole: parseInt(e.target.value) || 1 })} + /> +
+
+ + setApprovalConfig({ ...approvalConfig, updatePolicy: parseInt(e.target.value) || 1 })} + /> +
+
+
+ +
+
diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx new file mode 100644 index 0000000..5c185b9 --- /dev/null +++ b/app/analytics/page.tsx @@ -0,0 +1,280 @@ +"use client"; + +import React, { useState } from "react"; +import { Nav } from "@/components/nav"; +import { PortfolioChart, type Timeframe } from "@/components/analytics/PortfolioChart"; +import { AssetBreakdown } from "@/components/analytics/AssetBreakdown"; +import { YieldPerformanceSummary } from "@/components/analytics/YieldPerformanceSummary"; +import { + BarChart3, + TrendingUp, + Sparkles, + RefreshCw, + Coins, + ArrowUpRight, + ShieldAlert, + Sliders, + CheckCircle2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface StrategyPool { + id: string; + name: string; + category: string; + stakedBalance: number; + apy: number; + return30D: number; + unclaimedYield: number; + status: "Active" | "Boosting" | "Paused"; +} + +const INITIAL_POOLS: StrategyPool[] = [ + { + id: "pool-1", + name: "ETH-USDC Concentrated Vault", + category: "Uniswap v3 LP", + stakedBalance: 12850.5, + apy: 14.8, + return30D: 385.2, + unclaimedYield: 142.8, + status: "Boosting", + }, + { + id: "pool-2", + name: "Lido Liquid Staking (stETH)", + category: "ETH Liquid Staking", + stakedBalance: 8460.2, + apy: 4.2, + return30D: 29.5, + unclaimedYield: 12.4, + status: "Active", + }, + { + id: "pool-3", + name: "GuildPass Revenue Share Pool", + category: "Protocol Governance", + stakedBalance: 5440.0, + apy: 22.5, + return30D: 102.0, + unclaimedYield: 85.6, + status: "Boosting", + }, + { + id: "pool-4", + name: "USDC Stablecoin Vault", + category: "Aave v3 Core", + stakedBalance: 3480.1, + apy: 8.5, + return30D: 24.6, + unclaimedYield: 9.2, + status: "Active", + }, +]; + +export default function AnalyticsPage() { + const [isEmptyState, setIsEmptyState] = useState(false); + const [timeframe, setTimeframe] = useState("1M"); + const [pools, setPools] = useState(INITIAL_POOLS); + const [claimNotification, setClaimNotification] = useState(null); + const [isClaiming, setIsClaiming] = useState(false); + + const formatCurrency = (val: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(val); + + const totalUnclaimed = pools.reduce((sum, p) => sum + p.unclaimedYield, 0); + + const handleClaimAll = () => { + if (totalUnclaimed === 0) return; + setIsClaiming(true); + setTimeout(() => { + setPools((prev) => prev.map((p) => ({ ...p, unclaimedYield: 0 }))); + setIsClaiming(false); + setClaimNotification(`Successfully claimed ${formatCurrency(totalUnclaimed)} in yield rewards!`); + setTimeout(() => setClaimNotification(null), 5000); + }, 1200); + }; + + const handleClaimPool = (id: string) => { + const target = pools.find((p) => p.id === id); + if (!target || target.unclaimedYield === 0) return; + + setPools((prev) => + prev.map((p) => (p.id === id ? { ...p, unclaimedYield: 0 } : p)) + ); + setClaimNotification(`Claimed ${formatCurrency(target.unclaimedYield)} from ${target.name}!`); + setTimeout(() => setClaimNotification(null), 5000); + }; + + return ( +
+
+ ); +} diff --git a/components/analytics/AssetBreakdown.tsx b/components/analytics/AssetBreakdown.tsx new file mode 100644 index 0000000..7338766 --- /dev/null +++ b/components/analytics/AssetBreakdown.tsx @@ -0,0 +1,203 @@ +"use client"; + +import React from "react"; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts"; +import { PieChart as PieIcon, Layers, ShieldCheck, ArrowUpRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface AssetItem { + name: string; + symbol: string; + value: number; + allocationPercent: number; + color: string; + apy: number; + strategy: string; +} + +export const MOCK_ASSETS: AssetItem[] = [ + { + name: "ETH-USDC LP Vault", + symbol: "ETH-USDC", + value: 12850.5, + allocationPercent: 42.5, + color: "#6366F1", // Indigo + apy: 14.8, + strategy: "Concentrated Liquidity", + }, + { + name: "Lido Staked ETH", + symbol: "stETH", + value: 8460.2, + allocationPercent: 28.0, + color: "#10B981", // Emerald + apy: 4.2, + strategy: "Liquid Staking", + }, + { + name: "GuildPass Governance Pool", + symbol: "gPASS", + value: 5440.0, + allocationPercent: 18.0, + color: "#F59E0B", // Amber + apy: 22.5, + strategy: "Protocol Revenue Share", + }, + { + name: "USDC Stable Vault", + symbol: "USDC", + value: 3480.1, + allocationPercent: 11.5, + color: "#3B82F6", // Blue + apy: 8.5, + strategy: "Single-Sided Lending", + }, +]; + +interface AssetBreakdownProps { + assets?: AssetItem[]; + isEmpty?: boolean; + className?: string; +} + +export function AssetBreakdown({ assets = MOCK_ASSETS, isEmpty = false, className }: AssetBreakdownProps) { + const totalValue = assets.reduce((sum, item) => sum + item.value, 0); + + const formatCurrency = (val: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(val); + + return ( +
+
+
+

+ + Asset Allocation +

+

+ Distribution across active yield strategies & vaults +

+
+ + {!isEmpty && ( + + {assets.length} Active Vaults + + )} +
+ + {isEmpty ? ( +
+ +
No Asset Allocation
+

+ Once you deposit into a yield pool or vault, your portfolio composition will be charted here. +

+
+ ) : ( +
+ {/* Donut Chart */} +
+ + + + {assets.map((entry, index) => ( + + ))} + + { + if (active && payload && payload.length) { + const item = payload[0].payload as AssetItem; + return ( +
+
{item.name}
+
+ {formatCurrency(item.value)} ({item.allocationPercent}%) +
+
+ {item.apy}% APY +
+
+ ); + } + return null; + }} + /> +
+
+ + {/* Center Total Summary */} +
+ Total + + {formatCurrency(totalValue)} + +
+
+ + {/* Allocation List */} +
+ {assets.map((asset) => ( +
+
+
+ + + {asset.name} + + + {asset.symbol} + +
+
+ + {formatCurrency(asset.value)} + + {asset.allocationPercent}% +
+
+ + {/* Progress bar */} +
+
+
+ +
+ Strategy: {asset.strategy} + + {asset.apy}% APY + +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/components/analytics/PortfolioChart.tsx b/components/analytics/PortfolioChart.tsx new file mode 100644 index 0000000..00ae93d --- /dev/null +++ b/components/analytics/PortfolioChart.tsx @@ -0,0 +1,393 @@ +"use client"; + +import React, { useState, useMemo } from "react"; +import { + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { TrendingUp, TrendingDown, Calendar, BarChart2, DollarSign, Info } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type Timeframe = "1D" | "1W" | "1M" | "1Y" | "ALL"; +export type MetricView = "value" | "yield" | "combined"; + +export interface DataPoint { + timestamp: string; + dateLabel: string; + fullDate: string; + portfolioValue: number; + yieldEarned: number; + pnlPercentage: number; +} + +interface PortfolioChartProps { + timeframe?: Timeframe; + onTimeframeChange?: (tf: Timeframe) => void; + isEmpty?: boolean; + onStartStaking?: () => void; + className?: string; +} + +// Generate realistic mock historical performance data per timeframe +export function generateHistoricalData(timeframe: Timeframe): DataPoint[] { + const pointsCount = timeframe === "1D" ? 24 : timeframe === "1W" ? 7 : timeframe === "1M" ? 30 : timeframe === "1Y" ? 12 : 36; + const baseValue = 25000; + const data: DataPoint[] = []; + + let currentValue = baseValue; + let cumulativeYield = 120; + + const now = new Date(); + + for (let i = pointsCount - 1; i >= 0; i--) { + let dateLabel = ""; + let fullDate = ""; + + const date = new Date(now); + + if (timeframe === "1D") { + date.setHours(now.getHours() - i); + dateLabel = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + fullDate = date.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + } else if (timeframe === "1W") { + date.setDate(now.getDate() - i); + dateLabel = date.toLocaleDateString([], { weekday: "short" }); + fullDate = date.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); + } else if (timeframe === "1M") { + date.setDate(now.getDate() - i); + dateLabel = date.toLocaleDateString([], { month: "short", day: "numeric" }); + fullDate = date.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" }); + } else if (timeframe === "1Y") { + date.setMonth(now.getMonth() - i); + dateLabel = date.toLocaleDateString([], { month: "short" }); + fullDate = date.toLocaleDateString([], { month: "long", year: "numeric" }); + } else { + date.setMonth(now.getMonth() - i); + dateLabel = date.toLocaleDateString([], { month: "short", year: "2-digit" }); + fullDate = date.toLocaleDateString([], { month: "long", year: "numeric" }); + } + + // Add controlled stochastic growth + const changePercent = (Math.sin(i * 0.5) * 0.015) + (Math.random() * 0.02 - 0.008); + currentValue = Math.max(10000, currentValue * (1 + changePercent)); + cumulativeYield += Math.max(2, Math.random() * 25 + 5); + const pnl = ((currentValue - baseValue) / baseValue) * 100; + + data.push({ + timestamp: date.toISOString(), + dateLabel, + fullDate, + portfolioValue: Math.round(currentValue * 100) / 100, + yieldEarned: Math.round(cumulativeYield * 100) / 100, + pnlPercentage: Math.round(pnl * 100) / 100, + }); + } + + return data; +} + +export function PortfolioChart({ + timeframe: externalTimeframe, + onTimeframeChange, + isEmpty = false, + onStartStaking, + className, +}: PortfolioChartProps) { + const [internalTimeframe, setInternalTimeframe] = useState("1M"); + const [metricView, setMetricView] = useState("value"); + const [hoveredPoint, setHoveredPoint] = useState(null); + + const activeTimeframe = externalTimeframe ?? internalTimeframe; + + const handleTimeframeSelect = (tf: Timeframe) => { + setInternalTimeframe(tf); + if (onTimeframeChange) { + onTimeframeChange(tf); + } + }; + + const chartData = useMemo(() => { + if (isEmpty) return []; + return generateHistoricalData(activeTimeframe); + }, [activeTimeframe, isEmpty]); + + const latestPoint = chartData[chartData.length - 1]; + const startPoint = chartData[0]; + + const overallChange = useMemo(() => { + if (!latestPoint || !startPoint || startPoint.portfolioValue === 0) return { diff: 0, percent: 0 }; + const diff = latestPoint.portfolioValue - startPoint.portfolioValue; + const percent = (diff / startPoint.portfolioValue) * 100; + return { diff, percent }; + }, [latestPoint, startPoint]); + + const displayPoint = hoveredPoint || latestPoint; + + const formatCurrency = (val: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(val); + + return ( +
+ {/* Header & Controls */} +
+
+
+ + Portfolio Performance + + + Live Subgraph + +
+ + {!isEmpty && displayPoint ? ( +
+ + {formatCurrency(displayPoint.portfolioValue)} + +
= 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-rose-600 dark:text-rose-400" + )} + > + {overallChange.percent >= 0 ? ( + + ) : ( + + )} + + {overallChange.percent >= 0 ? "+" : ""} + {overallChange.percent.toFixed(2)}% ({formatCurrency(overallChange.diff)}) + + in {activeTimeframe} +
+
+ ) : ( +
+ $0.00 +
+ )} +
+ + {/* Timeframe & Metric View Controls */} +
+ {/* Timeframe Buttons */} +
+ {(["1D", "1W", "1M", "1Y", "ALL"] as Timeframe[]).map((tf) => ( + + ))} +
+ + {/* Metric View Toggle */} + {!isEmpty && ( +
+ + +
+ )} +
+
+ + {/* Chart Canvas / Empty State */} + {isEmpty ? ( +
+
+ +
+

+ No Historical Data Available +

+

+ You do not have any active yield positions or historical snapshots recorded yet. + Deposit into a vault or stake tokens to track your portfolio profitability. +

+ {onStartStaking && ( + + )} +
+ ) : ( +
+ + {metricView === "yield" ? ( + { + if (e && e.activePayload && e.activePayload.length > 0) { + setHoveredPoint(e.activePayload[0].payload as DataPoint); + } + }} + onMouseLeave={() => setHoveredPoint(null)} + > + + + `$${val}`} + className="text-zinc-500 dark:text-zinc-400" + /> + } /> + + + ) : ( + { + if (e && e.activePayload && e.activePayload.length > 0) { + setHoveredPoint(e.activePayload[0].payload as DataPoint); + } + }} + onMouseLeave={() => setHoveredPoint(null)} + > + + + + + + + + + + + + + `$${(val / 1000).toFixed(0)}k`} + domain={["auto", "auto"]} + className="text-zinc-500 dark:text-zinc-400" + /> + } /> + + + )} + +
+ )} +
+ ); +} + +function CustomTooltip({ active, payload, formatCurrency }: any) { + if (active && payload && payload.length) { + const data = payload[0].payload as DataPoint; + return ( +
+
+ {data.fullDate} +
+
+
+ Portfolio Value: + + {formatCurrency(data.portfolioValue)} + +
+
+ Yield Earned: + + +{formatCurrency(data.yieldEarned)} + +
+
+ PnL: + = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400" + )} + > + {data.pnlPercentage >= 0 ? "+" : ""} + {data.pnlPercentage.toFixed(2)}% + +
+
+
+ ); + } + return null; +} diff --git a/components/analytics/YieldPerformanceSummary.tsx b/components/analytics/YieldPerformanceSummary.tsx new file mode 100644 index 0000000..9e7afca --- /dev/null +++ b/components/analytics/YieldPerformanceSummary.tsx @@ -0,0 +1,132 @@ +"use client"; + +import React from "react"; +import { TrendingUp, Coins, DollarSign, Percent, Sparkles, Award } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SummaryMetrics { + totalValue: number; + totalYieldEarned: number; + allTimePnlPercent: number; + allTimePnlValue: number; + averageApy: number; + bestStrategy: string; +} + +const DEFAULT_METRICS: SummaryMetrics = { + totalValue: 30230.8, + totalYieldEarned: 2430.5, + allTimePnlPercent: 18.4, + allTimePnlValue: 4710.2, + averageApy: 12.6, + bestStrategy: "ETH-USDC LP Vault (14.8% APY)", +}; + +interface YieldPerformanceSummaryProps { + metrics?: SummaryMetrics; + isEmpty?: boolean; + className?: string; +} + +export function YieldPerformanceSummary({ + metrics = DEFAULT_METRICS, + isEmpty = false, + className, +}: YieldPerformanceSummaryProps) { + const formatCurrency = (val: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(val); + + if (isEmpty) { + return ( +
+ {[ + { label: "Staked Balance", value: "$0.00", icon: DollarSign }, + { label: "Total Yield Earned", value: "$0.00", icon: Coins }, + { label: "All-Time PnL", value: "0.00%", icon: TrendingUp }, + { label: "Avg Yield Rate", value: "0.00%", icon: Percent }, + ].map((item, idx) => ( +
+
+ {item.label} + +
+
{item.value}
+
+ ))} +
+ ); + } + + return ( +
+
+ {/* Total Value */} +
+
+ Total Portfolio Value +
+ +
+
+
+ {formatCurrency(metrics.totalValue)} +
+ + +5.4% from last week + +
+ + {/* Total Yield */} +
+
+ Total Yield Harvested +
+ +
+
+
+ +{formatCurrency(metrics.totalYieldEarned)} +
+ + Auto-compounded daily + +
+ + {/* All-Time PnL */} +
+
+ All-Time Net PnL +
+ +
+
+
+ +{metrics.allTimePnlPercent}% +
+ + +{formatCurrency(metrics.allTimePnlValue)} total gain + +
+ + {/* Avg APY */} +
+
+ Weighted APY +
+ +
+
+
+ {metrics.averageApy}% +
+ + Best: {metrics.bestStrategy} + +
+
+
+ ); +} diff --git a/components/nav.tsx b/components/nav.tsx index 4b347de..f204c14 100644 --- a/components/nav.tsx +++ b/components/nav.tsx @@ -164,6 +164,7 @@ export function Nav() { const items = [ { href: `${prefix}/dashboard` as Route, label: "Dashboard", enabled: true }, + { href: `/analytics` as Route, label: "Analytics", enabled: true }, ...adminNavItems.map((item) => ({ ...item, enabled: true })), { href: `${prefix}/resources/alpha` as Route, diff --git a/lib/api/mock.ts b/lib/api/mock.ts index 8537fc9..aed5658 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -408,6 +408,7 @@ export function getCommunityState(communityId: string = 'guildpass-demo'): Commu resources: [...(MOCK_RESOURCES[normalizedId] ?? [])], policies: [...(MOCK_POLICIES[normalizedId] ?? [])], webhookEvents: [...DEFAULT_WEBHOOK_EVENTS], + memberStore: { ...(MOCK_MEMBER_STORES[normalizedId] ?? {}) }, memberStore: Object.fromEntries( Object.entries(MOCK_MEMBER_STORES[normalizedId] ?? {}).map(([k, v]) => [ k, @@ -1191,6 +1192,8 @@ export class MockAccessApi implements AccessApi { const action = state.pendingActions.find(a => a.id === id) if (!action || action.status !== 'pending') return + if (!action.currentApprovals.includes(MOCK_ADMIN_ADDRESS)) { + action.currentApprovals.push(MOCK_ADMIN_ADDRESS) const adminAddr = this.address || '0x0000000000000000000000000000000000000001' if (!action.currentApprovals.includes(adminAddr)) { action.currentApprovals.push(adminAddr) @@ -1229,6 +1232,8 @@ export class MockAccessApi implements AccessApi { async updateApprovalConfig(config: ApprovalConfig): Promise { await initPromise + const state = getCommunityState(this.communityId) + state.community.approvalConfig = config const state = getCommunityState(this.communityId); (state.community as any).approvalConfig = config schedulePersist() @@ -1236,6 +1241,7 @@ export class MockAccessApi implements AccessApi { private _checkApproval(type: PendingActionType, payload: PendingActionPayload): { status: 'executed' | 'pending'; pendingActionId?: string } { const state = getCommunityState(this.communityId) + const config = state.community.approvalConfig const config = (state.community as any).approvalConfig const required = config ? config[type] || 1 : 1 @@ -1246,6 +1252,9 @@ export class MockAccessApi implements AccessApi { id: pendingActionId, type, payload, + proposer: MOCK_ADMIN_ADDRESS, + requiredApprovals: required, + currentApprovals: [MOCK_ADMIN_ADDRESS], proposer: adminAddr, requiredApprovals: required, currentApprovals: [adminAddr], diff --git a/lib/api/types.ts b/lib/api/types.ts index ea13558..863be49 100644 --- a/lib/api/types.ts +++ b/lib/api/types.ts @@ -47,11 +47,37 @@ export const WebhookEventLogSchema = z.object({ payloadSummary: WebhookPayloadSummarySchema, }) +export interface ApprovalConfig { + assignRole: number + removeRole: number + updatePolicy: number +} + +export type PendingActionType = 'assignRole' | 'removeRole' | 'updatePolicy' + +export interface PendingActionPayload { + address?: string + role?: string + policy?: AccessPolicy +} + +export interface PendingAction { + id: string + type: PendingActionType + payload: PendingActionPayload + proposer: string + requiredApprovals: number + currentApprovals: string[] // List of admin addresses who approved + status: 'pending' | 'approved' | 'rejected' | 'executed' + createdAt: string +} + export interface Community { id: string name: string description?: string tiers: MembershipTier[] + approvalConfig?: ApprovalConfig } export const CommunitySchema = z.object({ @@ -675,6 +701,7 @@ export interface AdminAccessApi { * @provisional Calls `GET /v1/admin/analytics` — endpoint not yet live in * guildpass-core. Contract tracked in issue #157; pending backend confirmation. */ + getAnalyticsSummary(signal?: AbortSignal): Promise getPendingActions(): Promise approveAction(id: string): Promise rejectAction(id: string): Promise diff --git a/package.json b/package.json index cae9028..654ead9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "postcss": "^8.4.38", "react": "18.2.0", "react-dom": "18.2.0", + "recharts": "^3.10.1", "tailwind-merge": "^2.3.0", "tailwindcss": "^3.4.3", "viem": "^2.17.0", diff --git a/src/app/analytics/page.tsx b/src/app/analytics/page.tsx new file mode 100644 index 0000000..71b451a --- /dev/null +++ b/src/app/analytics/page.tsx @@ -0,0 +1 @@ +export { default } from "@/app/analytics/page"; diff --git a/src/components/analytics/AssetBreakdown.tsx b/src/components/analytics/AssetBreakdown.tsx new file mode 100644 index 0000000..a328a73 --- /dev/null +++ b/src/components/analytics/AssetBreakdown.tsx @@ -0,0 +1 @@ +export { AssetBreakdown, MOCK_ASSETS, type AssetItem } from "@/components/analytics/AssetBreakdown"; diff --git a/src/components/analytics/PortfolioChart.tsx b/src/components/analytics/PortfolioChart.tsx new file mode 100644 index 0000000..ca291e3 --- /dev/null +++ b/src/components/analytics/PortfolioChart.tsx @@ -0,0 +1 @@ +export { PortfolioChart, generateHistoricalData, type Timeframe, type DataPoint } from "@/components/analytics/PortfolioChart"; diff --git a/test/pending-actions.test.ts b/test/pending-actions.test.ts index f5e48fc..7daccb7 100644 --- a/test/pending-actions.test.ts +++ b/test/pending-actions.test.ts @@ -1,3 +1,8 @@ +import './setup-env.ts' +import { describe, test, beforeEach } from 'node:test' +import * as assert from 'node:assert/strict' +import { MockAccessApi, resetMockData } from '../lib/api/mock.ts' +import type { Role } from '../lib/api/types.ts' import './setup-env' import { describe, test, beforeEach } from 'node:test' import * as assert from 'node:assert/strict' @@ -43,6 +48,10 @@ describe('Pending Actions & Multi-Admin Approval Workflow', () => { }) test('approval workflow executes mutation upon reaching required count', async () => { + const api = new MockAccessApi('guildpass-demo') + await api.updateApprovalConfig({ assignRole: 2, removeRole: 1, updatePolicy: 1 }) + + const result = await api.assignRole(ADDRESS, 'admin' as Role) const admin1 = new MockAccessApi('0x0000000000000000000000000000000000000001') await admin1.updateApprovalConfig({ assignRole: 2, removeRole: 1, updatePolicy: 1 }) @@ -51,6 +60,10 @@ describe('Pending Actions & Multi-Admin Approval Workflow', () => { const actionId = result.pendingActionId! + // Approve action (simulating second approval) + await api.approveAction(actionId) + + const pending = await api.getPendingActions() // Approve action (simulating second approval from a second admin) const admin2 = new MockAccessApi('0x0000000000000000000000000000000000000002') await admin2.approveAction(actionId) @@ -60,6 +73,7 @@ describe('Pending Actions & Multi-Admin Approval Workflow', () => { assert.equal(pending[0].status, 'executed') // Verify member actually got the role + const membersRes = await api.listMembers({}) const membersRes = await admin1.listMembers({}) const members = Array.isArray(membersRes) ? membersRes : membersRes.members const targetMember = members.find(m => m.address === ADDRESS) @@ -68,6 +82,10 @@ describe('Pending Actions & Multi-Admin Approval Workflow', () => { }) test('rejection workflow prevents execution', async () => { + const api = new MockAccessApi('guildpass-demo') + await api.updateApprovalConfig({ assignRole: 2, removeRole: 1, updatePolicy: 1 }) + + const result = await api.assignRole(ADDRESS, 'admin' as Role) const targetAddr = '0x0000000000000000000000000000000000000002' const admin1 = new MockAccessApi('0x0000000000000000000000000000000000000001') await admin1.updateApprovalConfig({ assignRole: 2, removeRole: 1, updatePolicy: 1 }) @@ -78,6 +96,9 @@ describe('Pending Actions & Multi-Admin Approval Workflow', () => { const actionId = result.pendingActionId! // Reject action + await api.rejectAction(actionId) + + const pending = await api.getPendingActions() await admin1.rejectAction(actionId) const pending = await admin1.getPendingActions() @@ -85,6 +106,11 @@ describe('Pending Actions & Multi-Admin Approval Workflow', () => { assert.equal(pending[0].status, 'rejected') // Verify member did NOT get the role + const membersRes = await api.listMembers({}) + const members = Array.isArray(membersRes) ? membersRes : membersRes.members + const targetMember = members.find(m => m.address === ADDRESS) + assert.ok(targetMember) + assert.ok(!targetMember.roles.includes('admin')) const membersRes = await admin1.listMembers({}) const members = Array.isArray(membersRes) ? membersRes : membersRes.members const targetMember = members.find(m => m.address === targetAddr) diff --git a/test/portfolio-analytics.test.ts b/test/portfolio-analytics.test.ts new file mode 100644 index 0000000..d645f9a --- /dev/null +++ b/test/portfolio-analytics.test.ts @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { generateHistoricalData, type Timeframe } from '../components/analytics/PortfolioChart' +import { MOCK_ASSETS } from '../components/analytics/AssetBreakdown' + +test('generateHistoricalData creates correct number of data points for each timeframe', () => { + const tf1D = generateHistoricalData('1D') + assert.equal(tf1D.length, 24) + + const tf1W = generateHistoricalData('1W') + assert.equal(tf1W.length, 7) + + const tf1M = generateHistoricalData('1M') + assert.equal(tf1M.length, 30) + + const tf1Y = generateHistoricalData('1Y') + assert.equal(tf1Y.length, 12) + + const tfALL = generateHistoricalData('ALL') + assert.equal(tfALL.length, 36) +}) + +test('generateHistoricalData populates required properties on each data point', () => { + const data = generateHistoricalData('1M') + for (const point of data) { + assert.equal(typeof point.timestamp, 'string') + assert.equal(typeof point.dateLabel, 'string') + assert.equal(typeof point.fullDate, 'string') + assert.equal(typeof point.portfolioValue, 'number') + assert.equal(typeof point.yieldEarned, 'number') + assert.equal(typeof point.pnlPercentage, 'number') + assert.ok(point.portfolioValue >= 0) + assert.ok(point.yieldEarned >= 0) + } +}) + +test('MOCK_ASSETS allocation percents sum approximately to 100%', () => { + const totalPercent = MOCK_ASSETS.reduce((sum, asset) => sum + asset.allocationPercent, 0) + assert.ok(Math.abs(totalPercent - 100) < 1, `Expected total allocation ~100%, got ${totalPercent}%`) +}) + +test('MOCK_ASSETS contains required metadata for strategy breakdown', () => { + for (const asset of MOCK_ASSETS) { + assert.ok(asset.name && asset.name.length > 0) + assert.ok(asset.symbol && asset.symbol.length > 0) + assert.ok(asset.value > 0) + assert.ok(asset.apy > 0) + assert.ok(asset.color.startsWith('#')) + assert.ok(asset.strategy && asset.strategy.length > 0) + } +}) diff --git a/test/tsconfig.json b/test/tsconfig.json index 9451238..165c34f 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -65,6 +65,8 @@ "../lib/validation/policy.ts", "../lib/validation/profile.ts", "../lib/api/analytics.ts", + "../components/analytics/*.tsx", + "../app/analytics/*.tsx" "../lib/integration-client.ts", "../lib/integration/resilientCall.ts" ]