diff --git a/apps/frontend/app/settings/page.tsx b/apps/frontend/app/settings/page.tsx index f51c21d5..ad719250 100644 --- a/apps/frontend/app/settings/page.tsx +++ b/apps/frontend/app/settings/page.tsx @@ -30,14 +30,17 @@ import { ConsentSettingsCard } from "../../components/settings/ConsentSettingsCa const PLACEHOLDER_USER: User = { id: "user-placeholder", stellarAddress: "GB...PLACEHOLDER", - displayName: null, - email: null, + displayName: "", + email: "", createdAt: new Date(), updatedAt: new Date(), }; const PLACEHOLDER_PREFERENCES: UserPreferences = { userId: "user-placeholder", + currency: "USD", + theme: "system", + notificationsEnabled: true, defaultSpendingLimit: 0n, requireApproval: true, notificationEmail: true, diff --git a/apps/frontend/components/analytics/AgentLeaderboard.tsx b/apps/frontend/components/analytics/AgentLeaderboard.tsx new file mode 100644 index 00000000..7e5cbcdf --- /dev/null +++ b/apps/frontend/components/analytics/AgentLeaderboard.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import type { Order, Delegation } from "@delegolabs/types"; +import { Amount } from "@delegolabs/ui"; +import { useCurrency } from "../../hooks/useCurrency"; + +export interface AgentLeaderboardProps { + orders: Order[]; + delegations: Delegation[]; +} + +type SortField = "agentId" | "tasks" | "successRate" | "avgSavings" | "totalSpent" | "activeDelegations"; +type SortDirection = "asc" | "desc"; + +export function AgentLeaderboard({ orders, delegations }: AgentLeaderboardProps) { + const router = useRouter(); + const { currencyId, rate } = useCurrency(); + const [sortField, setSortField] = useState("totalSpent"); + const [sortDir, setSortDir] = useState("desc"); + + const agentsData = useMemo(() => { + const agentMap = new Map(); + + // Process delegations + for (const del of delegations) { + if (!agentMap.has(del.agentId)) { + agentMap.set(del.agentId, { + agentId: del.agentId, + tasks: 0, + approvedCount: 0, + totalSpent: 0n, + avgSavings: 0n, + activeDelegations: 0 + }); + } + const data = agentMap.get(del.agentId); + if (del.status === "active") { + data.activeDelegations += 1; + } + } + + // Process orders (agentId is assumed to be associated, if it's stored on order or delegation) + // Note: Order might not have agentId directly, it might be delegationId. + // If order has delegationId, we map it to agentId. + const delegationAgentMap = new Map(delegations.map(d => [d.id, d.agentId])); + + for (const order of orders) { + const orderAgentId = (order as any).agentId || delegationAgentMap.get(order.delegationId); + if (!orderAgentId) continue; + + if (!agentMap.has(orderAgentId)) { + agentMap.set(orderAgentId, { + agentId: orderAgentId, + tasks: 0, + approvedCount: 0, + totalSpent: 0n, + avgSavings: 0n, + activeDelegations: 0 + }); + } + const data = agentMap.get(orderAgentId); + data.tasks += 1; + if (order.status === "approved" || order.status === "fulfilled" || order.status === "settled" || order.status === "escrowed") { + data.approvedCount += 1; + data.totalSpent += order.totalStroops; + } + } + + const rows = Array.from(agentMap.values()).map(row => ({ + ...row, + successRate: row.tasks > 0 ? row.approvedCount / row.tasks : 0, + })); + + return rows.sort((a, b) => { + let delta = 0; + if (sortField === "agentId") { + delta = a.agentId.localeCompare(b.agentId); + } else if (sortField === "totalSpent" || sortField === "avgSavings") { + delta = a[sortField] < b[sortField] ? -1 : a[sortField] > b[sortField] ? 1 : 0; + } else { + delta = a[sortField] - b[sortField]; + } + return sortDir === "asc" ? delta : -delta; + }); + }, [orders, delegations, sortField, sortDir]); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDir(sortDir === "asc" ? "desc" : "asc"); + } else { + setSortField(field); + setSortDir("desc"); + } + }; + + const SortIcon = ({ field }: { field: SortField }) => { + if (sortField !== field) return null; + return ; + }; + + const handleRowClick = (agentId: string) => { + router.push(`/orders?search=${encodeURIComponent(agentId)}`); + }; + + return ( +
+
+ + + + + + + + + + + + + {agentsData.map(row => ( + handleRowClick(row.agentId)} className="clickable-row"> + + + + + + + + ))} + {agentsData.length === 0 && ( + + + + )} + +
+ + + + + + + + + + + +
{row.agentId}{row.tasks}{(row.successRate * 100).toFixed(1)}% + + + + {row.activeDelegations}
No agent data available.
+
+ +
+ {agentsData.map(row => ( +
handleRowClick(row.agentId)} style={{ marginBottom: "1rem", cursor: "pointer" }}> +
+

{row.agentId}

+
+
+
+ Total Spent + + + +
+
+ Tasks + {row.tasks} +
+
+ Success Rate + {(row.successRate * 100).toFixed(1)}% +
+
+ Active + {row.activeDelegations} +
+
+
+ ))} +
+ + +
+ ); +} diff --git a/apps/frontend/components/analytics/DeltaCards.tsx b/apps/frontend/components/analytics/DeltaCards.tsx new file mode 100644 index 00000000..54791559 --- /dev/null +++ b/apps/frontend/components/analytics/DeltaCards.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Card, Amount } from "@delegolabs/ui"; +import type { Deltas, PeriodMetrics } from "../../lib/analytics"; +import { useCurrency } from "../../hooks/useCurrency"; + +export interface DeltaCardsProps { + current: PeriodMetrics; + deltas: Deltas; +} + +function DeltaChip({ delta, inverse = false }: { delta: number | null, inverse?: boolean }) { + if (delta === null) { + return ; + } + const isPositive = delta > 0; + const isZero = delta === 0; + + // green/red semantics chosen carefully: spend-down = green (inverse=true) + let tone = "neutral"; + if (!isZero) { + if (inverse) { + tone = isPositive ? "negative" : "positive"; + } else { + tone = isPositive ? "positive" : "negative"; + } + } + + const arrow = isPositive ? "▲" : isZero ? "" : "▼"; + const percentage = Math.abs(delta * 100).toFixed(1) + "%"; + + return ( + + {arrow} {percentage} + + ); +} + +export function DeltaCards({ current, deltas }: DeltaCardsProps) { + const { currencyId, rate } = useCurrency(); + + return ( +
+ +

+ +

+
+ vs previous +
+
+ + +

{current.orderCount}

+
+ vs previous +
+
+ + +

+ +

+
+ vs previous +
+
+ + +

+ {(current.approvalRate * 100).toFixed(1)}% +

+
+ vs previous +
+
+
+ ); +} diff --git a/apps/frontend/components/delegations/DelegationCard.tsx b/apps/frontend/components/delegations/DelegationCard.tsx index 02cb4186..7c93c2c3 100644 --- a/apps/frontend/components/delegations/DelegationCard.tsx +++ b/apps/frontend/components/delegations/DelegationCard.tsx @@ -132,7 +132,6 @@ export function DelegationCard({
setShowPauseModal(true)} onRenew={ onDuplicate ? () => onDuplicate(delegation) : undefined @@ -220,8 +219,8 @@ export function DelegationCard({
@@ -281,7 +280,7 @@ export function DelegationCard({ Per transaction limit: @@ -289,7 +288,7 @@ export function DelegationCard({ Total budget limit: diff --git a/apps/frontend/components/demo/DemoBanner.test.tsx b/apps/frontend/components/demo/DemoBanner.test.tsx index fe3f99c1..61e1f24e 100644 --- a/apps/frontend/components/demo/DemoBanner.test.tsx +++ b/apps/frontend/components/demo/DemoBanner.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DemoBanner } from "./DemoBanner"; @@ -34,7 +34,7 @@ describe("DemoBanner", () => { enableDemoMode(); const originalLocation = window.location; // @ts-expect-error -- overriding window.location for the test - delete window.location; + delete (window as any).location; // @ts-expect-error -- partial Location stub is enough for this assertion window.location = { href: "" }; diff --git a/apps/frontend/components/escrows/CancelGraceBanner.test.tsx b/apps/frontend/components/escrows/CancelGraceBanner.test.tsx index 2dd9f8b6..4e309d27 100644 --- a/apps/frontend/components/escrows/CancelGraceBanner.test.tsx +++ b/apps/frontend/components/escrows/CancelGraceBanner.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { NextIntlClientProvider } from "next-intl"; import type { CancellationGrace } from "@delegolabs/types"; diff --git a/apps/frontend/components/escrows/EscrowCard.tsx b/apps/frontend/components/escrows/EscrowCard.tsx index 9f80cf72..67dd16b9 100644 --- a/apps/frontend/components/escrows/EscrowCard.tsx +++ b/apps/frontend/components/escrows/EscrowCard.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import type { Escrow } from "@delegolabs/types"; import { ESCROW_STATUS_META } from "@delegolabs/types"; import { Amount, Card } from "@delegolabs/ui"; @@ -11,7 +10,8 @@ const LEDGER_CLOSE_SECONDS = 5; interface EscrowCardProps { escrow: Escrow; /** When set, wraps the escrow id in a link to its detail page. */ - href?: string; + href?: string; // Kept as href in interface to avoid breaking callers + /** * Force the "Disputed" status chip before the confirmed `escrow.status` * catches up — optimistic UI right after submitting a dispute. @@ -59,7 +59,7 @@ function computeCountdown( }; } -export function EscrowCard({ escrow, href, disputedOverride }: EscrowCardProps) { +export function EscrowCard({ escrow, href: _href, disputedOverride }: EscrowCardProps) { const { currencyId, rate } = useCurrency(); const meta = disputedOverride ? ESCROW_STATUS_META.Disputed : ESCROW_STATUS_META[escrow.status]; const countdown = computeCountdown( diff --git a/apps/frontend/components/layout/Sidebar.tsx b/apps/frontend/components/layout/Sidebar.tsx index b7a3db38..a43285f7 100644 --- a/apps/frontend/components/layout/Sidebar.tsx +++ b/apps/frontend/components/layout/Sidebar.tsx @@ -33,11 +33,10 @@ export function Sidebar() { // Primary nav: a small, fixed set of always-visible // destinations, so eager viewport prefetch is worth the // bandwidth (docs/architecture/prefetch-policy.md, #621). - prefetch={true} + prefetch={reducedModeActive ? false : true} className={`nav-link${isActive ? " active" : ""}`} aria-current={isActive ? "page" : undefined} data-nav={item.labelKey} - prefetch={reducedModeActive ? false : undefined} >