Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/frontend/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
211 changes: 211 additions & 0 deletions apps/frontend/components/analytics/AgentLeaderboard.tsx
Original file line number Diff line number Diff line change
@@ -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<SortField>("totalSpent");
const [sortDir, setSortDir] = useState<SortDirection>("desc");

const agentsData = useMemo(() => {
const agentMap = new Map<string, any>();

// 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 <span aria-hidden="true" className="sort-icon">{sortDir === "asc" ? " ▲" : " ▼"}</span>;
};

const handleRowClick = (agentId: string) => {
router.push(`/orders?search=${encodeURIComponent(agentId)}`);
};

return (
<div className="leaderboard-container">
<div className="comparison-table-wrapper desktop-leaderboard">
<table className="comparison-table sticky-header">
<thead>
<tr>
<th aria-sort={sortField === "agentId" ? (sortDir === "asc" ? "ascending" : "descending") : "none"}>
<button onClick={() => handleSort("agentId")} className="sort-button">Agent <SortIcon field="agentId" /></button>
</th>
<th aria-sort={sortField === "tasks" ? (sortDir === "asc" ? "ascending" : "descending") : "none"}>
<button onClick={() => handleSort("tasks")} className="sort-button">Tasks <SortIcon field="tasks" /></button>
</th>
<th aria-sort={sortField === "successRate" ? (sortDir === "asc" ? "ascending" : "descending") : "none"}>
<button onClick={() => handleSort("successRate")} className="sort-button">Success Rate <SortIcon field="successRate" /></button>
</th>
<th aria-sort={sortField === "avgSavings" ? (sortDir === "asc" ? "ascending" : "descending") : "none"}>
<button onClick={() => handleSort("avgSavings")} className="sort-button">Avg Savings <SortIcon field="avgSavings" /></button>
</th>
<th aria-sort={sortField === "totalSpent" ? (sortDir === "asc" ? "ascending" : "descending") : "none"}>
<button onClick={() => handleSort("totalSpent")} className="sort-button">Total Spent <SortIcon field="totalSpent" /></button>
</th>
<th aria-sort={sortField === "activeDelegations" ? (sortDir === "asc" ? "ascending" : "descending") : "none"}>
<button onClick={() => handleSort("activeDelegations")} className="sort-button">Active Delegations <SortIcon field="activeDelegations" /></button>
</th>
</tr>
</thead>
<tbody>
{agentsData.map(row => (
<tr key={row.agentId} onClick={() => handleRowClick(row.agentId)} className="clickable-row">
<td>{row.agentId}</td>
<td>{row.tasks}</td>
<td>{(row.successRate * 100).toFixed(1)}%</td>
<td>
<Amount stroops={row.avgSavings} currency={currencyId} xlmUsdRate={rate?.xlmUsdRate} />
</td>
<td>
<Amount stroops={row.totalSpent} currency={currencyId} xlmUsdRate={rate?.xlmUsdRate} />
</td>
<td>{row.activeDelegations}</td>
</tr>
))}
{agentsData.length === 0 && (
<tr>
<td colSpan={6} style={{ textAlign: "center" }}>No agent data available.</td>
</tr>
)}
</tbody>
</table>
</div>

<div className="mobile-leaderboard-cards">
{agentsData.map(row => (
<div key={row.agentId} className="card clickable-card" onClick={() => handleRowClick(row.agentId)} style={{ marginBottom: "1rem", cursor: "pointer" }}>
<div className="card-header">
<h3>{row.agentId}</h3>
</div>
<div className="grid">
<div className="stat-group">
<span className="stat-label">Total Spent </span>
<span className="stat-value">
<Amount stroops={row.totalSpent} currency={currencyId} xlmUsdRate={rate?.xlmUsdRate} />
</span>
</div>
<div className="stat-group">
<span className="stat-label">Tasks </span>
<span className="stat-value">{row.tasks}</span>
</div>
<div className="stat-group">
<span className="stat-label">Success Rate </span>
<span className="stat-value">{(row.successRate * 100).toFixed(1)}%</span>
</div>
<div className="stat-group">
<span className="stat-label">Active </span>
<span className="stat-value">{row.activeDelegations}</span>
</div>
</div>
</div>
))}
</div>

<style jsx>{`
.sort-button {
background: none;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.clickable-row { cursor: pointer; transition: background 0.15s; }
.clickable-row:hover { background: var(--color-bg-subtle); }
.sticky-header th { position: sticky; top: 0; background: var(--color-bg); z-index: 10; }
@media (min-width: 768px) {
.mobile-leaderboard-cards { display: none; }
}
@media (max-width: 767px) {
.desktop-leaderboard { display: none; }
}
`}</style>
</div>
);
}
90 changes: 90 additions & 0 deletions apps/frontend/components/analytics/DeltaCards.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className="delta-chip delta-neutral">—</span>;
}
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 (
<span
className={`delta-chip status-${tone}`}
aria-label={isZero ? "No change" : `${isPositive ? 'Increased' : 'Decreased'} by ${percentage}`}
>
{arrow} {percentage}
</span>
);
}

export function DeltaCards({ current, deltas }: DeltaCardsProps) {
const { currencyId, rate } = useCurrency();

return (
<div className="grid animate-fade-in delta-cards-grid">
<Card title="Spend">
<p className="stat-value">
<Amount
stroops={current.spend}
currency={currencyId}
xlmUsdRate={rate?.xlmUsdRate}
/>
</p>
<div className="stat-label">
<DeltaChip delta={deltas.spendDelta} inverse={true} /> vs previous
</div>
</Card>

<Card title="Orders">
<p className="stat-value">{current.orderCount}</p>
<div className="stat-label">
<DeltaChip delta={deltas.orderCountDelta} /> vs previous
</div>
</Card>

<Card title="Avg Order Value">
<p className="stat-value">
<Amount
stroops={current.avgOrderValue}
currency={currencyId}
xlmUsdRate={rate?.xlmUsdRate}
/>
</p>
<div className="stat-label">
<DeltaChip delta={deltas.avgOrderValueDelta} inverse={true} /> vs previous
</div>
</Card>

<Card title="Approval Rate">
<p className="stat-value">
{(current.approvalRate * 100).toFixed(1)}%
</p>
<div className="stat-label">
<DeltaChip delta={deltas.approvalRateDelta} /> vs previous
</div>
</Card>
</div>
);
}
9 changes: 4 additions & 5 deletions apps/frontend/components/delegations/DelegationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ export function DelegationCard({
<div className="flex items-center gap-2">
<DelegationStatusChip
delegation={delegation}
cap={delegation.policy.maxTotal}
onResume={() => setShowPauseModal(true)}
onRenew={
onDuplicate ? () => onDuplicate(delegation) : undefined
Expand Down Expand Up @@ -220,8 +219,8 @@ export function DelegationCard({
</div>

<LimitUsageBar
delegation={delegation}
currencyId={currencyId}
spent={0n} cap={delegation.policy.maxTotal} periodRollover={delegation.policy.expiresAt}
currency={currencyId as any}
rate={rate}
/>

Expand Down Expand Up @@ -281,15 +280,15 @@ export function DelegationCard({
<span>Per transaction limit:</span>
<Amount
stroops={delegation.policy.maxPerTransaction}
currencyId={currencyId}
currency={currencyId as any}
rate={rate}
/>
</div>
<div className="policy-summary-row">
<span>Total budget limit:</span>
<Amount
stroops={delegation.policy.maxTotal}
currencyId={currencyId}
currency={currencyId as any}
rate={rate}
/>
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/components/demo/DemoBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: "" };

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Loading
Loading