Skip to content
Open
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
28 changes: 24 additions & 4 deletions src/components/LPPortfolio.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useMemo } from "react";
import { useEffect, useState, useMemo } from "react";
import { formatAddress, formatDate, formatUSDC, calculateYield } from "@/utils/format";
import type { Invoice } from "@/utils/soroban";
import type { ApprovedToken } from "@/hooks/useApprovedTokens";
Expand All @@ -9,6 +9,8 @@ import InvoiceTable, { ColumnDefinition } from "./InvoiceTable";
import LPTokenMetricsCards from "./LPTokenMetricsCards";
import WeeklyYieldChart from "./WeeklyYieldChart";
import { calculatePerTokenMetrics } from "@/utils/per-token-yield";
import LPRiskPanel from "./LPRiskPanel";
import { applyLPRiskFilter, type LPRiskFilter } from "@/utils/lpRisk";

interface LPPortfolioProps {
invoices: Invoice[];
Expand All @@ -28,7 +30,13 @@ export default function LPPortfolio({
defaultToken = null,
}: LPPortfolioProps) {
const [showUSDEquivalent, setShowUSDEquivalent] = useState(false);
const now = Date.now();
const [riskFilter, setRiskFilter] = useState<LPRiskFilter>("all");
const [riskNow, setRiskNow] = useState(() => Date.now());

useEffect(() => {
const interval = window.setInterval(() => setRiskNow(Date.now()), 5 * 60 * 1000);
return () => window.clearInterval(interval);
}, []);

// Calculate per-token metrics
const perTokenMetrics = useMemo(
Expand All @@ -40,6 +48,11 @@ export default function LPPortfolio({
.filter((invoice) => invoice.status === "Paid")
.reduce((total, invoice) => total + calculateYield(invoice.amount, invoice.discount_rate), 0n);

const visibleInvoices = useMemo(
() => applyLPRiskFilter(invoices, riskFilter, riskNow),
[invoices, riskFilter, riskNow],
);

const columns: ColumnDefinition<Invoice>[] = [
{
id: "id",
Expand Down Expand Up @@ -105,7 +118,7 @@ export default function LPPortfolio({
label: "",
sortable: false,
renderCell: (inv) => {
const isPastDue = Number(inv.due_date) * 1000 < now;
const isPastDue = Number(inv.due_date) * 1000 < riskNow;
const isClaimEligible = inv.status === "Funded" && isPastDue;
const isClaiming = claimingInvoiceId === inv.id.toString();

Expand Down Expand Up @@ -152,10 +165,17 @@ export default function LPPortfolio({
/>
)}

<LPRiskPanel
invoices={invoices}
activeFilter={riskFilter}
onFilterChange={setRiskFilter}
now={riskNow}
/>

{/* Portfolio Table */}
<InvoiceTable
tableId="lp_portfolio_table"
data={invoices}
data={visibleInvoices}
columns={columns}
isLoading={isLoading}
emptyStateNode={
Expand Down
144 changes: 144 additions & 0 deletions src/components/LPRiskPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"use client";

import type { Invoice } from "@/utils/soroban";
import { formatUSDC } from "@/utils/format";
import {
calculateLPRiskMetrics,
getRiskSeverity,
type LPRiskFilter,
} from "@/utils/lpRisk";

interface LPRiskPanelProps {
invoices: Invoice[];
activeFilter: LPRiskFilter;
onFilterChange: (filter: LPRiskFilter) => void;
now: number;
}

const SEVERITY_CLASSES = {
green: "border-green-500/20 bg-green-500/10 text-green-700",
amber: "border-amber-500/25 bg-amber-500/10 text-amber-700",
red: "border-red-500/25 bg-red-500/10 text-red-700",
} as const;

function MetricButton({
icon,
label,
value,
sub,
severity,
active,
onClick,
}: {
icon: string;
label: string;
value: string;
sub: string;
severity: keyof typeof SEVERITY_CLASSES;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={`rounded-2xl border p-4 text-left transition-all hover:-translate-y-0.5 hover:shadow-sm ${
SEVERITY_CLASSES[severity]
} ${active ? "ring-2 ring-primary ring-offset-2 ring-offset-surface-container-lowest" : ""}`}
>
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-bold uppercase tracking-[0.18em]">
{label}
</span>
<span className="material-symbols-outlined text-xl" aria-hidden="true">
{icon}
</span>
</div>
<p className="mt-3 font-headline text-3xl font-bold">{value}</p>
<p className="mt-1 text-xs opacity-80">{sub}</p>
</button>
);
}

export default function LPRiskPanel({
invoices,
activeFilter,
onFilterChange,
now,
}: LPRiskPanelProps) {
const metrics = calculateLPRiskMetrics(invoices, now);

const toggleFilter = (filter: LPRiskFilter) => {
onFilterChange(activeFilter === filter ? "all" : filter);
};

return (
<section
aria-labelledby="lp-risk-panel-heading"
className="rounded-2xl border border-outline-variant/15 bg-surface-container-lowest p-5 shadow-sm"
>
<div className="mb-4 flex flex-col justify-between gap-3 sm:flex-row sm:items-end">
<div>
<h3 id="lp-risk-panel-heading" className="font-headline text-xl font-bold text-on-surface">
Position Risk
</h3>
<p className="text-sm text-on-surface-variant">
Flags disputed positions and funded invoices due within 24 hours.
</p>
</div>
<div className="flex items-center gap-2 text-xs text-on-surface-variant">
<span className="material-symbols-outlined text-sm" aria-hidden="true">
refresh
</span>
Refreshes with portfolio data
</div>
</div>

<div className="grid gap-3 md:grid-cols-3">
<MetricButton
icon="warning"
label="Positions at Risk"
value={metrics.positionsAtRisk.toLocaleString()}
sub="Disputed or due in under 24h"
severity={getRiskSeverity(metrics.positionsAtRisk)}
active={activeFilter === "at-risk"}
onClick={() => toggleFilter("at-risk")}
/>
<MetricButton
icon="account_balance_wallet"
label="Capital at Risk"
value={formatUSDC(metrics.capitalAtRisk)}
sub="Funded amount in at-risk positions"
severity={getRiskSeverity(metrics.positionsAtRisk)}
active={activeFilter === "at-risk"}
onClick={() => toggleFilter("at-risk")}
/>
<MetricButton
icon="gavel"
label="Disputed Positions"
value={metrics.disputedPositions.toLocaleString()}
sub="Positions with disputed status"
severity={getRiskSeverity(metrics.disputedPositions)}
active={activeFilter === "disputed"}
onClick={() => toggleFilter("disputed")}
/>
</div>

{activeFilter !== "all" && (
<div className="mt-4 flex items-center justify-between gap-3 rounded-xl bg-surface-container-low px-4 py-3 text-sm">
<span className="font-medium text-on-surface">
Filtering table to {activeFilter === "disputed" ? "disputed positions" : "at-risk positions"}.
</span>
<button
type="button"
onClick={() => onFilterChange("all")}
className="text-xs font-bold text-primary hover:underline"
>
Clear filter
</button>
</div>
)}
</section>
);
}
81 changes: 81 additions & 0 deletions src/utils/__tests__/lpRisk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import type { Invoice } from "@/utils/soroban";
import {
applyLPRiskFilter,
calculateLPRiskMetrics,
getRiskSeverity,
isAtRiskPosition,
} from "../lpRisk";

const NOW = Date.UTC(2026, 4, 26, 12, 0, 0);

function invoice(overrides: Partial<Invoice>): Invoice {
return {
id: 1n,
status: "Funded",
freelancer: "freelancer",
payer: "payer",
amount: 1000_0000000n,
due_date: BigInt(Math.floor((NOW + 7 * 24 * 60 * 60 * 1000) / 1000)),
discount_rate: 300,
funder: "lp",
...overrides,
};
}

describe("LP risk metrics", () => {
it("marks disputed and funded positions due within 24 hours as at risk", () => {
const disputed = invoice({ id: 1n, status: "Disputed" });
const nearExpiry = invoice({
id: 2n,
due_date: BigInt(Math.floor((NOW + 23 * 60 * 60 * 1000) / 1000)),
});
const healthy = invoice({
id: 3n,
due_date: BigInt(Math.floor((NOW + 3 * 24 * 60 * 60 * 1000) / 1000)),
});

expect(isAtRiskPosition(disputed, NOW)).toBe(true);
expect(isAtRiskPosition(nearExpiry, NOW)).toBe(true);
expect(isAtRiskPosition(healthy, NOW)).toBe(false);
});

it("calculates counts and capital at risk from LP positions", () => {
const invoices = [
invoice({ id: 1n, amount: 500_0000000n, status: "Disputed" }),
invoice({
id: 2n,
amount: 1200_0000000n,
due_date: BigInt(Math.floor((NOW + 60 * 60 * 1000) / 1000)),
}),
invoice({ id: 3n, amount: 900_0000000n, status: "Paid" }),
];

expect(calculateLPRiskMetrics(invoices, NOW)).toEqual({
positionsAtRisk: 2,
capitalAtRisk: 1700_0000000n,
disputedPositions: 1,
});
});

it("filters table positions for at-risk and disputed metrics", () => {
const invoices = [
invoice({ id: 1n, status: "Disputed" }),
invoice({
id: 2n,
due_date: BigInt(Math.floor((NOW + 60 * 60 * 1000) / 1000)),
}),
invoice({ id: 3n, status: "Paid" }),
];

expect(applyLPRiskFilter(invoices, "at-risk", NOW).map((item) => item.id)).toEqual([1n, 2n]);
expect(applyLPRiskFilter(invoices, "disputed", NOW).map((item) => item.id)).toEqual([1n]);
expect(applyLPRiskFilter(invoices, "all", NOW)).toHaveLength(3);
});

it("maps zero, low, and high metric counts to severity colours", () => {
expect(getRiskSeverity(0)).toBe("green");
expect(getRiskSeverity(2)).toBe("amber");
expect(getRiskSeverity(3)).toBe("red");
});
});
69 changes: 69 additions & 0 deletions src/utils/lpRisk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Invoice } from "@/utils/soroban";

export type LPRiskFilter = "all" | "at-risk" | "disputed";

export interface LPRiskMetrics {
positionsAtRisk: number;
capitalAtRisk: bigint;
disputedPositions: number;
}

const DAY_MS = 24 * 60 * 60 * 1000;
const RISK_WINDOW_MS = DAY_MS;

export function isDisputedPosition(invoice: Invoice): boolean {
return invoice.status.toLowerCase() === "disputed";
}

export function isNearingExpiry(invoice: Invoice, now = Date.now()): boolean {
if (invoice.status !== "Funded") return false;
const dueAt = Number(invoice.due_date) * 1000;
return dueAt <= now + RISK_WINDOW_MS;
}

export function isAtRiskPosition(invoice: Invoice, now = Date.now()): boolean {
return isDisputedPosition(invoice) || isNearingExpiry(invoice, now);
}

export function calculateLPRiskMetrics(
invoices: Invoice[],
now = Date.now(),
): LPRiskMetrics {
return invoices.reduce<LPRiskMetrics>(
(metrics, invoice) => {
const disputed = isDisputedPosition(invoice);
const atRisk = disputed || isNearingExpiry(invoice, now);

if (atRisk) {
metrics.positionsAtRisk += 1;
metrics.capitalAtRisk += invoice.amount;
}
if (disputed) {
metrics.disputedPositions += 1;
}

return metrics;
},
{
positionsAtRisk: 0,
capitalAtRisk: 0n,
disputedPositions: 0,
},
);
}

export function applyLPRiskFilter(
invoices: Invoice[],
filter: LPRiskFilter,
now = Date.now(),
): Invoice[] {
if (filter === "all") return invoices;
if (filter === "disputed") return invoices.filter(isDisputedPosition);
return invoices.filter((invoice) => isAtRiskPosition(invoice, now));
}

export function getRiskSeverity(value: number): "green" | "amber" | "red" {
if (value === 0) return "green";
if (value <= 2) return "amber";
return "red";
}