diff --git a/src/__tests__/exports.test.ts b/src/__tests__/exports.test.ts index d467428..e640612 100644 --- a/src/__tests__/exports.test.ts +++ b/src/__tests__/exports.test.ts @@ -108,6 +108,7 @@ describe("Public exports surface", () => { expect(exports.PortfolioRebalancer).toBeDefined(); expect(exports.DelegationRow).toBeDefined(); expect(exports.RewardHistory).toBeDefined(); + expect(exports.generateRewardHistoryCsv).toBeTypeOf("function"); expect(exports.RewardsPanel).toBeDefined(); expect(exports.SwapExecutionTracker).toBeDefined(); expect(exports.PieChart).toBeDefined(); @@ -141,8 +142,11 @@ describe("Public exports surface", () => { // Staking Utilities expect(exports.aggregateDailyRewards).toBeTypeOf("function"); expect(exports.createDefaultFilter).toBeTypeOf("function"); + expect(exports.DEFAULT_XLM_PRICE_USD).toBeTypeOf("number"); expect(exports.DELEGATION_BASE_FEE_STROOPS).toBeTypeOf("number"); expect(exports.estimateDelegationFeeXlm).toBeTypeOf("function"); + expect(exports.estimateHistoricalXlmPriceUsd).toBeTypeOf("function"); + expect(exports.estimateRewardUsdValue).toBeTypeOf("function"); expect(exports.filterValidators).toBeTypeOf("function"); expect(exports.formatStakingPct).toBeTypeOf("function"); expect(exports.formatXlm).toBeTypeOf("function"); diff --git a/src/components/RewardHistory.test.tsx b/src/components/RewardHistory.test.tsx index 9700ac8..3016478 100644 --- a/src/components/RewardHistory.test.tsx +++ b/src/components/RewardHistory.test.tsx @@ -1,6 +1,7 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DailyReward, RewardEvent } from "@/lib/staking"; import { aggregateDailyRewards, generateMockRewardHistory, @@ -8,11 +9,21 @@ import { REWARD_HISTORY_DAYS, } from "@/lib/staking"; -import { RewardHistory } from "./RewardHistory"; +import { generateRewardHistoryCsv, RewardHistory } from "./RewardHistory"; const ALL_EVENTS = generateMockRewardHistory(); const DAILY = aggregateDailyRewards(ALL_EVENTS); +// Mock URL.createObjectURL and URL.revokeObjectURL for DOM CSV export tests +const mockCreateObjectURL = vi.fn(() => "blob:mock-reward-csv"); +const mockRevokeObjectURL = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + URL.createObjectURL = mockCreateObjectURL; + URL.revokeObjectURL = mockRevokeObjectURL; +}); + // ─── Rendering ──────────────────────────────────────────────────────────────── describe("RewardHistory — rendering", () => { @@ -24,7 +35,9 @@ describe("RewardHistory — rendering", () => { validators={MOCK_VALIDATORS} />, ); - expect(screen.getByText(`Last ${REWARD_HISTORY_DAYS} Days`)).toBeInTheDocument(); + expect( + screen.getByText(`Last ${REWARD_HISTORY_DAYS} Days`), + ).toBeInTheDocument(); }); it("renders the chart with accessible aria label", () => { @@ -52,7 +65,7 @@ describe("RewardHistory — rendering", () => { expect(screen.getAllByText(/XLM/)[0]).toBeInTheDocument(); }); - it("renders the event table header columns", () => { + it("renders the event table header columns including Est. USD", () => { render( { validators={MOCK_VALIDATORS} />, ); - expect(screen.getByRole("columnheader", { name: /date/i })).toBeInTheDocument(); - expect(screen.getByRole("columnheader", { name: /validator/i })).toBeInTheDocument(); - expect(screen.getByRole("columnheader", { name: /amount/i })).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: /date/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: /validator/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: /amount/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: /est\. usd/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: /tx/i }), + ).toBeInTheDocument(); }); - it("renders reward event rows (capped at 50)", () => { + it("renders reward event rows capped by pageSize", () => { render( { validators={MOCK_VALIDATORS} />, ); - // Each row has a + amount cell + // Default pageSize is 10 const amountCells = screen .getAllByText(/^\+/) .filter((el) => el.textContent?.includes("XLM")); expect(amountCells.length).toBeGreaterThan(0); - expect(amountCells.length).toBeLessThanOrEqual(50); + expect(amountCells.length).toBeLessThanOrEqual(10); + }); + + it("shows validator names in the event table", () => { + render( + , + ); + // At least one validator name should appear in the table + expect(screen.getAllByText("Alpha Staking").length).toBeGreaterThan(0); + }); + + it("shows the date range labels under the chart", () => { + render( + , + ); + // First and last date labels exist + expect(screen.getByText(DAILY[0].date)).toBeInTheDocument(); + expect(screen.getByText(DAILY[DAILY.length - 1].date)).toBeInTheDocument(); + }); + + it("renders truncated transaction hashes when present", () => { + const eventsWithHash: RewardEvent[] = [ + { + id: "tx-1", + date: "2026-10-15", + amount: "1.2500", + validatorId: "VALIDATOR_ALPHA", + txHash: "abcdef1234567890fedcba0987654321", + }, + ]; + render( + , + ); + expect(screen.getByText("abcdef…4321")).toBeInTheDocument(); }); +}); + +// ─── Empty state ────────────────────────────────────────────────────────────── - it("shows 'no reward data' message in chart when daily rewards is empty", () => { +describe("RewardHistory — empty state", () => { + it("shows 'No reward data' message in chart when daily rewards is empty", () => { render( { />, ); expect( - screen.getByText(/no reward history in the last/i), + screen.getByText(`No reward history in the last ${REWARD_HISTORY_DAYS} days.`), ).toBeInTheDocument(); }); - it("shows validator names in the event table", () => { + it("displays 0 XLM total and hides reward days badge and CSV button when empty", () => { render( , ); - // At least one validator name should appear in the table - expect(screen.getAllByText("Alpha Staking").length).toBeGreaterThan(0); + expect(screen.getByText("0 XLM")).toBeInTheDocument(); + expect(screen.queryByText(/reward days/i)).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /export csv/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); }); - it("shows the date range labels under the chart", () => { + it("shows empty filtered state when date filter excludes all events", () => { render( { validators={MOCK_VALIDATORS} />, ); - // First and last date labels exist - expect(screen.getByText(DAILY[0].date)).toBeInTheDocument(); + + const startInput = screen.getByLabelText("Start date"); + fireEvent.change(startInput, { target: { value: "2099-01-01" } }); + expect( - screen.getByText(DAILY[DAILY.length - 1].date), + screen.getByText("No reward events match the selected date range."), ).toBeInTheDocument(); + expect(screen.getByText("No reward data")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /export csv/i })).toBeDisabled(); + }); +}); + +// ─── Pagination ─────────────────────────────────────────────────────────────── + +describe("RewardHistory — pagination", () => { + it("paginates long reward histories without rendering all rows in DOM at once", () => { + // Generate 250 events to simulate hundreds of daily payouts + const largeEvents: RewardEvent[] = Array.from({ length: 250 }, (_, i) => ({ + id: `evt-${i}`, + date: `2026-10-${String((i % 28) + 1).padStart(2, "0")}`, + amount: (1 + i * 0.01).toFixed(4), + validatorId: "VALIDATOR_ALPHA", + })); + + render( + , + ); + + // Only 10 body rows should be rendered in the DOM + const rows = screen.getAllByRole("row"); + // 1 header row + 10 body rows = 11 rows + expect(rows).toHaveLength(11); + expect(screen.getByText("Page 1 of 25")).toBeInTheDocument(); + }); + + it("navigates between pages with Next and Prev buttons", () => { + const customEvents: RewardEvent[] = Array.from({ length: 15 }, (_, i) => ({ + id: `evt-${i}`, + date: `2026-10-${String(20 - i).padStart(2, "0")}`, + amount: (10 + i).toFixed(4), + validatorId: "VALIDATOR_ALPHA", + })); + + render( + , + ); + + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + const prevBtn = screen.getByRole("button", { name: /prev/i }); + const nextBtn = screen.getByRole("button", { name: /next/i }); + + expect(prevBtn).toBeDisabled(); + expect(nextBtn).not.toBeDisabled(); + + // First page has 10 items (10.0000 to 19.0000) + expect(screen.getByText("+10.0000 XLM")).toBeInTheDocument(); + expect(screen.queryByText("+20.0000 XLM")).not.toBeInTheDocument(); + + // Go to page 2 + fireEvent.click(nextBtn); + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + expect(prevBtn).not.toBeDisabled(); + expect(nextBtn).toBeDisabled(); + + // Second page has remaining 5 items (20.0000 to 24.0000) + expect(screen.getByText("+20.0000 XLM")).toBeInTheDocument(); + expect(screen.queryByText("+10.0000 XLM")).not.toBeInTheDocument(); + + // Go back to page 1 + fireEvent.click(prevBtn); + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + expect(screen.getByText("+10.0000 XLM")).toBeInTheDocument(); + }); + + it("hides pagination controls when all events fit on a single page", () => { + const fewEvents: RewardEvent[] = [ + { + id: "evt-1", + date: "2026-10-15", + amount: "2.5000", + validatorId: "VALIDATOR_ALPHA", + }, + ]; + + render( + , + ); + + expect(screen.queryByText(/page 1 of/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /next/i })).not.toBeInTheDocument(); + }); +}); + +// ─── Date filtering ─────────────────────────────────────────────────────────── + +describe("RewardHistory — date filtering", () => { + const sampleDaily: DailyReward[] = [ + { date: "2026-10-01", totalXlm: 1.0 }, + { date: "2026-10-05", totalXlm: 2.0 }, + { date: "2026-10-10", totalXlm: 3.0 }, + ]; + + const sampleEvents: RewardEvent[] = [ + { + id: "e1", + date: "2026-10-01", + amount: "1.0000", + validatorId: "VALIDATOR_ALPHA", + }, + { + id: "e2", + date: "2026-10-05", + amount: "2.0000", + validatorId: "VALIDATOR_GAMMA", + }, + { + id: "e3", + date: "2026-10-10", + amount: "3.0000", + validatorId: "VALIDATOR_DELTA", + }, + ]; + + it("filters events and daily rewards by start date", () => { + render( + , + ); + + const startInput = screen.getByLabelText("Start date"); + fireEvent.change(startInput, { target: { value: "2026-10-05" } }); + + expect(screen.queryByText("+1.0000 XLM")).not.toBeInTheDocument(); + expect(screen.getByText("+2.0000 XLM")).toBeInTheDocument(); + expect(screen.getByText("+3.0000 XLM")).toBeInTheDocument(); + expect(screen.getByText("5 XLM")).toBeInTheDocument(); + expect(screen.getByText("2 reward days")).toBeInTheDocument(); + }); + + it("filters events and daily rewards by end date", () => { + render( + , + ); + + const endInput = screen.getByLabelText("End date"); + fireEvent.change(endInput, { target: { value: "2026-10-05" } }); + + expect(screen.getByText("+1.0000 XLM")).toBeInTheDocument(); + expect(screen.getByText("+2.0000 XLM")).toBeInTheDocument(); + expect(screen.queryByText("+3.0000 XLM")).not.toBeInTheDocument(); + expect(screen.getByText("3 XLM")).toBeInTheDocument(); + }); + + it("filters by both start date and end date and clears filters", () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText("Start date"), { + target: { value: "2026-10-04" }, + }); + fireEvent.change(screen.getByLabelText("End date"), { + target: { value: "2026-10-06" }, + }); + + expect(screen.queryByText("+1.0000 XLM")).not.toBeInTheDocument(); + expect(screen.getByText("+2.0000 XLM")).toBeInTheDocument(); + expect(screen.queryByText("+3.0000 XLM")).not.toBeInTheDocument(); + + const clearBtn = screen.getByRole("button", { name: /clear date filters/i }); + fireEvent.click(clearBtn); + + expect(screen.getByText("+1.0000 XLM")).toBeInTheDocument(); + expect(screen.getByText("+2.0000 XLM")).toBeInTheDocument(); + expect(screen.getByText("+3.0000 XLM")).toBeInTheDocument(); + }); + + it("resets pagination to page 1 when date filter changes", () => { + const manyEvents: RewardEvent[] = [ + ...Array.from({ length: 12 }, (_, i) => ({ + id: `oct-${i}`, + date: "2026-10-10", + amount: "1.0000", + validatorId: "VALIDATOR_ALPHA", + })), + { + id: "sep-1", + date: "2026-09-01", + amount: "5.0000", + validatorId: "VALIDATOR_GAMMA", + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /next/i })); + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Start date"), { + target: { value: "2026-10-01" }, + }); + + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + }); +}); + +// ─── CSV export with historical fiat values ─────────────────────────────────── + +describe("RewardHistory — CSV export", () => { + const exportEvents: RewardEvent[] = [ + { + id: "r1", + date: "2026-10-15", + amount: "100.000000", + validatorId: "VALIDATOR_ALPHA", + priceUsd: 0.125, + usdValue: 12.5, + txHash: "txhash123", + }, + { + id: "r2", + date: "2026-10-14", + amount: "50.000000", + validatorId: "VALIDATOR_GAMMA", + priceUsd: 0.11, + usdValue: 5.5, + }, + ]; + + it("includes historical fiat value columns in generated CSV", () => { + const csv = generateRewardHistoryCsv(exportEvents, MOCK_VALIDATORS); + const lines = csv.split("\n"); + + expect(lines[0]).toBe( + "Date,Validator,Amount (XLM),XLM Price (USD),Estimated Value (USD),Transaction Hash", + ); + expect(lines[1]).toBe( + "2026-10-15,Alpha Staking,100.000000,0.1250,12.5000,txhash123", + ); + expect(lines[2]).toBe( + "2026-10-14,Gamma Validator,50.000000,0.1100,5.5000,", + ); + }); + + it("calls onExport callback with CSV containing historical USD estimates", () => { + const onExport = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /export csv/i })); + expect(onExport).toHaveBeenCalledTimes(1); + + const csv = onExport.mock.calls[0][0] as string; + expect(csv).toContain("XLM Price (USD),Estimated Value (USD)"); + expect(csv).toContain("0.1250,12.5000"); + expect(csv).toContain("0.1100,5.5000"); + }); + + it("triggers DOM file download when onExport is not provided", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /export csv/i })); + expect(mockCreateObjectURL).toHaveBeenCalledTimes(1); + expect(mockRevokeObjectURL).toHaveBeenCalledTimes(1); }); }); diff --git a/src/components/RewardHistory.tsx b/src/components/RewardHistory.tsx index 9e1ecee..31de817 100644 --- a/src/components/RewardHistory.tsx +++ b/src/components/RewardHistory.tsx @@ -1,18 +1,31 @@ /** * RewardHistory — displays earned rewards over the last 30 days. * - * Shows a hand-rolled SVG bar chart (matching the project's PieChart approach) - * and a tabular breakdown of individual reward events. + * Shows a hand-rolled SVG bar chart (matching the project's PieChart approach), + * date-range filtering, CSV export with historical fiat values, and a + * paginated tabular breakdown of individual reward events. */ +import { + ArrowLeft01Icon, + ArrowRight01Icon, + Download01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/Button"; import type { DailyReward, RewardEvent, Validator } from "@/lib/staking"; import { + estimateRewardUsdValue, formatXlm, REWARD_HISTORY_DAYS, totalRewardHistoryXlm, } from "@/lib/staking"; import { cn } from "@/lib/utils"; +const DEFAULT_PAGE_SIZE = 10; + // ─── Types ──────────────────────────────────────────────────────────────────── export interface RewardHistoryProps { @@ -21,6 +34,14 @@ export interface RewardHistoryProps { /** Raw reward events for the table */ events: RewardEvent[]; validators: Validator[]; + /** Number of rows per page (defaults to 10) */ + pageSize?: number; + /** Optional initial start date filter (YYYY-MM-DD) */ + startDate?: string; + /** Optional initial end date filter (YYYY-MM-DD) */ + endDate?: string; + /** Optional callback for CSV export. If provided, called instead of DOM download. */ + onExport?: (csv: string) => void; className?: string; } @@ -96,26 +117,164 @@ function truncateTxHash(hash: string): string { return `${hash.slice(0, 6)}…${hash.slice(-4)}`; } +function formatFiatUsd(value: number): string { + if (!Number.isFinite(value)) return "$0.00"; + const decimals = value > 0 && value < 0.01 ? 4 : 2; + return `$${value.toFixed(decimals)}`; +} + +function escapeCsv(value: string): string { + if (value.includes(",") || value.includes('"') || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export function generateRewardHistoryCsv( + events: RewardEvent[], + validators: Validator[], +): string { + const headers = [ + "Date", + "Validator", + "Amount (XLM)", + "XLM Price (USD)", + "Estimated Value (USD)", + "Transaction Hash", + ]; + const lines: string[] = [headers.join(",")]; + + for (const event of events) { + const vName = validatorName(validators, event.validatorId); + const amountXlm = parseFloat(event.amount); + const formattedAmount = Number.isFinite(amountXlm) + ? amountXlm.toFixed(6) + : "0.000000"; + const { priceUsd, usdValue } = estimateRewardUsdValue(event); + + lines.push( + [ + escapeCsv(event.date), + escapeCsv(vName), + formattedAmount, + priceUsd.toFixed(4), + usdValue.toFixed(4), + escapeCsv(event.txHash ?? ""), + ].join(","), + ); + } + + return lines.join("\n"); +} + +function downloadRewardCsv( + events: RewardEvent[], + validators: Validator[], + onExport?: (csv: string) => void, +): void { + const csv = generateRewardHistoryCsv(events, validators); + + if (onExport) { + onExport(csv); + return; + } + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `reward-history-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + // ─── Component ──────────────────────────────────────────────────────────────── export function RewardHistory({ dailyRewards, events, validators, + pageSize = DEFAULT_PAGE_SIZE, + startDate = "", + endDate = "", + onExport, className, }: RewardHistoryProps) { - const total = totalRewardHistoryXlm(dailyRewards); + const [dateFrom, setDateFrom] = useState(startDate); + const [dateTo, setDateTo] = useState(endDate); + const [page, setPage] = useState(1); + + const [prevStartDate, setPrevStartDate] = useState(startDate); + const [prevEndDate, setPrevEndDate] = useState(endDate); + + if (startDate !== prevStartDate) { + setPrevStartDate(startDate); + setDateFrom(startDate); + setPage(1); + } + + if (endDate !== prevEndDate) { + setPrevEndDate(endDate); + setDateTo(endDate); + setPage(1); + } + + const filteredDailyRewards = useMemo(() => { + return dailyRewards.filter((day) => { + const dayKey = day.date.slice(0, 10); + if (dateFrom && dayKey < dateFrom) return false; + if (dateTo && dayKey > dateTo) return false; + return true; + }); + }, [dailyRewards, dateFrom, dateTo]); + + const total = totalRewardHistoryXlm(filteredDailyRewards); + + // Filter and sort most recent events first + const filteredEvents = useMemo(() => { + return events + .filter((event) => { + const eventDateKey = event.date.slice(0, 10); + if (dateFrom && eventDateKey < dateFrom) return false; + if (dateTo && eventDateKey > dateTo) return false; + return true; + }) + .sort((a, b) => b.date.localeCompare(a.date)); + }, [events, dateFrom, dateTo]); + + const safePageSize = Math.max(1, pageSize); + const totalPages = Math.max(1, Math.ceil(filteredEvents.length / safePageSize)); + const currentPage = Math.min(page, totalPages); + const pagedEvents = filteredEvents.slice( + (currentPage - 1) * safePageSize, + currentPage * safePageSize, + ); - // Most recent events first, capped at 50 rows for display - const sortedEvents = [...events] - .sort((a, b) => b.date.localeCompare(a.date)) - .slice(0, 50); + const hasDateFilter = Boolean(dateFrom || dateTo); + + function handleDateFromChange(value: string) { + setDateFrom(value); + setPage(1); + } + + function handleDateToChange(value: string) { + setDateTo(value); + setPage(1); + } + + function handleClearFilters() { + setDateFrom(""); + setDateTo(""); + setPage(1); + } return (
{/* ── Summary header ──────────────────────────────────────────────────── */} -
+

Last {REWARD_HISTORY_DAYS} Days @@ -124,93 +283,223 @@ export function RewardHistory({ {formatXlm(total)}

- {dailyRewards.length > 0 && ( - - {dailyRewards.length} reward days - +
+ {filteredDailyRewards.length > 0 && ( + + {filteredDailyRewards.length} reward days + + )} + {events.length > 0 && ( + + )} +
+
+ + {/* ── Date filter controls ────────────────────────────────────────────── */} +
+
+ + handleDateFromChange(e.target.value)} + aria-label="Start date" + className={cn( + "h-8 rounded-lg border border-line bg-surface-2 px-2.5", + "text-[12px] text-ink", + "outline-none transition-colors focus:border-line-2 focus:ring-1 focus:ring-brand-dim", + )} + /> +
+
+ + handleDateToChange(e.target.value)} + aria-label="End date" + className={cn( + "h-8 rounded-lg border border-line bg-surface-2 px-2.5", + "text-[12px] text-ink", + "outline-none transition-colors focus:border-line-2 focus:ring-1 focus:ring-brand-dim", + )} + /> +
+ {hasDateFilter && ( + )}
{/* ── Bar chart ───────────────────────────────────────────────────────── */}
- +
- {dailyRewards[0]?.date ?? ""} + {filteredDailyRewards[0]?.date ?? ""} - {dailyRewards[dailyRewards.length - 1]?.date ?? ""} + {filteredDailyRewards[filteredDailyRewards.length - 1]?.date ?? ""}
{/* ── Event table ─────────────────────────────────────────────────────── */} - {sortedEvents.length > 0 ? ( -
- - - - - - - - - - - {sortedEvents.map((event) => ( - + Prev + + - - - - - ))} - -
- Date - - Validator - - Amount - 0 ? ( +
+
+ + + + + + + + + + + + {pagedEvents.map((event) => { + const { usdValue } = estimateRewardUsdValue(event); + return ( + + + + + + + + ); + })} + +
+ Date + + Validator + + Amount + + Est. USD + + Tx +
+ {formatEventDate(event.date)} + + {validatorName(validators, event.validatorId)} + + +{parseFloat(event.amount).toFixed(4)} XLM + + {formatFiatUsd(usdValue)} + + {event.txHash ? ( + + {truncateTxHash(event.txHash)} + + ) : ( + — + )} +
+
+ + {/* ── Pagination ──────────────────────────────────────────────────── */} + {totalPages > 1 && ( +
+ + Page {currentPage} of {totalPages} + +
+
- {formatEventDate(event.date)} - - {validatorName(validators, event.validatorId)} - - +{parseFloat(event.amount).toFixed(4)} XLM - - {event.txHash ? ( - - {truncateTxHash(event.txHash)} - - ) : ( - — - )} -
+ Next + + +
+
+ )}
) : (

- No reward history in the last {REWARD_HISTORY_DAYS} days. + {events.length > 0 && hasDateFilter + ? "No reward events match the selected date range." + : `No reward history in the last ${REWARD_HISTORY_DAYS} days.`}

)} diff --git a/src/components/index.ts b/src/components/index.ts index 6e7a128..2c92922 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -145,7 +145,7 @@ export type { DelegationRowProps } from "./DelegationRow"; export { DelegationRow } from "./DelegationRow"; export { RebalancerHistory } from "./RebalancerHistory"; export type { RewardHistoryProps } from "./RewardHistory"; -export { RewardHistory } from "./RewardHistory"; +export { generateRewardHistoryCsv, RewardHistory } from "./RewardHistory"; export type { RewardsPanelProps } from "./RewardsPanel"; export { RewardsPanel } from "./RewardsPanel"; export type { StakingDashboardProps } from "./StakingDashboard"; @@ -210,8 +210,11 @@ export type { export { aggregateDailyRewards, createDefaultFilter, + DEFAULT_XLM_PRICE_USD, DELEGATION_BASE_FEE_STROOPS, estimateDelegationFeeXlm, + estimateHistoricalXlmPriceUsd, + estimateRewardUsdValue, filterValidators, /** @alias formatPct from staking lib */ formatPct as formatStakingPct, diff --git a/src/lib/staking.test.ts b/src/lib/staking.test.ts index 760bbf0..ed5cef8 100644 --- a/src/lib/staking.test.ts +++ b/src/lib/staking.test.ts @@ -8,7 +8,10 @@ import { aggregateDailyRewards, computeUptimePct, createDefaultFilter, + DEFAULT_XLM_PRICE_USD, estimateDelegationFeeXlm, + estimateHistoricalXlmPriceUsd, + estimateRewardUsdValue, filterValidators, formatPct, formatXlm, @@ -264,6 +267,56 @@ describe("generateMockRewardHistory", () => { expect(new Date(e.date).getTime()).toBeGreaterThan(cutoff.getTime()); } }); + + it("includes estimated historical priceUsd and usdValue on generated events", () => { + const events = generateMockRewardHistory(); + for (const e of events) { + expect(e.priceUsd).toBeGreaterThan(0); + expect(e.usdValue).toBeGreaterThan(0); + } + }); +}); + +// ─── estimateHistoricalXlmPriceUsd & estimateRewardUsdValue ────────────────── + +describe("estimateHistoricalXlmPriceUsd & estimateRewardUsdValue", () => { + it("returns a deterministic price around DEFAULT_XLM_PRICE_USD", () => { + const p1 = estimateHistoricalXlmPriceUsd("2026-10-15"); + const p2 = estimateHistoricalXlmPriceUsd("2026-10-15"); + expect(p1).toBe(p2); + expect(p1).toBeGreaterThan(0.08); + expect(p1).toBeLessThan(0.15); + }); + + it("falls back to DEFAULT_XLM_PRICE_USD for invalid date strings", () => { + expect(estimateHistoricalXlmPriceUsd("not-a-date")).toBe( + DEFAULT_XLM_PRICE_USD, + ); + }); + + it("calculates usdValue from event amount and historical price when not explicitly set", () => { + const result = estimateRewardUsdValue({ + id: "r1", + date: "2026-10-15", + amount: "100", + validatorId: "VALIDATOR_ALPHA", + }); + expect(result.priceUsd).toBeGreaterThan(0); + expect(result.usdValue).toBeCloseTo(100 * result.priceUsd, 3); + }); + + it("preserves explicit priceUsd and usdValue when provided on the event", () => { + const result = estimateRewardUsdValue({ + id: "r2", + date: "2026-10-15", + amount: "50", + validatorId: "VALIDATOR_ALPHA", + priceUsd: 0.2, + usdValue: 10, + }); + expect(result.priceUsd).toBe(0.2); + expect(result.usdValue).toBe(10); + }); }); // ─── filterValidators ──────────────────────────────────────────────────────── diff --git a/src/lib/staking.ts b/src/lib/staking.ts index 8784d67..7e04cae 100644 --- a/src/lib/staking.ts +++ b/src/lib/staking.ts @@ -80,6 +80,10 @@ export interface RewardEvent { validatorId: string; /** Transaction hash for the payout */ txHash?: string; + /** Historical XLM price in USD at the time of distribution */ + priceUsd?: number; + /** Estimated USD value of the reward at the time of distribution */ + usdValue?: number; } /** Aggregated daily reward bucket for the history chart */ @@ -155,6 +159,9 @@ export const MIN_DELEGATION_XLM = 1; /** Number of days shown in the reward history */ export const REWARD_HISTORY_DAYS = 30; +/** Default baseline XLM price in USD for historical fiat estimation */ +export const DEFAULT_XLM_PRICE_USD = 0.11; + // ─── Pure utility functions ─────────────────────────────────────────────────── /** @@ -257,6 +264,39 @@ export function totalRewardHistoryXlm(history: DailyReward[]): number { return history.reduce((s, d) => s + d.totalXlm, 0); } +/** + * Deterministically estimate the historical XLM price in USD for a given date. + * Uses a mild sinusoidal curve around `DEFAULT_XLM_PRICE_USD` when no explicit + * price feed is provided. + */ +export function estimateHistoricalXlmPriceUsd(dateStr: string): number { + const time = new Date(dateStr).getTime(); + if (Number.isNaN(time)) return DEFAULT_XLM_PRICE_USD; + const dayIndex = Math.floor(time / 86_400_000); + const variance = Math.sin(dayIndex * 0.45) * 0.015; + return Number((DEFAULT_XLM_PRICE_USD + variance).toFixed(4)); +} + +/** + * Estimate the historical USD value of a reward event at the time of distribution. + */ +export function estimateRewardUsdValue(event: RewardEvent): { + priceUsd: number; + usdValue: number; +} { + const amount = parseFloat(event.amount); + const validAmount = Number.isFinite(amount) ? amount : 0; + const priceUsd = + event.priceUsd != null && Number.isFinite(event.priceUsd) + ? event.priceUsd + : estimateHistoricalXlmPriceUsd(event.date); + const usdValue = + event.usdValue != null && Number.isFinite(event.usdValue) + ? event.usdValue + : Number((validAmount * priceUsd).toFixed(4)); + return { priceUsd, usdValue }; +} + /** * Filter and sort validators based on a ValidatorFilter. */ @@ -470,11 +510,16 @@ export function generateMockRewardHistory(): RewardEvent[] { // Simulate small daily variance (±10%) const base = amounts[idx]; const jitter = base * 0.1 * (Math.sin(i * idx + 1) * 0.5 + 0.5); + const amount = (base + jitter).toFixed(6); + const priceUsd = estimateHistoricalXlmPriceUsd(dateStr); + const usdValue = Number((parseFloat(amount) * priceUsd).toFixed(4)); events.push({ id: `reward-${dateStr}-${vid}`, date: dateStr, - amount: (base + jitter).toFixed(6), + amount, validatorId: vid, + priceUsd, + usdValue, }); }); }