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
167 changes: 167 additions & 0 deletions frontend/src/components/TreasuryFlowChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { TreasuryFlowChart } from "./TreasuryFlowChart";
import type { TreasuryFlowBucket } from "../types/accord";

vi.mock("recharts", async () => {
const original = await vi.importActual("recharts");
return {
...original,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
};
});

const sampleBuckets: TreasuryFlowBucket[] = [
{
timestamp: "2026-07-01T00:00:00Z",
token: "XLM",
inflow: "2000",
outflow: "1250.5",
},
{
timestamp: "2026-08-01T00:00:00Z",
token: "XLM",
inflow: "500",
outflow: "300",
},
{
timestamp: "2026-07-01T00:00:00Z",
token: "USDC",
inflow: "100",
outflow: "50",
},
];

describe("TreasuryFlowChart", () => {
test("shows loading state when loading prop is true", () => {
render(<TreasuryFlowChart data={[]} loading={true} />);
expect(screen.getByText("Loading...")).toBeDefined();
});

test("shows empty message when data is empty", () => {
render(<TreasuryFlowChart data={[]} loading={false} />);
expect(
screen.getByText("No treasury flow data matches the selected filters."),
).toBeDefined();
});

test("shows error state and calls onRetry when retry is clicked", () => {
const onRetry = vi.fn();
render(
<TreasuryFlowChart
data={[]}
loading={false}
error="Network error"
onRetry={onRetry}
/>,
);
expect(screen.getByText("Network error")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
});

test("renders chart title, subtitle and granularity controls", () => {
render(<TreasuryFlowChart data={sampleBuckets} loading={false} />);
expect(screen.getByText("Treasury Inflow vs Outflow")).toBeDefined();
expect(
screen.getByText("Deposits and executed transfers per window"),
).toBeDefined();
expect(screen.getByTestId("flow-granularity-day")).toBeDefined();
expect(screen.getByTestId("flow-granularity-week")).toBeDefined();
expect(screen.getByTestId("flow-granularity-month")).toBeDefined();
});

test("renders accessible chart container when data is present", () => {
render(<TreasuryFlowChart data={sampleBuckets} loading={false} />);
expect(
screen.getByRole("img", { name: /treasury inflow vs outflow chart/i }),
).toBeDefined();
});

test("monthly granularity is active by default", () => {
render(<TreasuryFlowChart data={sampleBuckets} loading={false} />);
expect(
screen.getByTestId("flow-granularity-month").getAttribute("aria-pressed"),
).toBe("true");
expect(
screen.getByTestId("flow-granularity-day").getAttribute("aria-pressed"),
).toBe("false");
expect(
screen.getByTestId("flow-granularity-week").getAttribute("aria-pressed"),
).toBe("false");
});

test("clicking a granularity button updates aria-pressed and calls onGranularityChange", () => {
const onGranularityChange = vi.fn();
render(
<TreasuryFlowChart
data={sampleBuckets}
loading={false}
onGranularityChange={onGranularityChange}
/>,
);

const weekBtn = screen.getByTestId("flow-granularity-week");
fireEvent.click(weekBtn);
expect(onGranularityChange).toHaveBeenCalledWith("week");
expect(weekBtn.getAttribute("aria-pressed")).toBe("true");

const dayBtn = screen.getByTestId("flow-granularity-day");
fireEvent.click(dayBtn);
expect(onGranularityChange).toHaveBeenCalledWith("day");
expect(dayBtn.getAttribute("aria-pressed")).toBe("true");
});

test("controlled granularity prop overrides internal state", () => {
const { rerender } = render(
<TreasuryFlowChart
data={sampleBuckets}
loading={false}
granularity="day"
/>,
);
expect(
screen.getByTestId("flow-granularity-day").getAttribute("aria-pressed"),
).toBe("true");

rerender(
<TreasuryFlowChart
data={sampleBuckets}
loading={false}
granularity="week"
/>,
);
expect(
screen.getByTestId("flow-granularity-week").getAttribute("aria-pressed"),
).toBe("true");
expect(
screen.getByTestId("flow-granularity-day").getAttribute("aria-pressed"),
).toBe("false");
});

test("merges per-token buckets for the same timestamp into one chart point", () => {
// sampleBuckets has two tokens for 2026-07 — the chart should have 2 bars not 3.
render(<TreasuryFlowChart data={sampleBuckets} loading={false} />);
// aria-label mentions the count of data points
const img = screen.getByRole("img", { name: /treasury inflow vs outflow chart/i });
expect(img.getAttribute("aria-label")).toContain("2 data points");
});

test("screen-reader summary mentions inflow and outflow totals", () => {
render(<TreasuryFlowChart data={sampleBuckets} loading={false} />);
// The sr-only paragraph should contain total figures
const srText = document.querySelector(".sr-only")?.textContent ?? "";
expect(srText).toMatch(/Total inflow/i);
expect(srText).toMatch(/Total outflow/i);
});

test("granularity buttons display correct labels", () => {
render(<TreasuryFlowChart data={[]} loading={false} />);
expect(screen.getByTestId("flow-granularity-day").textContent).toBe("Daily");
expect(screen.getByTestId("flow-granularity-week").textContent).toBe("Weekly");
expect(screen.getByTestId("flow-granularity-month").textContent).toBe("Monthly");
});
});
187 changes: 187 additions & 0 deletions frontend/src/components/TreasuryFlowChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { AnalyticsSectionState } from "./AnalyticsSectionState";
import { formatTimeSeriesLabel } from "../lib/soroban";
import type { AnalyticsGranularity, TreasuryFlowBucket } from "../types/accord";

export type TreasuryFlowChartPoint = {
period: string;
inflow: number;
outflow: number;
};

export type TreasuryFlowChartProps = {
/** Raw per-token, per-bucket data from the /treasury/flow API. */
data: TreasuryFlowBucket[];
loading?: boolean;
error?: string | null;
onRetry?: () => void;
/** Controlled granularity; when omitted the component manages its own state. */
granularity?: AnalyticsGranularity;
onGranularityChange?: (g: AnalyticsGranularity) => void;
};

/**
* Merge per-token buckets that share the same timestamp into a single display
* point by summing inflows and outflows across all tokens in the window.
* Returns one entry per unique period label, sorted chronologically.
*/
function bucketsToBars(
data: TreasuryFlowBucket[],
granularity: AnalyticsGranularity,
): TreasuryFlowChartPoint[] {
const byPeriod = new Map<string, { inflow: number; outflow: number; sortKey: string }>();

for (const row of data) {
const label = formatTimeSeriesLabel(row.timestamp, granularity);
const existing = byPeriod.get(label);
const inflow = Number(row.inflow) || 0;
const outflow = Number(row.outflow) || 0;
if (existing) {
existing.inflow += inflow;
existing.outflow += outflow;
} else {
byPeriod.set(label, { inflow, outflow, sortKey: row.timestamp });
}
}

return [...byPeriod.entries()]
.sort(([, a], [, b]) => a.sortKey.localeCompare(b.sortKey))
.map(([period, { inflow, outflow }]) => ({ period, inflow, outflow }));
}

export function TreasuryFlowChart({
data,
loading = false,
error = null,
onRetry,
granularity: controlledGranularity,
onGranularityChange,
}: TreasuryFlowChartProps) {
const [internalGranularity, setInternalGranularity] =
useState<AnalyticsGranularity>("month");

const activeGranularity = controlledGranularity ?? internalGranularity;

const handleGranularityChange = (newG: AnalyticsGranularity) => {
setInternalGranularity(newG);
onGranularityChange?.(newG);
};

const chartData = useMemo(
() => bucketsToBars(data, activeGranularity),
[data, activeGranularity],
);

const isEmpty = !loading && !error && chartData.length === 0;

const totalInflow = chartData.reduce((sum, p) => sum + p.inflow, 0);
const totalOutflow = chartData.reduce((sum, p) => sum + p.outflow, 0);

return (
<div
className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 mb-6"
data-testid="treasury-flow-chart-container"
>
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h3 className="font-semibold text-sm">Treasury Inflow vs Outflow</h3>
<p className="text-xs text-zinc-500 mt-0.5">
Deposits and executed transfers per window
</p>
</div>
<div
className="flex items-center gap-1 bg-zinc-800/80 p-1 rounded-lg"
role="group"
aria-label="Granularity selection"
>
{(["day", "week", "month"] as const).map((g) => (
<button
key={g}
type="button"
onClick={() => handleGranularityChange(g)}
aria-pressed={activeGranularity === g}
data-testid={`flow-granularity-${g}`}
className={`text-xs px-2.5 py-1 rounded-md transition-colors capitalize ${
activeGranularity === g
? "bg-zinc-700 text-white font-medium"
: "text-zinc-400 hover:text-zinc-200"
}`}
>
{g === "day" ? "Daily" : g === "week" ? "Weekly" : "Monthly"}
</button>
))}
</div>
</div>

<AnalyticsSectionState
loading={loading}
error={error}
empty={isEmpty}
emptyMessage="No treasury flow data matches the selected filters."
onRetry={onRetry}
>
<div
className="w-full h-64"
role="img"
aria-label={`Treasury inflow vs outflow chart. ${chartData.length} data points shown.`}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} barGap={2}>
<CartesianGrid
strokeDasharray="3 3"
stroke="rgba(113, 113, 122, 0.3)"
/>
<XAxis
dataKey="period"
stroke="#71717a"
style={{ fontSize: "0.75rem" }}
/>
<YAxis stroke="#71717a" style={{ fontSize: "0.75rem" }} />
<Tooltip
contentStyle={{
backgroundColor: "#18181b",
border: "1px solid #3f3f46",
borderRadius: "0.5rem",
}}
labelStyle={{ color: "#e4e4e7" }}
formatter={(value) =>
typeof value === "number" ? value.toFixed(2) : value
}
/>
<Legend
wrapperStyle={{ fontSize: "0.75rem", paddingTop: "0.5rem" }}
/>
<Bar
dataKey="inflow"
name="Inflow"
fill="#10b981"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="outflow"
name="Outflow"
fill="#f43f5e"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
<p className="sr-only">
{chartData.length
? `Total inflow: ${totalInflow.toFixed(2)}. Total outflow: ${totalOutflow.toFixed(2)}.`
: "No treasury flow data is available."}
</p>
</AnalyticsSectionState>
</div>
);
}
11 changes: 11 additions & 0 deletions frontend/src/hooks/useRoles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ function parseRoleAssignments(raw: string | undefined): RoleAssignments {
const [address, rolesValue] = entry.split(":");
const normalizedAddress = normalizeAddress(address ?? "");
if (!normalizedAddress || !rolesValue) return assignments;
const parsedRoles = rolesValue
.split("|")
.map(parseRole)
.filter((r): r is WalletRole => r !== null);
if (parsedRoles.length > 0) {
assignments[normalizedAddress] = parsedRoles;
}
return assignments;
}, {});
}

export function useRoles(address: string | null) {
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState<boolean>(false);
Expand Down
Loading