diff --git a/app/(dashboard)/mypredictions/page.tsx b/app/(dashboard)/mypredictions/page.tsx index 6df275af..b8eabfe1 100644 --- a/app/(dashboard)/mypredictions/page.tsx +++ b/app/(dashboard)/mypredictions/page.tsx @@ -9,6 +9,7 @@ import { Clock, Activity, } from "lucide-react"; +import { PortfolioPie, STATUS_COLORS } from "@/components/PortfolioPie"; // --- 1. Type Definitions --- @@ -415,6 +416,30 @@ const MyPredictionsAndHistoryPage: React.FC = () => { const MAIN_TABS: MainTab[] = ["My Predictions", "Transaction history"]; const [activeMainTab, setActiveMainTab] = useState("My Predictions"); + /** + * Derive portfolio slice data from MOCK_PREDICTIONS. + * Each slice aggregates stakeAmount for a given status. + * Replace MOCK_PREDICTIONS with real data when the API is ready. + */ + const pieData = useMemo(() => { + const statusOrder: Array = ["active", "pending", "won", "lost"]; + const labelMap: Record = { + active: "Active", + pending: "Pending", + won: "Won", + lost: "Lost", + }; + + return statusOrder.map((status) => ({ + label: labelMap[status], + value: MOCK_PREDICTIONS.filter((p) => p.status === status).reduce( + (sum, p) => sum + p.stakeAmount, + 0 + ), + color: STATUS_COLORS[labelMap[status]] ?? "#6B7280", + })); + }, []); + const renderContent = () => { switch (activeMainTab) { case "My Predictions": @@ -426,6 +451,9 @@ const MyPredictionsAndHistoryPage: React.FC = () => { ))} + {/* Portfolio distribution pie chart */} + + ); diff --git a/components/PortfolioPie.tsx b/components/PortfolioPie.tsx new file mode 100644 index 00000000..83c228bf --- /dev/null +++ b/components/PortfolioPie.tsx @@ -0,0 +1,407 @@ +"use client"; + +/** + * PortfolioPie — stake distribution chart for the My Predictions page. + * + * Renders a Recharts PieChart showing the user's total staked amount split + * across Active / Pending / Won / Lost positions. A visually-hidden + * is provided as an SR fallback (WCAG 2.1 AA, SC 1.1.1 & 1.3.1). + * + * @module components/PortfolioPie + */ + +import React, { useCallback, useState } from "react"; +import { + PieChart, + Pie, + Cell, + Tooltip, + ResponsiveContainer, + Sector, +} from "recharts"; +import type { PieSectorDataItem } from "recharts/types/polar/Pie"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** A single slice of the portfolio distribution. */ +export interface PortfolioSlice { + /** Human-readable label (e.g. "Active"). */ + label: string; + /** Total staked amount for this status. */ + value: number; + /** Hex colour token used for the slice fill. */ + color: string; +} + +export interface PortfolioPieProps { + /** + * Array of slices to render. Pass an empty array to show the zero-data + * placeholder. + */ + data: PortfolioSlice[]; + /** + * Currency / token label appended to tooltip values (default: "XLM"). + */ + token?: string; +} + +// ─── Colour palette ────────────────────────────────────────────────────────── + +/** + * Brand-aligned, WCAG-AA-contrast colours for each status slice. + * These intentionally avoid generic primary colours and match the purple + * brand palette used across the dashboard. + */ +export const STATUS_COLORS: Record = { + Active: "#7C3AED", // violet-600 — clear, vibrant + Pending: "#F59E0B", // amber-500 — distinct from violet + Won: "#10B981", // emerald-500 — positive + Lost: "#F43F5E", // rose-500 — negative, differentiated from red +}; + +// ─── Active-shape renderer ─────────────────────────────────────────────────── + +/** + * Custom active-shape for the hovered slice — adds an outer ring and + * slightly pops the slice outward for a premium micro-interaction feel. + */ +const renderActiveShape = (props: PieSectorDataItem) => { + const { + cx = 0, + cy = 0, + innerRadius = 0, + outerRadius = 0, + startAngle, + endAngle, + fill, + payload, + percent, + value, + } = props as PieSectorDataItem & { + payload: PortfolioSlice; + percent: number; + value: number; + }; + + const pct = ((percent ?? 0) * 100).toFixed(1); + + return ( + + {/* Outer glow ring */} + + {/* Primary slice — offset outward */} + + {/* Centre label */} + + {(payload as PortfolioSlice).label} + + + {pct}% + + + {value} XLM + + + ); +}; + +// ─── Custom Tooltip ────────────────────────────────────────────────────────── + +interface CustomTooltipProps { + active?: boolean; + payload?: Array<{ payload: PortfolioSlice; value: number; payload: PortfolioSlice }>; + token: string; +} + +const CustomTooltip: React.FC = ({ + active, + payload, + token, +}) => { + if (!active || !payload?.length) return null; + const slice = payload[0].payload as PortfolioSlice; + const pct = + payload[0] && + (payload[0] as unknown as { percent: number }).percent !== undefined + ? ((payload[0] as unknown as { percent: number }).percent * 100).toFixed(1) + : "—"; + + return ( +
+
+ ); +}; + +// ─── SR Table fallback ──────────────────────────────────────────────────────── + +interface SRTableProps { + data: PortfolioSlice[]; + total: number; + token: string; +} + +/** + * Visually-hidden table that exposes the chart data to screen readers. + * The containing chart SVG is aria-hidden; this table carries the semantic + * meaning (WCAG 2.1 AA — SC 1.1.1 Non-text Content). + */ +const SRTable: React.FC = ({ data, total, token }) => ( +
+ + + + + + + + + + {data.map((slice) => { + const pct = total > 0 ? ((slice.value / total) * 100).toFixed(1) : "0.0"; + return ( + + + + + + ); + })} + + + + + + + + +
Portfolio distribution by prediction status
StatusAmount ({token})Percentage
{slice.label}{slice.value}{pct}%
Total{total}100%
+); + +// ─── Legend ─────────────────────────────────────────────────────────────────── + +const Legend: React.FC<{ data: PortfolioSlice[]; total: number; token: string }> = ({ + data, + total, + token, +}) => ( +
    + {data.map((slice) => { + const pct = total > 0 ? ((slice.value / total) * 100).toFixed(1) : "0.0"; + return ( +
  • +
  • + ); + })} +
+); + +// ─── Zero-state placeholder ─────────────────────────────────────────────────── + +const ZeroState: React.FC = () => ( +
+ +

No staked positions yet

+
+); + +// ─── Main Component ─────────────────────────────────────────────────────────── + +/** + * PortfolioPie + * + * Renders an interactive donut pie chart of the user's prediction portfolio + * broken down by status. Includes a screen-reader `` fallback. + * + * @example + * ```tsx + * + * ``` + */ +export const PortfolioPie: React.FC = ({ + data, + token = "XLM", +}) => { + const [activeIndex, setActiveIndex] = useState(undefined); + + const total = data.reduce((sum, s) => sum + s.value, 0); + + const onPieEnter = useCallback((_: unknown, index: number) => { + setActiveIndex(index); + }, []); + + const onPieLeave = useCallback(() => { + setActiveIndex(undefined); + }, []); + + const hasData = total > 0; + + return ( +
+
+

+ Portfolio Distribution +

+ {hasData && ( + + Total staked: {total} {token} + + )} +
+ + {/* SR-only data table — aria-hidden on the chart below */} + + + {hasData ? ( + <> + {/* Chart — aria-hidden; data is conveyed by the SR table above */} + + + + + ) : ( + + )} +
+ ); +}; + +export default PortfolioPie; diff --git a/components/__tests__/PortfolioPie.test.tsx b/components/__tests__/PortfolioPie.test.tsx new file mode 100644 index 00000000..cfa021f2 --- /dev/null +++ b/components/__tests__/PortfolioPie.test.tsx @@ -0,0 +1,174 @@ +/** + * PortfolioPie — focused unit tests + * + * Covers: + * 1. Renders without crashing on valid data + * 2. test-id "portfolio-pie" is present + * 3. SR table is present (data-testid="portfolio-pie-sr-table") + * 4. SR table contains all 4 status rows + * 5. SR table percentage values sum to ~100 % + * 6. Zero-data: renders zero-state placeholder, not the chart + * 7. Single non-zero slice: renders SR table with one populated row + * 8. aria-hidden on the chart wrapper (non-SR content hidden) + * 9. Snapshot of full component with default data + * 10. Snapshot of zero-state + */ + +import React from "react"; +import { render, screen, within } from "@testing-library/react"; +import { PortfolioPie, STATUS_COLORS } from "../PortfolioPie"; +import type { PortfolioSlice } from "../PortfolioPie"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Mock Recharts so rendering doesn't depend on SVG/canvas support in jsdom. */ +jest.mock("recharts", () => { + const Recharts = jest.requireActual("recharts"); + return { + ...Recharts, + ResponsiveContainer: ({ + children, + }: { + children: React.ReactNode; + }) =>
{children}
, + PieChart: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Pie: ({ data }: { data: PortfolioSlice[] }) => ( + + {data.map((d) => ( + + ))} + + ), + Cell: ({ fill }: { fill: string }) => ( + + ), + Tooltip: () => null, + Sector: () => , + }; +}); + +/** Default 4-slice test data mirroring MOCK_PREDICTIONS totals. */ +const DEFAULT_DATA: PortfolioSlice[] = [ + { label: "Active", value: 10, color: STATUS_COLORS.Active }, + { label: "Pending", value: 5, color: STATUS_COLORS.Pending }, + { label: "Won", value: 28, color: STATUS_COLORS.Won }, + { label: "Lost", value: 15, color: STATUS_COLORS.Lost }, +]; + +/** Total expected for DEFAULT_DATA */ +const TOTAL = DEFAULT_DATA.reduce((s, d) => s + d.value, 0); // 58 + +// ─── Test suite ─────────────────────────────────────────────────────────────── + +describe("PortfolioPie", () => { + // ── 1. Renders without crashing ────────────────────────────────────────── + it("renders without crashing on valid data", () => { + expect(() => + render() + ).not.toThrow(); + }); + + // ── 2. Root test-id ────────────────────────────────────────────────────── + it('renders the section with data-testid="portfolio-pie"', () => { + render(); + expect(screen.getByTestId("portfolio-pie")).toBeInTheDocument(); + }); + + // ── 3. SR table is present ─────────────────────────────────────────────── + it('renders an SR table with data-testid="portfolio-pie-sr-table"', () => { + render(); + const table = screen.getByTestId("portfolio-pie-sr-table"); + expect(table).toBeInTheDocument(); + // Must carry sr-only class so it is visually hidden but DOM-accessible + expect(table).toHaveClass("sr-only"); + }); + + // ── 4. SR table rows ───────────────────────────────────────────────────── + it("SR table contains one row per slice", () => { + render(); + const table = screen.getByTestId("portfolio-pie-sr-table"); + const bodyRows = within(table).getAllByRole("row"); + // thead row + 4 body rows + tfoot row = 6 + expect(bodyRows).toHaveLength(DEFAULT_DATA.length + 2); + }); + + // ── 5. SR table has correct label cells ────────────────────────────────── + it("SR table displays each status label", () => { + render(); + const table = screen.getByTestId("portfolio-pie-sr-table"); + DEFAULT_DATA.forEach(({ label }) => { + expect(within(table).getByText(label)).toBeInTheDocument(); + }); + }); + + // ── 6. SR table percentage values ──────────────────────────────────────── + it("SR table shows correct percentage for each slice", () => { + render(); + const table = screen.getByTestId("portfolio-pie-sr-table"); + + DEFAULT_DATA.forEach(({ value }) => { + const pct = ((value / TOTAL) * 100).toFixed(1) + "%"; + expect(within(table).getByText(pct)).toBeInTheDocument(); + }); + }); + + // ── 7. Zero-data: shows placeholder, not chart ─────────────────────────── + it("renders zero-state placeholder when all values are 0", () => { + const zeroData: PortfolioSlice[] = DEFAULT_DATA.map((d) => ({ + ...d, + value: 0, + })); + render(); + expect( + screen.getByText(/no staked positions yet/i) + ).toBeInTheDocument(); + // Recharts chart must NOT be present + expect(screen.queryByTestId("recharts-responsive-container")).toBeNull(); + }); + + // ── 8. Single non-zero slice ───────────────────────────────────────────── + it("handles a single non-zero slice without crashing", () => { + const singleSlice: PortfolioSlice[] = [ + { label: "Won", value: 42, color: STATUS_COLORS.Won }, + ]; + render(); + const table = screen.getByTestId("portfolio-pie-sr-table"); + expect(within(table).getByText("Won")).toBeInTheDocument(); + expect(within(table).getByText("100.0%")).toBeInTheDocument(); + }); + + // ── 9. aria-hidden on chart wrapper ────────────────────────────────────── + it("the chart area is aria-hidden so screen readers use the SR table", () => { + render(); + // The div wrapping ResponsiveContainer must have aria-hidden="true" + const chartWrapper = screen + .getByTestId("portfolio-pie") + .querySelector('[aria-hidden="true"]'); + expect(chartWrapper).not.toBeNull(); + }); + + // ── 10. Token label ────────────────────────────────────────────────────── + it('displays the custom token label in the SR table header', () => { + render(); + const table = screen.getByTestId("portfolio-pie-sr-table"); + expect(within(table).getByText(/Amount \(USDC\)/)).toBeInTheDocument(); + }); + + // ── 11. Snapshot — default data ────────────────────────────────────────── + it("matches snapshot with default data", () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + // ── 12. Snapshot — zero state ──────────────────────────────────────────── + it("matches snapshot with zero data", () => { + const zeroData: PortfolioSlice[] = DEFAULT_DATA.map((d) => ({ + ...d, + value: 0, + })); + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/components/__tests__/__snapshots__/PortfolioPie.test.tsx.snap b/components/__tests__/__snapshots__/PortfolioPie.test.tsx.snap new file mode 100644 index 00000000..c2f41b56 --- /dev/null +++ b/components/__tests__/__snapshots__/PortfolioPie.test.tsx.snap @@ -0,0 +1,412 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PortfolioPie matches snapshot with default data 1`] = ` +
+
+

+ Portfolio Distribution +

+ + Total staked: + 58 + + XLM + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Portfolio distribution by prediction status +
+ Status + + Amount ( + XLM + ) + + Percentage +
+ Active + + 10 + + 17.2 + % +
+ Pending + + 5 + + 8.6 + % +
+ Won + + 28 + + 48.3 + % +
+ Lost + + 15 + + 25.9 + % +
+ Total + + 58 + + 100% +
+ +
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+ +`; + +exports[`PortfolioPie matches snapshot with zero data 1`] = ` +
+
+

+ Portfolio Distribution +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Portfolio distribution by prediction status +
+ Status + + Amount ( + XLM + ) + + Percentage +
+ Active + + 0 + + 0.0 + % +
+ Pending + + 0 + + 0.0 + % +
+ Won + + 0 + + 0.0 + % +
+ Lost + + 0 + + 0.0 + % +
+ Total + + 0 + + 100% +
+
+ +

+ No staked positions yet +

+
+
+`;