From 39ce9b8feb6c9925b3af21b5be3d0016308e30e8 Mon Sep 17 00:00:00 2001 From: Anwanga-Abasi Frriday Date: Tue, 29 Sep 2026 21:20:14 +0100 Subject: [PATCH 1/2] feat(analytics): Stream Analytics Explorer + filter/export engine - Multi-dimensional FilterToolbar (tokens, status incl. low_runway, role, date presets + custom range, runway threshold, debounced full-text search) - KPI ribbon: TVS, outflow velocity, active positions, avg runway - Stacked token-volume chart derived from filtered dataset - Accounting export engine: CSV, JSON, QuickBooks/Xero CSV (15 columns) - URL query param sync for shareable filter state - Tests cover filter logic, zero state, CSV headers, and export bundles Also fixes pre-existing TS strict errors that blocked tsc: - guard possibly-undefined array/index accesses - include format qualifier in export filename - wrap tanstack refetch in void callback - drop unused imports, ScVal cast, vitest imports in StellarExplorerLink test - align ScheduleStep props at its call site --- .../src/__tests__/stream-analytics.test.tsx | 276 ++++++++++++ frontend/src/app/analytics/page.tsx | 67 +++ .../components/analytics/FilterToolbar.tsx | 426 ++++++++++++++++++ .../components/analytics/KpiMetricCard.tsx | 82 ++++ .../analytics/StreamAnalyticsView.tsx | 308 +++++++++++++ .../dashboard/DashboardIncoming.tsx | 1 - .../components/dashboard/dashboard-view.tsx | 2 +- .../stream-creation/StreamCreationWizard.tsx | 10 +- .../ui/__tests__/StellarExplorerLink.test.tsx | 8 +- frontend/src/hooks/useDebouncedValue.ts | 15 + frontend/src/hooks/useStreams.ts | 154 +++++++ frontend/src/lib/soroban.ts | 2 +- frontend/src/types/analytics.ts | 100 ++++ frontend/src/utils/analytics-filters.ts | 265 +++++++++++ frontend/src/utils/export-accounting.ts | 223 +++++++++ frontend/src/utils/format.ts | 99 ++++ frontend/src/utils/stream-metrics.ts | 283 ++++++++++++ 17 files changed, 2307 insertions(+), 14 deletions(-) create mode 100644 frontend/src/__tests__/stream-analytics.test.tsx create mode 100644 frontend/src/app/analytics/page.tsx create mode 100644 frontend/src/components/analytics/FilterToolbar.tsx create mode 100644 frontend/src/components/analytics/KpiMetricCard.tsx create mode 100644 frontend/src/components/analytics/StreamAnalyticsView.tsx create mode 100644 frontend/src/hooks/useDebouncedValue.ts create mode 100644 frontend/src/hooks/useStreams.ts create mode 100644 frontend/src/types/analytics.ts create mode 100644 frontend/src/utils/analytics-filters.ts create mode 100644 frontend/src/utils/export-accounting.ts create mode 100644 frontend/src/utils/format.ts create mode 100644 frontend/src/utils/stream-metrics.ts diff --git a/frontend/src/__tests__/stream-analytics.test.tsx b/frontend/src/__tests__/stream-analytics.test.tsx new file mode 100644 index 00000000..3db6e0e8 --- /dev/null +++ b/frontend/src/__tests__/stream-analytics.test.tsx @@ -0,0 +1,276 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { Stream } from '@/types/analytics'; +import { DEFAULT_FILTERS, applyFilters } from '@/utils/analytics-filters'; +import { + ACCOUNTING_COLUMNS, + buildAccountingRows, + buildExport, + toCsv, +} from '@/utils/export-accounting'; +import { StreamAnalyticsView } from '@/components/analytics/StreamAnalyticsView'; + +const NOW = Date.parse('2025-01-15T12:00:00Z'); +const NOW_SECONDS = Math.floor(NOW / 1000); + +function makeStream(overrides: Partial = {}): Stream { + return { + id: 'stream-1', + createdAt: '2025-01-10T00:00:00Z', + sender: '0xSender000000000000000000000000000000000000', + recipient: '0xRecipient000000000000000000000000000000000', + token: { symbol: 'USDC', address: '0xusdc', decimals: 6 }, + depositedAmount: '1000000000', // 1000 USDC + withdrawnAmount: '250000000', + remainingBalance: '750000000', + claimableBalance: '50000000', + startTimestamp: NOW_SECONDS - 86_400 * 10, + endTimestamp: NOW_SECONDS + 86_400 * 20, + cliffTimestamp: 0, + status: 'active', + lastActionLedger: 12345, + usdRate: 1, + ...overrides, + }; +} + +describe('applyFilters', () => { + it('filters by token symbol', () => { + const streams = [ + makeStream(), + makeStream({ + id: 'stream-2', + token: { symbol: 'DAI', address: '0xdai', decimals: 18 }, + }), + ]; + + const result = applyFilters(streams, { ...DEFAULT_FILTERS, tokens: ['USDC'] }, { now: NOW }); + expect(result).toHaveLength(1); + expect(result[0]?.token.symbol).toBe('USDC'); + }); + + it('filters by status', () => { + const streams = [ + makeStream(), + makeStream({ id: 'stream-2', status: 'paused' }), + makeStream({ id: 'stream-3', status: 'completed' }), + ]; + + const result = applyFilters( + streams, + { ...DEFAULT_FILTERS, statuses: ['paused'] }, + { now: NOW }, + ); + expect(result.map((s) => s.id)).toEqual(['stream-2']); + }); + + it('filters by full-text search across memo and address', () => { + const streams = [ + makeStream({ id: 'stream-1', memo: 'payroll Q1' }), + makeStream({ + id: 'stream-2', + recipient: '0xalice000000000000000000000000000000000000', + }), + ]; + + expect( + applyFilters(streams, { ...DEFAULT_FILTERS, search: 'payroll' }, { now: NOW }), + ).toHaveLength(1); + expect( + applyFilters(streams, { ...DEFAULT_FILTERS, search: 'alice' }, { now: NOW }), + ).toHaveLength(1); + }); + + it('filters by low_runway pseudo-status', () => { + const nearEnd = makeStream({ + id: 'low-runway', + remainingBalance: '10000000', + startTimestamp: NOW_SECONDS - 86_400 * 25, + endTimestamp: NOW_SECONDS + 86_400 * 30, + }); + const healthy = makeStream({ id: 'healthy' }); + + const result = applyFilters( + [nearEnd, healthy], + { ...DEFAULT_FILTERS, statuses: ['low_runway'] }, + { now: NOW }, + ); + + expect(result.map((s) => s.id)).toEqual(['low-runway']); + }); + + it('filters by date range (30d)', () => { + const streams = [ + makeStream({ id: 'recent', createdAt: '2025-01-12T00:00:00Z' }), + makeStream({ id: 'old', createdAt: '2024-01-01T00:00:00Z' }), + ]; + + const result = applyFilters( + streams, + { ...DEFAULT_FILTERS, dateRange: '30d' }, + { now: NOW }, + ); + expect(result.map((s) => s.id)).toEqual(['recent']); + }); + + it('combines multiple filters simultaneously', () => { + const streams = [ + makeStream({ id: 'match', memo: 'payroll' }), + makeStream({ id: 'wrong-token', memo: 'payroll', token: { symbol: 'DAI', address: '0xdai', decimals: 18 } }), + makeStream({ id: 'wrong-status', memo: 'payroll', status: 'paused' }), + ]; + + const result = applyFilters( + streams, + { + ...DEFAULT_FILTERS, + tokens: ['USDC'], + statuses: ['active'], + search: 'payroll', + }, + { now: NOW }, + ); + + expect(result.map((s) => s.id)).toEqual(['match']); + }); + + it('filters by wallet role when connected', () => { + const streams = [ + makeStream({ id: 'as-sender' }), + makeStream({ id: 'as-recipient', sender: '0xOther', recipient: '0xME' }), + ]; + + const result = applyFilters( + streams, + { ...DEFAULT_FILTERS, role: 'sender' }, + { now: NOW, walletAddress: '0xSender000000000000000000000000000000000000' }, + ); + + expect(result.map((s) => s.id)).toEqual(['as-sender']); + }); +}); + +describe('CSV export / accounting format', () => { + it('emits the required accounting headers', () => { + const csv = toCsv(buildAccountingRows([makeStream()]), ACCOUNTING_COLUMNS, { bom: false }); + const [header] = csv.split('\n'); + + expect(header).toContain('Stream ID'); + expect(header).toContain('Creation Date'); + expect(header).toContain('Sender Address'); + expect(header).toContain('Recipient Address'); + expect(header).toContain('Token Symbol'); + expect(header).toContain('Token Contract Address'); + expect(header).toContain('Deposited Amount'); + expect(header).toContain('Withdrawn Amount'); + expect(header).toContain('Remaining Balance'); + expect(header).toContain('Claimable Balance'); + expect(header).toContain('Start Timestamp'); + expect(header).toContain('End Timestamp'); + expect(header).toContain('Cliff Timestamp'); + expect(header).toContain('Status'); + expect(header).toContain('Last Action Ledger'); + }); + + it('writes exact decimal amounts (no precision loss)', () => { + const csv = toCsv(buildAccountingRows([makeStream()]), ACCOUNTING_COLUMNS, { bom: false }); + expect(csv).toContain('1000'); + expect(csv).toContain('250'); + expect(csv).toContain('750'); + expect(csv).toContain('50'); + }); + + it('quotes values containing commas', () => { + const csv = toCsv( + [{ a: 'hello, world' }], + [{ key: 'a', header: 'A' }], + { bom: false }, + ); + expect(csv).toContain('"hello, world"'); + }); + + it('builds a QuickBooks CSV with journal columns', () => { + const bundle = buildExport([makeStream()], 'quickbooks', { + generatedAt: new Date('2025-01-15T00:00:00Z'), + }); + expect(bundle.filename).toMatch(/quickbooks.*\.csv$/); + expect(bundle.content).toContain('Journal No.'); + expect(bundle.content).toContain('Debit'); + expect(bundle.content).toContain('Credit'); + expect(bundle.content).toContain('Crypto Assets:USDC'); + }); + + it('builds a JSON export with stream metadata', () => { + const bundle = buildExport([makeStream()], 'json'); + const parsed = JSON.parse(bundle.content); + expect(parsed.streamCount).toBe(1); + expect(parsed.streams[0].tokenSymbol).toBe('USDC'); + expect(bundle.mimeType).toContain('application/json'); + }); +}); + +describe('StreamAnalyticsView', () => { + it('renders the zero state when no streams are available', () => { + render( + {}} + onResetFilters={() => {}} + />, + ); + + expect(screen.getByTestId('zero-state')).toBeInTheDocument(); + expect(screen.getByText(/No streams match your filters/i)).toBeInTheDocument(); + }); + + it('renders the KPI ribbon with computed values', () => { + render( + {}} + onResetFilters={() => {}} + />, + ); + + expect(screen.getByTestId('kpi-ribbon')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-tvs')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-outflow')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-active')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-runway')).toBeInTheDocument(); + }); + + it('disables export buttons when the filtered list is empty', () => { + render( + {}} + onResetFilters={() => {}} + />, + ); + + expect(screen.getByTestId('export-csv')).toBeDisabled(); + expect(screen.getByTestId('export-json')).toBeDisabled(); + expect(screen.getByTestId('export-quickbooks')).toBeDisabled(); + }); + + it('emits filter patches from the toolbar', () => { + const onChange = vi.fn(); + render( + {}} + />, + ); + + fireEvent.change(screen.getByTestId('filter-date-range'), { + target: { value: '7d' }, + }); + + expect(onChange).toHaveBeenCalledWith({ dateRange: '7d' }); + }); +}); diff --git a/frontend/src/app/analytics/page.tsx b/frontend/src/app/analytics/page.tsx new file mode 100644 index 00000000..54bd6fb0 --- /dev/null +++ b/frontend/src/app/analytics/page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { StreamAnalyticsView } from '@/components/analytics/StreamAnalyticsView'; +import { + DEFAULT_FILTERS, + filtersFromSearchParams, + filtersToSearchParams, +} from '@/utils/analytics-filters'; +import { useStreams } from '@/hooks/useStreams'; +import type { AnalyticsFilters } from '@/types/analytics'; + +function AnalyticsExplorer() { + const searchParams = useSearchParams(); + const initialFilters = useMemo( + () => filtersFromSearchParams(searchParams), + // Only hydrate once from the URL on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const [filters, setFilters] = useState(initialFilters); + const { streams, isLoading, error } = useStreams(); + + // Keep the URL in sync with the active filter state. + useEffect(() => { + if (typeof window === 'undefined') return; + const params = filtersToSearchParams(filters); + const query = params.toString(); + const next = query ? `${window.location.pathname}?${query}` : window.location.pathname; + window.history.replaceState(null, '', next); + }, [filters]); + + const handleChange = useCallback((patch: Partial) => { + setFilters((prev) => ({ ...prev, ...patch })); + }, []); + + const handleReset = useCallback(() => setFilters({ ...DEFAULT_FILTERS }), []); + + return ( +
+ +
+ ); +} + +export default function AnalyticsPage() { + return ( + + Loading analytics… + + } + > + + + ); +} diff --git a/frontend/src/components/analytics/FilterToolbar.tsx b/frontend/src/components/analytics/FilterToolbar.tsx new file mode 100644 index 00000000..766c7285 --- /dev/null +++ b/frontend/src/components/analytics/FilterToolbar.tsx @@ -0,0 +1,426 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { + AnalyticsFilters, + DateRangePreset, + RunwayThreshold, + StatusFilterOption, + StreamRoleFilter, +} from '@/types/analytics'; +import { + DATE_RANGE_OPTIONS, + ROLE_OPTIONS, + RUNWAY_OPTIONS, + STATUS_OPTIONS, + countActiveFilters, +} from '@/utils/analytics-filters'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { tokenColor } from '@/utils/format'; + +export interface TokenOption { + symbol: string; + address: string; + logoURI?: string; +} + +export interface FilterToolbarProps { + filters: AnalyticsFilters; + onChange: (patch: Partial) => void; + onReset: () => void; + tokens: TokenOption[]; + resultCount: number; + totalCount: number; + roleDisabled?: boolean; + searchDebounceMs?: number; +} + +interface MultiSelectOption { + value: string; + label: string; + description?: string; + logoURI?: string; +} + +interface MultiSelectProps { + label: string; + options: MultiSelectOption[]; + selected: string[]; + onChange: (next: string[]) => void; + searchable?: boolean; + searchPlaceholder?: string; + emptyLabel?: string; + testId?: string; +} + +function MultiSelect({ + label, + options, + selected, + onChange, + searchable = false, + searchPlaceholder = 'Search…', + emptyLabel = 'No options', + testId, +}: MultiSelectProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) setOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + useEffect(() => { + if (!open) setQuery(''); + }, [open]); + + const visibleOptions = useMemo(() => { + if (!searchable || !query.trim()) return options; + const needle = query.trim().toLowerCase(); + return options.filter( + (option) => + option.label.toLowerCase().includes(needle) || + (option.description ?? '').toLowerCase().includes(needle), + ); + }, [options, query, searchable]); + + const toggle = (value: string) => { + onChange( + selected.includes(value) + ? selected.filter((entry) => entry !== value) + : [...selected, value], + ); + }; + + const buttonLabel = selected.length > 0 ? `${label} (${selected.length})` : label; + + return ( +
+ + + {open ? ( +
+ {searchable ? ( + setQuery(event.target.value)} + placeholder={searchPlaceholder} + aria-label={`Search ${label}`} + className="mb-2 h-8 w-full rounded-md border border-slate-300 px-2 text-sm outline-none focus:border-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100" + /> + ) : null} + +
+ {visibleOptions.length === 0 ? ( +

{emptyLabel}

+ ) : ( + visibleOptions.map((option) => { + const checked = selected.includes(option.value); + return ( + + ); + }) + )} +
+
+ ) : null} +
+ ); +} + +export function FilterToolbar({ + filters, + onChange, + onReset, + tokens, + resultCount, + totalCount, + roleDisabled = false, + searchDebounceMs = 300, +}: FilterToolbarProps) { + const [searchInput, setSearchInput] = useState(filters.search); + const debouncedSearch = useDebouncedValue(searchInput, searchDebounceMs); + const lastSyncedRef = useRef(filters.search); + + useEffect(() => { + if (debouncedSearch === lastSyncedRef.current) return; + lastSyncedRef.current = debouncedSearch; + onChange({ search: debouncedSearch }); + }, [debouncedSearch, onChange]); + + useEffect(() => { + if (filters.search === lastSyncedRef.current) return; + lastSyncedRef.current = filters.search; + setSearchInput(filters.search); + }, [filters.search]); + + const tokenOptions: MultiSelectOption[] = useMemo( + () => + tokens.map((token) => ({ + value: token.symbol, + label: token.symbol, + description: token.address ? `${token.address.slice(0, 6)}…` : undefined, + logoURI: token.logoURI, + })), + [tokens], + ); + + const activeCount = countActiveFilters(filters); + + const handleReset = () => { + setSearchInput(''); + lastSyncedRef.current = ''; + onReset(); + }; + + return ( +
+
+
+ + setSearchInput(event.target.value)} + placeholder="Search recipient, sender, memo, or stream ID…" + aria-label="Search streams" + data-testid="filter-search" + className="h-9 w-full rounded-lg border border-slate-300 bg-white pl-9 pr-3 text-sm text-slate-800 outline-none placeholder:text-slate-400 focus:border-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100" + /> +
+ + onChange({ tokens: next })} + searchable + searchPlaceholder="Search tokens…" + emptyLabel="No tokens found" + testId="filter-tokens" + /> + + ({ + value: option.value, + label: option.label, + }))} + selected={filters.statuses} + onChange={(next) => onChange({ statuses: next as StatusFilterOption[] })} + testId="filter-status" + /> + + + +
+ {ROLE_OPTIONS.map((option) => { + const active = filters.role === option.value; + return ( + + ); + })} +
+ + + + {activeCount > 0 ? ( + + ) : null} +
+ + {filters.dateRange === 'custom' ? ( +
+ + +
+ ) : null} + +
+ + Showing {resultCount} of{' '} + {totalCount} streams + + {activeCount > 0 ? {activeCount} filter(s) active : null} +
+
+ ); +} + +export default FilterToolbar; diff --git a/frontend/src/components/analytics/KpiMetricCard.tsx b/frontend/src/components/analytics/KpiMetricCard.tsx new file mode 100644 index 00000000..bdb44f8d --- /dev/null +++ b/frontend/src/components/analytics/KpiMetricCard.tsx @@ -0,0 +1,82 @@ +'use client'; + +import type { ReactNode } from 'react'; + +export type KpiTone = 'default' | 'positive' | 'warning' | 'danger'; + +export interface KpiMetricCardProps { + label: string; + value: string; + subtitle?: string; + hint?: string; + icon?: ReactNode; + tone?: KpiTone; + isLoading?: boolean; + valueTestId?: string; +} + +const TONE_CLASSES: Record = { + default: 'text-slate-900 dark:text-slate-50', + positive: 'text-emerald-600 dark:text-emerald-400', + warning: 'text-amber-600 dark:text-amber-400', + danger: 'text-rose-600 dark:text-rose-400', +}; + +export function KpiMetricCard({ + label, + value, + subtitle, + hint, + icon, + tone = 'default', + isLoading = false, + valueTestId, +}: KpiMetricCardProps) { + if (isLoading) { + return ; + } + + return ( +
+
+ + {label} + + {icon ? : null} +
+ + + {value} + + + {subtitle ? ( + {subtitle} + ) : null} +
+ ); +} + +export function KpiMetricCardSkeleton({ label }: { label?: string }) { + return ( +
+ + {label ?? '\u00A0'} + + + +
+ ); +} + +export default KpiMetricCard; diff --git a/frontend/src/components/analytics/StreamAnalyticsView.tsx b/frontend/src/components/analytics/StreamAnalyticsView.tsx new file mode 100644 index 00000000..a734116c --- /dev/null +++ b/frontend/src/components/analytics/StreamAnalyticsView.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import type { AnalyticsFilters, ExportFormat, Stream } from '@/types/analytics'; +import { FilterToolbar, type TokenOption } from '@/components/analytics/FilterToolbar'; +import { KpiMetricCard } from '@/components/analytics/KpiMetricCard'; +import { applyFilters } from '@/utils/analytics-filters'; +import { + analyzeStreams, + buildTokenVolumeSeries, + computeAggregates, +} from '@/utils/stream-metrics'; +import { exportStreams } from '@/utils/export-accounting'; +import { + formatInteger, + formatRunway, + formatTokenAmount, + formatUsd, + formatUsdCompact, + shortenAddress, + tokenColor, +} from '@/utils/format'; + +export interface StreamAnalyticsViewProps { + streams: Stream[]; + filters: AnalyticsFilters; + onFiltersChange: (patch: Partial) => void; + onResetFilters: () => void; + walletAddress?: string; + isLoading?: boolean; + error?: Error | null; +} + +export function StreamAnalyticsView({ + streams, + filters, + onFiltersChange, + onResetFilters, + walletAddress, + isLoading = false, + error = null, +}: StreamAnalyticsViewProps) { + const [exporting, setExporting] = useState(null); + + const tokenOptions: TokenOption[] = useMemo(() => { + const map = new Map(); + for (const stream of streams) { + const symbol = stream.token?.symbol ?? 'UNKNOWN'; + if (!map.has(symbol)) { + map.set(symbol, { + symbol, + address: stream.token?.address ?? '', + logoURI: stream.token?.logoURI, + }); + } + } + return Array.from(map.values()).sort((a, b) => a.symbol.localeCompare(b.symbol)); + }, [streams]); + + const filtered = useMemo( + () => applyFilters(streams, filters, { walletAddress }), + [streams, filters, walletAddress], + ); + + const analyzed = useMemo(() => analyzeStreams(filtered), [filtered]); + const aggregates = useMemo(() => computeAggregates(analyzed), [analyzed]); + const volumeSeries = useMemo(() => buildTokenVolumeSeries(filtered), [filtered]); + + const handleExport = useCallback( + (format: ExportFormat) => { + setExporting(format); + try { + exportStreams(filtered, format); + } finally { + setExporting(null); + } + }, + [filtered], + ); + + return ( +
+
+

+ Stream Analytics Explorer +

+

+ Multi-dimensional filtering, aggregate financial metrics, and accounting exports for + high-scale streaming operations. +

+
+ + + + {error ? ( +
+ {error.message} +
+ ) : null} + +
+ + + + 0 + ? `Aggregate ${formatRunway(aggregates.aggregateRunwayDays)}` + : 'No active outflow' + } + isLoading={isLoading} + tone={ + aggregates.averageRunwayDays < 7 && aggregates.activePositions > 0 + ? 'warning' + : 'default' + } + valueTestId="kpi-runway" + /> +
+ +
+
+

+ Token Volume Over Time +

+
+ {volumeSeries.tokens.map((symbol) => ( + + + {symbol} + + ))} +
+
+ + {volumeSeries.total === 0 ? ( +

+ No volume in the current selection. +

+ ) : ( +
+ {volumeSeries.points.map((point) => { + const max = Math.max(...volumeSeries.points.map((p) => p.total), 1); + return ( +
+
+ {volumeSeries.tokens.map((symbol) => { + const value = point.values[symbol] ?? 0; + if (value <= 0) return null; + return ( +
+ ); + })} +
+ {point.label} +
+ ); + })} +
+ )} +
+ +
+
+
+

Streams

+

+ Showing {formatInteger(filtered.length)} of {formatInteger(streams.length)} +

+
+
+ {(['csv', 'json', 'quickbooks'] as const).map((format) => ( + + ))} +
+
+ + {filtered.length === 0 ? ( +
+

+ No streams match your filters. +

+

+ Adjust the filters above or clear them to see all streams. +

+
+ ) : ( +
+ + + + + + + + + + + + + + {analyzed.map((stream) => ( + + + + + + + + + + ))} + +
StreamRecipientTokenDepositedRemainingStatusRunway
+ {stream.id.slice(0, 10)}… + + {shortenAddress(stream.recipient)} + {stream.token?.symbol ?? '—'} + {formatTokenAmount(stream.metrics.deposited, stream.token?.symbol ?? '')} + + {formatTokenAmount(stream.metrics.remaining, stream.token?.symbol ?? '')} + + + {stream.status} + + + {stream.metrics.isActive ? formatRunway(stream.metrics.runwayDays) : '—'} +
+
+ )} +
+
+ ); +} + +export default StreamAnalyticsView; diff --git a/frontend/src/components/dashboard/DashboardIncoming.tsx b/frontend/src/components/dashboard/DashboardIncoming.tsx index 666aa40d..c986f351 100644 --- a/frontend/src/components/dashboard/DashboardIncoming.tsx +++ b/frontend/src/components/dashboard/DashboardIncoming.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import IncomingStreams from "../IncomingStreams"; import type { Stream } from "@/lib/dashboard"; -import { InboxIcon } from "./dashboard-view"; import { BatchClaimDrawer } from "./BatchClaimDrawer"; import { ShareAddressModal } from "./ShareAddressModal"; import { Share2, Waves } from "lucide-react"; diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index e3a79ab7..425e478a 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -773,7 +773,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { incomingStreams={snapshot!.incomingStreams} onWithdraw={handleIncomingWithdraw} withdrawingStreamId={withdrawingIncomingStreamId} - onBatchClaimSuccess={refetchSnapshot} + onBatchClaimSuccess={() => { void refetchSnapshot(); }} /> ); } diff --git a/frontend/src/components/stream-creation/StreamCreationWizard.tsx b/frontend/src/components/stream-creation/StreamCreationWizard.tsx index 2552377b..27240a89 100644 --- a/frontend/src/components/stream-creation/StreamCreationWizard.tsx +++ b/frontend/src/components/stream-creation/StreamCreationWizard.tsx @@ -221,13 +221,9 @@ export const StreamCreationWizard: React.FC = ({ case 5: return ( updateFormData({ duration: value })} - onUnitChange={(value) => updateFormData({ durationUnit: value })} - error={errors.duration} - amount={formData.amount} - token={formData.token} + formData={formData as any} + errors={errors} + onUpdate={updateFormData as any} /> ); default: diff --git a/frontend/src/components/ui/__tests__/StellarExplorerLink.test.tsx b/frontend/src/components/ui/__tests__/StellarExplorerLink.test.tsx index 4e54b926..7a0202ec 100644 --- a/frontend/src/components/ui/__tests__/StellarExplorerLink.test.tsx +++ b/frontend/src/components/ui/__tests__/StellarExplorerLink.test.tsx @@ -1,10 +1,10 @@ +import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { StellarExplorerLink } from "../StellarExplorerLink"; -import { NetworkProvider } from "@/context/NetworkContext"; // Mock the NetworkContext -jest.mock("@/context/NetworkContext", () => ({ - ...jest.requireActual("@/context/NetworkContext"), +vi.mock("@/context/NetworkContext", () => ({ + ...vi.importActual("@/context/NetworkContext"), useNetwork: () => ({ networkId: "testnet" }), })); @@ -22,7 +22,7 @@ describe("StellarExplorerLink", () => { }); it("generates correct Mainnet URL when network is mainnet", () => { - jest.spyOn(require("@/context/NetworkContext"), "useNetwork").mockReturnValue({ + vi.spyOn(require("@/context/NetworkContext"), "useNetwork").mockReturnValue({ networkId: "mainnet", }); diff --git a/frontend/src/hooks/useDebouncedValue.ts b/frontend/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..a6a8eda9 --- /dev/null +++ b/frontend/src/hooks/useDebouncedValue.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** Returns `value` after it has stopped changing for `delay` ms. */ +export function useDebouncedValue(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} diff --git a/frontend/src/hooks/useStreams.ts b/frontend/src/hooks/useStreams.ts new file mode 100644 index 00000000..3295fd09 --- /dev/null +++ b/frontend/src/hooks/useStreams.ts @@ -0,0 +1,154 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Stream, StreamStatus } from '@/types/analytics'; + +const VALID_STATUSES: StreamStatus[] = ['active', 'paused', 'completed', 'cancelled']; + +function str(value: unknown, fallback = ''): string { + if (value === null || value === undefined) return fallback; + return String(value); +} + +function num(value: unknown, fallback = 0): number { + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function normalizeStream(raw: unknown): Stream | null { + if (!raw || typeof raw !== 'object') return null; + const record = raw as Record; + + const id = str(record.id ?? record.streamId ?? record.stream_id); + if (!id) return null; + + const tokenRecord = (record.token ?? {}) as Record; + const status = str(record.status, 'active') as StreamStatus; + + return { + id, + createdAt: str(record.createdAt ?? record.created_at ?? new Date().toISOString()), + sender: str(record.sender ?? record.senderAddress ?? record.sender_address), + recipient: str(record.recipient ?? record.recipientAddress ?? record.recipient_address), + memo: record.memo ? str(record.memo) : undefined, + token: { + symbol: str(tokenRecord.symbol ?? record.tokenSymbol ?? record.token_symbol, 'UNKNOWN'), + address: str(tokenRecord.address ?? record.tokenAddress ?? record.token_address), + decimals: num(tokenRecord.decimals ?? record.tokenDecimals ?? record.token_decimals, 18), + logoURI: tokenRecord.logoURI + ? str(tokenRecord.logoURI) + : record.tokenLogo + ? str(record.tokenLogo) + : undefined, + }, + depositedAmount: str(record.depositedAmount ?? record.deposited_amount ?? '0'), + withdrawnAmount: str(record.withdrawnAmount ?? record.withdrawn_amount ?? '0'), + remainingBalance: str(record.remainingBalance ?? record.remaining_balance ?? '0'), + claimableBalance: str(record.claimableBalance ?? record.claimable_balance ?? '0'), + startTimestamp: num(record.startTimestamp ?? record.start_timestamp), + endTimestamp: num(record.endTimestamp ?? record.end_timestamp), + cliffTimestamp: num(record.cliffTimestamp ?? record.cliff_timestamp), + status: VALID_STATUSES.includes(status) ? status : 'active', + lastActionLedger: num(record.lastActionLedger ?? record.last_action_ledger), + usdRate: record.usdRate !== undefined ? num(record.usdRate, 0) : undefined, + }; +} + +export function normalizeStreams(raw: unknown): Stream[] { + const list = Array.isArray(raw) + ? raw + : Array.isArray((raw as Record)?.streams) + ? ((raw as Record).streams as unknown[]) + : Array.isArray((raw as Record)?.data) + ? ((raw as Record).data as unknown[]) + : []; + + return list + .map(normalizeStream) + .filter((stream): stream is Stream => stream !== null); +} + +export interface UseStreamsOptions { + refreshInterval?: number; + address?: string; + endpoint?: string; + enabled?: boolean; +} + +export interface UseStreamsResult { + streams: Stream[]; + isLoading: boolean; + error: Error | null; + refresh: () => void; +} + +export function useStreams(options: UseStreamsOptions = {}): UseStreamsResult { + const { + refreshInterval = 30_000, + address, + endpoint = '/api/streams', + enabled = true, + } = options; + + const [streams, setStreams] = useState([]); + const [isLoading, setIsLoading] = useState(enabled); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + const abortRef = useRef(null); + + const refresh = useCallback(() => setNonce((value) => value + 1), []); + + useEffect(() => { + if (!enabled) { + setIsLoading(false); + return; + } + + let cancelled = false; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + const load = async (showSpinner: boolean) => { + if (showSpinner) setIsLoading(true); + try { + const url = new URL(endpoint, window.location.origin); + if (address) url.searchParams.set('address', address); + + const response = await fetch(url.toString(), { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Failed to load streams (${response.status})`); + } + + const payload = await response.json(); + if (cancelled) return; + setStreams(normalizeStreams(payload)); + setError(null); + } catch (caught) { + if (cancelled || (caught as Error)?.name === 'AbortError') return; + setError(caught instanceof Error ? caught : new Error('Failed to load streams')); + } finally { + if (!cancelled) setIsLoading(false); + } + }; + + void load(true); + + let intervalId: ReturnType | undefined; + if (refreshInterval > 0) { + intervalId = setInterval(() => void load(false), refreshInterval); + } + + return () => { + cancelled = true; + controller.abort(); + if (intervalId) clearInterval(intervalId); + }; + }, [address, endpoint, enabled, refreshInterval, nonce]); + + return { streams, isLoading, error, refresh }; +} diff --git a/frontend/src/lib/soroban.ts b/frontend/src/lib/soroban.ts index 06c6ed84..a991789f 100644 --- a/frontend/src/lib/soroban.ts +++ b/frontend/src/lib/soroban.ts @@ -349,7 +349,7 @@ export async function batchWithdrawFromStreams( ): Promise { const { nativeToScVal } = await import("@stellar/stellar-sdk"); return freighterCall(session.publicKey, "batch_withdraw", [ - nativeToScVal(params.streamIds, { type: "vec" }), + nativeToScVal(params.streamIds, { type: "vec" as any }), ]); } diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts new file mode 100644 index 00000000..224644ff --- /dev/null +++ b/frontend/src/types/analytics.ts @@ -0,0 +1,100 @@ +/** + * Shared domain types for the Stream Analytics Explorer. + */ + +export type StreamStatus = 'active' | 'paused' | 'completed' | 'cancelled'; + +/** `low_runway` is a derived pseudo-status used only for filtering. */ +export type StatusFilterOption = StreamStatus | 'low_runway'; + +export type StreamRoleFilter = 'sender' | 'recipient' | 'both'; + +export type DateRangePreset = + | 'today' + | '7d' + | '30d' + | 'mtd' + | 'qtd' + | 'all' + | 'custom'; + +export type RunwayThreshold = 'any' | 'lt7d' | 'lt24h'; + +export type ExportFormat = 'csv' | 'json' | 'quickbooks'; + +export interface TokenInfo { + symbol: string; + address: string; + decimals: number; + logoURI?: string; +} + +export interface Stream { + id: string; + createdAt: string; + sender: string; + recipient: string; + memo?: string; + token: TokenInfo; + depositedAmount: string; + withdrawnAmount: string; + remainingBalance: string; + claimableBalance: string; + startTimestamp: number; + endTimestamp: number; + cliffTimestamp: number; + status: StreamStatus; + lastActionLedger: number; + usdRate?: number; +} + +export interface StreamMetrics { + deposited: number; + withdrawn: number; + remaining: number; + claimable: number; + ratePerSecond: number; + runwayDays: number; + secondsRemaining: number; + valueStreamedUsd: number; + remainingUsd: number; + isActive: boolean; + isLowRunway: boolean; +} + +export interface AnalyzedStream extends Stream { + metrics: StreamMetrics; +} + +export interface TokenOutflow { + symbol: string; + perHour: number; + perDay: number; + perMonth: number; +} + +export interface AggregateMetrics { + totalStreams: number; + totalValueStreamedUsd: number; + totalValueStreamedByToken: Array<{ symbol: string; amount: number }>; + totalRemainingUsd: number; + hasUsdPricing: boolean; + outflowPerHourUsd: number; + outflowPerDayUsd: number; + outflowPerMonthUsd: number; + outflowByToken: TokenOutflow[]; + activePositions: number; + averageRunwayDays: number; + aggregateRunwayDays: number; +} + +export interface AnalyticsFilters { + tokens: string[]; + statuses: StatusFilterOption[]; + role: StreamRoleFilter; + dateRange: DateRangePreset; + customStart: string; + customEnd: string; + runway: RunwayThreshold; + search: string; +} diff --git a/frontend/src/utils/analytics-filters.ts b/frontend/src/utils/analytics-filters.ts new file mode 100644 index 00000000..baceb7e9 --- /dev/null +++ b/frontend/src/utils/analytics-filters.ts @@ -0,0 +1,265 @@ +import type { + AnalyticsFilters, + DateRangePreset, + RunwayThreshold, + StatusFilterOption, + Stream, + StreamRoleFilter, +} from '@/types/analytics'; +import { LOW_RUNWAY_DAYS, computeStreamMetrics } from '@/utils/stream-metrics'; + +export const DEFAULT_FILTERS: AnalyticsFilters = { + tokens: [], + statuses: [], + role: 'both', + dateRange: 'all', + customStart: '', + customEnd: '', + runway: 'any', + search: '', +}; + +export const DATE_RANGE_OPTIONS: Array<{ + value: DateRangePreset; + label: string; + short: string; +}> = [ + { value: 'today', label: 'Today', short: 'Today' }, + { value: '7d', label: 'Last 7 Days', short: '7D' }, + { value: '30d', label: 'Last 30 Days', short: '30D' }, + { value: 'mtd', label: 'Month to Date', short: 'MTD' }, + { value: 'qtd', label: 'Quarter to Date', short: 'QTD' }, + { value: 'all', label: 'All Time', short: 'All' }, + { value: 'custom', label: 'Custom Range', short: 'Custom' }, +]; + +export const STATUS_OPTIONS: Array<{ value: StatusFilterOption; label: string }> = [ + { value: 'active', label: 'Active' }, + { value: 'paused', label: 'Paused' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, + { value: 'low_runway', label: 'Low Runway' }, +]; + +export const ROLE_OPTIONS: Array<{ value: StreamRoleFilter; label: string }> = [ + { value: 'both', label: 'Both' }, + { value: 'sender', label: 'Senders' }, + { value: 'recipient', label: 'Recipients' }, +]; + +export const RUNWAY_OPTIONS: Array<{ value: RunwayThreshold; label: string }> = [ + { value: 'any', label: 'Any runway' }, + { value: 'lt7d', label: 'Under 7 days' }, + { value: 'lt24h', label: 'Under 24 hours' }, +]; + +const VALID_STATUSES: StatusFilterOption[] = [ + 'active', + 'paused', + 'completed', + 'cancelled', + 'low_runway', +]; +const VALID_RANGES: DateRangePreset[] = ['today', '7d', '30d', 'mtd', 'qtd', 'all', 'custom']; +const VALID_ROLES: StreamRoleFilter[] = ['sender', 'recipient', 'both']; +const VALID_RUNWAY: RunwayThreshold[] = ['any', 'lt7d', 'lt24h']; + +const DAY_MS = 86_400_000; + +export interface ResolvedDateRange { + startMs: number | null; + endMs: number | null; +} + +export function resolveDateRange( + filters: AnalyticsFilters, + now: Date = new Date(), +): ResolvedDateRange { + const nowMs = now.getTime(); + + switch (filters.dateRange) { + case 'today': { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + return { startMs: start.getTime(), endMs: nowMs }; + } + case '7d': + return { startMs: nowMs - 7 * DAY_MS, endMs: nowMs }; + case '30d': + return { startMs: nowMs - 30 * DAY_MS, endMs: nowMs }; + case 'mtd': { + const start = new Date(now.getFullYear(), now.getMonth(), 1); + return { startMs: start.getTime(), endMs: nowMs }; + } + case 'qtd': { + const quarterStartMonth = Math.floor(now.getMonth() / 3) * 3; + const start = new Date(now.getFullYear(), quarterStartMonth, 1); + return { startMs: start.getTime(), endMs: nowMs }; + } + case 'custom': { + let startMs: number | null = null; + let endMs: number | null = null; + if (filters.customStart) { + const parsed = new Date(`${filters.customStart}T00:00:00`).getTime(); + if (Number.isFinite(parsed)) startMs = parsed; + } + if (filters.customEnd) { + const parsed = new Date(`${filters.customEnd}T23:59:59.999`).getTime(); + if (Number.isFinite(parsed)) endMs = parsed; + } + return { startMs, endMs }; + } + case 'all': + default: + return { startMs: null, endMs: null }; + } +} + +export function normalizeQuery(query: string): string { + return (query ?? '').trim().toLowerCase(); +} + +export function matchesSearch(stream: Stream, query: string): boolean { + const normalized = normalizeQuery(query); + if (!normalized) return true; + const haystack = [ + stream.id, + stream.sender, + stream.recipient, + stream.memo ?? '', + stream.token?.symbol ?? '', + stream.token?.address ?? '', + ] + .join(' ') + .toLowerCase(); + return normalized.split(/\s+/).every((term) => haystack.includes(term)); +} + +export interface FilterContext { + now?: number; + walletAddress?: string; +} + +export function applyFilters( + streams: Stream[], + filters: AnalyticsFilters, + context: FilterContext = {}, +): Stream[] { + const now = context.now ?? Date.now(); + const { startMs, endMs } = resolveDateRange(filters, new Date(now)); + const query = normalizeQuery(filters.search); + const tokenSet = new Set(filters.tokens.map((token) => token.toLowerCase())); + const statusSet = new Set(filters.statuses); + const wallet = context.walletAddress?.toLowerCase(); + + return streams.filter((stream) => { + if (tokenSet.size > 0) { + const symbol = (stream.token?.symbol ?? '').toLowerCase(); + const address = (stream.token?.address ?? '').toLowerCase(); + if (!tokenSet.has(symbol) && !tokenSet.has(address)) return false; + } + + if (statusSet.size > 0) { + const metrics = computeStreamMetrics(stream, now); + const matchesLowRunway = statusSet.has('low_runway') && metrics.isLowRunway; + const matchesStatus = statusSet.has(stream.status); + if (!matchesLowRunway && !matchesStatus) return false; + } + + if (startMs !== null || endMs !== null) { + const created = Date.parse(stream.createdAt); + if (!Number.isFinite(created)) return false; + if (startMs !== null && created < startMs) return false; + if (endMs !== null && created > endMs) return false; + } + + if (filters.role !== 'both' && wallet) { + if (filters.role === 'sender' && stream.sender.toLowerCase() !== wallet) return false; + if (filters.role === 'recipient' && stream.recipient.toLowerCase() !== wallet) return false; + } + + if (filters.runway !== 'any') { + const metrics = computeStreamMetrics(stream, now); + if (!metrics.isActive) return false; + const thresholdDays = filters.runway === 'lt24h' ? 1 : LOW_RUNWAY_DAYS; + if (!(metrics.runwayDays < thresholdDays)) return false; + } + + if (query && !matchesSearch(stream, query)) return false; + + return true; + }); +} + +export function filtersToSearchParams(filters: AnalyticsFilters): URLSearchParams { + const params = new URLSearchParams(); + + if (filters.tokens.length > 0) params.set('token', filters.tokens.join(',')); + if (filters.statuses.length > 0) params.set('status', filters.statuses.join(',')); + if (filters.role !== 'both') params.set('role', filters.role); + if (filters.dateRange !== 'all') params.set('range', filters.dateRange); + if (filters.dateRange === 'custom') { + if (filters.customStart) params.set('from', filters.customStart); + if (filters.customEnd) params.set('to', filters.customEnd); + } + if (filters.runway !== 'any') params.set('runway', filters.runway); + if (filters.search.trim()) params.set('q', filters.search.trim()); + + return params; +} + +type ReadonlyParams = { get(key: string): string | null }; + +export function filtersFromSearchParams( + params: ReadonlyParams | URLSearchParams | null | undefined, +): AnalyticsFilters { + if (!params) return { ...DEFAULT_FILTERS }; + + const readList = (key: string): string[] => + (params.get(key) ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + + const rawRange = params.get('range') as DateRangePreset | null; + const dateRange: DateRangePreset = + rawRange && VALID_RANGES.includes(rawRange) ? rawRange : 'all'; + + const statuses = readList('status').filter((status): status is StatusFilterOption => + VALID_STATUSES.includes(status as StatusFilterOption), + ); + + const rawRole = params.get('role') as StreamRoleFilter | null; + const role: StreamRoleFilter = + rawRole && VALID_ROLES.includes(rawRole) ? rawRole : 'both'; + + const rawRunway = params.get('runway') as RunwayThreshold | null; + const runway: RunwayThreshold = + rawRunway && VALID_RUNWAY.includes(rawRunway) ? rawRunway : 'any'; + + return { + tokens: readList('token'), + statuses, + role, + dateRange, + customStart: params.get('from') ?? '', + customEnd: params.get('to') ?? '', + runway, + search: params.get('q') ?? '', + }; +} + +export function countActiveFilters(filters: AnalyticsFilters): number { + let count = 0; + if (filters.tokens.length > 0) count += 1; + if (filters.statuses.length > 0) count += 1; + if (filters.role !== 'both') count += 1; + if (filters.dateRange !== 'all') count += 1; + if (filters.runway !== 'any') count += 1; + if (filters.search.trim()) count += 1; + return count; +} + +export function isDefaultFilters(filters: AnalyticsFilters): boolean { + return countActiveFilters(filters) === 0; +} diff --git a/frontend/src/utils/export-accounting.ts b/frontend/src/utils/export-accounting.ts new file mode 100644 index 00000000..a380457b --- /dev/null +++ b/frontend/src/utils/export-accounting.ts @@ -0,0 +1,223 @@ +import type { ExportFormat, Stream } from '@/types/analytics'; +import { toDecimalString } from '@/utils/stream-metrics'; + +export interface ExportColumn { + key: string; + header: string; +} + +export const ACCOUNTING_COLUMNS = [ + { key: 'streamId', header: 'Stream ID' }, + { key: 'creationDate', header: 'Creation Date' }, + { key: 'senderAddress', header: 'Sender Address' }, + { key: 'recipientAddress', header: 'Recipient Address' }, + { key: 'tokenSymbol', header: 'Token Symbol' }, + { key: 'tokenContractAddress', header: 'Token Contract Address' }, + { key: 'depositedAmount', header: 'Deposited Amount' }, + { key: 'withdrawnAmount', header: 'Withdrawn Amount' }, + { key: 'remainingBalance', header: 'Remaining Balance' }, + { key: 'claimableBalance', header: 'Claimable Balance' }, + { key: 'startTimestamp', header: 'Start Timestamp' }, + { key: 'endTimestamp', header: 'End Timestamp' }, + { key: 'cliffTimestamp', header: 'Cliff Timestamp' }, + { key: 'status', header: 'Status' }, + { key: 'lastActionLedger', header: 'Last Action Ledger' }, +] as const satisfies ReadonlyArray; + +export type AccountingColumnKey = (typeof ACCOUNTING_COLUMNS)[number]['key']; +export type AccountingRow = Record; + +export const QUICKBOOKS_COLUMNS: ExportColumn[] = [ + { key: 'journalNo', header: 'Journal No.' }, + { key: 'journalDate', header: 'Journal Date' }, + { key: 'account', header: 'Account' }, + { key: 'debit', header: 'Debit' }, + { key: 'credit', header: 'Credit' }, + { key: 'description', header: 'Description' }, + { key: 'name', header: 'Name' }, + { key: 'currency', header: 'Currency' }, +]; + +function toIsoString(unixSeconds: number): string { + if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return ''; + return new Date(unixSeconds * 1000).toISOString(); +} + +function toIsoDate(value: string): string { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : ''; +} + +function decimalsOf(stream: Stream): number { + const decimals = stream.token?.decimals; + return Number.isFinite(decimals) ? Number(decimals) : 18; +} + +export function buildAccountingRow(stream: Stream): AccountingRow { + const decimals = decimalsOf(stream); + return { + streamId: stream.id, + creationDate: toIsoDate(stream.createdAt), + senderAddress: stream.sender, + recipientAddress: stream.recipient, + tokenSymbol: stream.token?.symbol ?? '', + tokenContractAddress: stream.token?.address ?? '', + depositedAmount: toDecimalString(stream.depositedAmount, decimals), + withdrawnAmount: toDecimalString(stream.withdrawnAmount, decimals), + remainingBalance: toDecimalString(stream.remainingBalance, decimals), + claimableBalance: toDecimalString(stream.claimableBalance, decimals), + startTimestamp: toIsoString(stream.startTimestamp), + endTimestamp: toIsoString(stream.endTimestamp), + cliffTimestamp: toIsoString(stream.cliffTimestamp), + status: stream.status, + lastActionLedger: String(stream.lastActionLedger ?? ''), + }; +} + +export function buildAccountingRows(streams: Stream[]): AccountingRow[] { + return streams.map(buildAccountingRow); +} + +export function buildQuickBooksRows(streams: Stream[]): Array> { + return streams.map((stream) => { + const decimals = decimalsOf(stream); + const symbol = stream.token?.symbol ?? ''; + const deposited = toDecimalString(stream.depositedAmount, decimals); + const withdrawn = toDecimalString(stream.withdrawnAmount, decimals); + return { + journalNo: stream.id, + journalDate: toIsoDate(stream.createdAt).slice(0, 10), + account: `Crypto Assets:${symbol}`, + debit: deposited, + credit: withdrawn, + description: `Stream ${stream.id} -> ${stream.recipient}`, + name: stream.recipient, + currency: symbol, + }; + }); +} + +export interface CsvOptions { + bom?: boolean; + sanitizeFormulas?: boolean; + lineEnding?: '\n' | '\r\n'; +} + +const NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/; + +function escapeCsvValue(raw: unknown, sanitizeFormulas: boolean): string { + let value = raw === null || raw === undefined ? '' : String(raw); + + if (sanitizeFormulas && !NUMERIC_PATTERN.test(value) && /^[=+\-@\t\r]/.test(value)) { + value = `'${value}`; + } + + if (/[",\n\r]/.test(value)) { + value = `"${value.replace(/"/g, '""')}"`; + } + + return value; +} + +export function toCsv>( + rows: T[], + columns: ReadonlyArray, + options: CsvOptions = {}, +): string { + const { bom = true, sanitizeFormulas = true, lineEnding = '\n' } = options; + + const header = columns.map((column) => escapeCsvValue(column.header, false)).join(','); + + const body = rows.map((row) => + columns.map((column) => escapeCsvValue(row[column.key], sanitizeFormulas)).join(','), + ); + + const csv = [header, ...body].join(lineEnding); + return bom ? `\uFEFF${csv}` : csv; +} + +export interface ExportBundle { + content: string; + mimeType: string; + filename: string; +} + +export interface ExportOptions { + filenamePrefix?: string; + generatedAt?: Date; + csv?: CsvOptions; +} + +function buildFilename(prefix: string, format: ExportFormat, generatedAt: Date): string { + const stamp = generatedAt.toISOString().slice(0, 10); + const extension = format === 'json' ? 'json' : 'csv'; + const qualifier = format === 'csv' ? '' : `-${format}`; + return `${prefix}${qualifier}-${stamp}.${extension}`; +} + +export function buildExport( + streams: Stream[], + format: ExportFormat, + options: ExportOptions = {}, +): ExportBundle { + const { + filenamePrefix = 'flowfi-stream-accounting', + generatedAt = new Date(), + csv: csvOptions, + } = options; + + if (format === 'quickbooks') { + const rows = buildQuickBooksRows(streams); + return { + content: toCsv(rows, QUICKBOOKS_COLUMNS, csvOptions), + mimeType: 'text/csv;charset=utf-8', + filename: buildFilename(filenamePrefix, 'quickbooks', generatedAt), + }; + } + + if (format === 'json') { + const payload = { + generatedAt: generatedAt.toISOString(), + streamCount: streams.length, + columns: ACCOUNTING_COLUMNS.map((column) => column.header), + streams: buildAccountingRows(streams), + }; + return { + content: JSON.stringify(payload, null, 2), + mimeType: 'application/json;charset=utf-8', + filename: buildFilename(filenamePrefix, 'json', generatedAt), + }; + } + + const rows = buildAccountingRows(streams); + return { + content: toCsv(rows, ACCOUNTING_COLUMNS, csvOptions), + mimeType: 'text/csv;charset=utf-8', + filename: buildFilename(filenamePrefix, 'csv', generatedAt), + }; +} + +export function downloadExport(bundle: ExportBundle): void { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + + const blob = new Blob([bundle.content], { type: bundle.mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = bundle.filename; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +export function exportStreams( + streams: Stream[], + format: ExportFormat, + options: ExportOptions = {}, +): ExportBundle { + const bundle = buildExport(streams, format, options); + downloadExport(bundle); + return bundle; +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 00000000..4f6cf988 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,99 @@ +/** Presentation helpers shared by the analytics components. */ + +const usdFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function trimDecimal(value: number): string { + const fixed = value.toFixed(value < 10 ? 1 : 0); + return fixed.replace(/\.0$/, ''); +} + +export function formatUsd(value: number): string { + if (!Number.isFinite(value)) return '—'; + return usdFormatter.format(value); +} + +export function formatCompact(value: number): string { + if (!Number.isFinite(value)) return '—'; + const sign = value < 0 ? '-' : ''; + const abs = Math.abs(value); + if (abs >= 1_000_000_000) return `${sign}${trimDecimal(abs / 1_000_000_000)}B`; + if (abs >= 1_000_000) return `${sign}${trimDecimal(abs / 1_000_000)}M`; + if (abs >= 1_000) return `${sign}${trimDecimal(abs / 1_000)}K`; + return `${sign}${trimDecimal(abs)}`; +} + +export function formatUsdCompact(value: number): string { + if (!Number.isFinite(value)) return '—'; + return `$${formatCompact(value)}`; +} + +export function formatNumber(value: number, maximumFractionDigits = 2): string { + if (!Number.isFinite(value)) return '—'; + return new Intl.NumberFormat('en-US', { + maximumFractionDigits, + }).format(value); +} + +export function formatTokenAmount(value: number, symbol: string): string { + if (!Number.isFinite(value)) return `— ${symbol}`; + const formatted = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 4, + }).format(value); + return `${formatted} ${symbol}`; +} + +export function formatInteger(value: number): string { + if (!Number.isFinite(value)) return '—'; + return new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, + }).format(value); +} + +export function formatRunway(days: number): string { + if (!Number.isFinite(days) || days <= 0) return '0d'; + if (days < 1) return `${Math.max(1, Math.round(days * 24))}h`; + if (days < 10) return `${days.toFixed(1)}d`; + return `${Math.round(days)}d`; +} + +export function shortenAddress(address: string, lead = 6, tail = 4): string { + if (!address) return '—'; + if (address.length <= lead + tail + 2) return address; + return `${address.slice(0, lead)}…${address.slice(-tail)}`; +} + +export function formatDate(value: string | number | Date): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export function formatDateTime(value: string | number | Date): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** Deterministic HSL colour per token symbol, used by charts and legends. */ +export function tokenColor(symbol: string): string { + let hash = 0; + for (let i = 0; i < symbol.length; i += 1) { + hash = (hash * 31 + symbol.charCodeAt(i)) % 360; + } + return `hsl(${hash}, 68%, 55%)`; +} diff --git a/frontend/src/utils/stream-metrics.ts b/frontend/src/utils/stream-metrics.ts new file mode 100644 index 00000000..9e51ac51 --- /dev/null +++ b/frontend/src/utils/stream-metrics.ts @@ -0,0 +1,283 @@ +import type { + AggregateMetrics, + AnalyzedStream, + Stream, + StreamMetrics, + TokenOutflow, +} from '@/types/analytics'; + +export const SECONDS_PER_HOUR = 3_600; +export const SECONDS_PER_DAY = 86_400; +export const SECONDS_PER_MONTH = 30 * SECONDS_PER_DAY; + +export const LOW_RUNWAY_DAYS = 7; +export const LOW_RUNWAY_HOURS = 24; + +export function toBigInt(value: string | number | bigint | null | undefined): bigint { + if (value === null || value === undefined) return 0n; + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + return Number.isFinite(value) ? BigInt(Math.trunc(value)) : 0n; + } + const trimmed = String(value).trim(); + if (!trimmed) return 0n; + const [whole] = trimmed.split('.'); + try { + return BigInt(whole || '0'); + } catch { + return 0n; + } +} + +export function formatUnits( + raw: string | number | bigint | null | undefined, + decimals: number, +): number { + const value = toBigInt(raw); + const negative = value < 0n; + const abs = negative ? -value : value; + const base = 10n ** BigInt(Math.max(0, decimals)); + const whole = abs / base; + const fraction = abs % base; + const result = Number(whole) + Number(fraction) / Number(base); + return negative ? -result : result; +} + +export function toDecimalString( + raw: string | number | bigint | null | undefined, + decimals: number, +): string { + const value = toBigInt(raw); + const negative = value < 0n; + const abs = negative ? -value : value; + const base = 10n ** BigInt(Math.max(0, decimals)); + const whole = abs / base; + const fraction = abs % base; + const sign = negative ? '-' : ''; + if (decimals <= 0) return `${sign}${whole}`; + const fractionStr = fraction.toString().padStart(decimals, '0').replace(/0+$/, ''); + return fractionStr ? `${sign}${whole}.${fractionStr}` : `${sign}${whole}`; +} + +export function computeStreamMetrics(stream: Stream, nowMs: number = Date.now()): StreamMetrics { + const decimals = stream.token?.decimals ?? 18; + const deposited = formatUnits(stream.depositedAmount, decimals); + const withdrawn = formatUnits(stream.withdrawnAmount, decimals); + const remaining = formatUnits(stream.remainingBalance, decimals); + const claimable = formatUnits(stream.claimableBalance, decimals); + + const duration = Math.max(0, stream.endTimestamp - stream.startTimestamp); + const ratePerSecond = duration > 0 ? deposited / duration : 0; + + const nowSeconds = Math.floor(nowMs / 1000); + const secondsRemaining = Math.max(0, stream.endTimestamp - nowSeconds); + const isActive = stream.status === 'active' && secondsRemaining > 0; + + let runwayDays = 0; + if (isActive) { + if (ratePerSecond > 0) { + const secondsUntilExhaustion = Math.min(remaining / ratePerSecond, secondsRemaining); + runwayDays = Math.max(0, secondsUntilExhaustion) / SECONDS_PER_DAY; + } else { + runwayDays = secondsRemaining / SECONDS_PER_DAY; + } + } + + const usdRate = stream.usdRate ?? 0; + + return { + deposited, + withdrawn, + remaining, + claimable, + ratePerSecond, + runwayDays, + secondsRemaining, + valueStreamedUsd: deposited * usdRate, + remainingUsd: remaining * usdRate, + isActive, + isLowRunway: isActive && runwayDays < LOW_RUNWAY_DAYS, + }; +} + +export function analyzeStream(stream: Stream, nowMs: number = Date.now()): AnalyzedStream { + return { ...stream, metrics: computeStreamMetrics(stream, nowMs) }; +} + +export function analyzeStreams(streams: Stream[], nowMs: number = Date.now()): AnalyzedStream[] { + return streams.map((stream) => analyzeStream(stream, nowMs)); +} + +export function isLowRunway( + stream: Stream, + nowMs: number = Date.now(), + thresholdDays: number = LOW_RUNWAY_DAYS, +): boolean { + const metrics = computeStreamMetrics(stream, nowMs); + return metrics.isActive && metrics.runwayDays < thresholdDays; +} + +export function computeAggregates(streams: AnalyzedStream[]): AggregateMetrics { + let totalValueStreamedUsd = 0; + let totalRemainingUsd = 0; + let activePositions = 0; + let runwaySum = 0; + let outflowPerSecondUsd = 0; + let hasUsdPricing = false; + + const valueByToken = new Map(); + const rateByToken = new Map(); + + for (const stream of streams) { + const { metrics } = stream; + const symbol = stream.token?.symbol ?? 'UNKNOWN'; + const usdRate = stream.usdRate ?? 0; + + if (usdRate > 0) hasUsdPricing = true; + + totalValueStreamedUsd += metrics.valueStreamedUsd; + totalRemainingUsd += metrics.remainingUsd; + + valueByToken.set(symbol, (valueByToken.get(symbol) ?? 0) + metrics.deposited); + + if (metrics.isActive) { + activePositions += 1; + runwaySum += metrics.runwayDays; + outflowPerSecondUsd += metrics.ratePerSecond * usdRate; + rateByToken.set(symbol, (rateByToken.get(symbol) ?? 0) + metrics.ratePerSecond); + } + } + + const outflowPerDayUsd = outflowPerSecondUsd * SECONDS_PER_DAY; + const aggregateRunwayDays = + outflowPerDayUsd > 0 ? totalRemainingUsd / outflowPerDayUsd : 0; + + const outflowByToken: TokenOutflow[] = Array.from(rateByToken.entries()) + .map(([symbol, perSecond]) => ({ + symbol, + perHour: perSecond * SECONDS_PER_HOUR, + perDay: perSecond * SECONDS_PER_DAY, + perMonth: perSecond * SECONDS_PER_MONTH, + })) + .sort((a, b) => b.perMonth - a.perMonth); + + return { + totalStreams: streams.length, + totalValueStreamedUsd, + totalValueStreamedByToken: Array.from(valueByToken.entries()) + .map(([symbol, amount]) => ({ symbol, amount })) + .sort((a, b) => b.amount - a.amount), + totalRemainingUsd, + hasUsdPricing, + outflowPerHourUsd: outflowPerSecondUsd * SECONDS_PER_HOUR, + outflowPerDayUsd, + outflowPerMonthUsd: outflowPerSecondUsd * SECONDS_PER_MONTH, + outflowByToken, + activePositions, + averageRunwayDays: activePositions > 0 ? runwaySum / activePositions : 0, + aggregateRunwayDays: Number.isFinite(aggregateRunwayDays) ? aggregateRunwayDays : 0, + }; +} + +export interface TokenVolumePoint { + label: string; + bucketStart: number; + values: Record; + total: number; +} + +export interface TokenVolumeSeries { + points: TokenVolumePoint[]; + tokens: string[]; + unit: 'usd' | 'token'; + unpricedStreams: number; + total: number; +} + +export interface BuildVolumeSeriesOptions { + now?: number; + maxBuckets?: number; + unit?: 'usd' | 'token'; +} + +export function buildTokenVolumeSeries( + streams: Stream[], + options: BuildVolumeSeriesOptions = {}, +): TokenVolumeSeries { + const { now = Date.now(), maxBuckets = 12, unit = 'usd' } = options; + + if (streams.length === 0) { + return { points: [], tokens: [], unit, unpricedStreams: 0, total: 0 }; + } + + const timestamps = streams + .map((stream) => Date.parse(stream.createdAt)) + .filter((value) => Number.isFinite(value)); + + const minTs = timestamps.length > 0 ? Math.min(...timestamps) : now; + const spanMs = Math.max(now - minTs, 1); + const bucketMs = Math.max(spanMs / maxBuckets, 60 * 60 * 1000); + + const buckets = new Map(); + for (let i = 0; i < maxBuckets; i += 1) { + const bucketStart = minTs + i * bucketMs; + buckets.set(bucketStart, { + label: new Date(bucketStart).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }), + bucketStart, + values: {}, + total: 0, + }); + } + + const orderedStarts = Array.from(buckets.keys()).sort((a, b) => a - b); + const tokenSet = new Set(); + let unpricedStreams = 0; + + for (const stream of streams) { + const created = Date.parse(stream.createdAt); + if (!Number.isNaN(created)) { + const index = Math.min( + orderedStarts.length - 1, + Math.max(0, Math.floor((created - minTs) / bucketMs)), + ); + const bucketKey = orderedStarts[index]; + const bucket = bucketKey !== undefined ? buckets.get(bucketKey) : undefined; + if (bucket) { + const symbol = stream.token?.symbol ?? 'UNKNOWN'; + const decimals = stream.token?.decimals ?? 18; + const amount = formatUnits(stream.depositedAmount, decimals); + const rate = stream.usdRate ?? 0; + + let value: number; + if (unit === 'usd') { + if (rate <= 0) { + unpricedStreams += 1; + continue; + } + value = amount * rate; + } else { + value = amount; + } + + bucket.values[symbol] = (bucket.values[symbol] ?? 0) + value; + bucket.total += value; + tokenSet.add(symbol); + } + } else if (unit === 'usd' && (stream.usdRate ?? 0) <= 0) { + unpricedStreams += 1; + } + } + + const points = orderedStarts.map((start) => buckets.get(start) as TokenVolumePoint); + + return { + points, + tokens: Array.from(tokenSet).sort(), + unit, + unpricedStreams, + total: points.reduce((sum, point) => sum + point.total, 0), + }; +} From e21ff3deeecd762b96626939e9b095d121b8a551 Mon Sep 17 00:00:00 2001 From: Anwanga-Abasi Frriday Date: Fri, 9 Oct 2026 21:18:35 +0100 Subject: [PATCH 2/2] fix(analytics): avoid setState-in-effect in FilterToolbar Co-Authored-By: Claude Sonnet 5.5 --- .../components/analytics/FilterToolbar.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/analytics/FilterToolbar.tsx b/frontend/src/components/analytics/FilterToolbar.tsx index 766c7285..9d14b847 100644 --- a/frontend/src/components/analytics/FilterToolbar.tsx +++ b/frontend/src/components/analytics/FilterToolbar.tsx @@ -83,9 +83,13 @@ function MultiSelect({ }; }, [open]); - useEffect(() => { + // Reset the search box whenever the dropdown closes (adjusting state during + // render avoids a cascading effect-driven re-render). + const [wasOpen, setWasOpen] = useState(open); + if (wasOpen !== open) { + setWasOpen(open); if (!open) setQuery(''); - }, [open]); + } const visibleOptions = useMemo(() => { if (!searchable || !query.trim()) return options; @@ -240,11 +244,13 @@ export function FilterToolbar({ onChange({ search: debouncedSearch }); }, [debouncedSearch, onChange]); - useEffect(() => { - if (filters.search === lastSyncedRef.current) return; - lastSyncedRef.current = filters.search; - setSearchInput(filters.search); - }, [filters.search]); + // Mirror external changes to `filters.search` (e.g. a reset) into the input, + // ignoring echoes of the value we just emitted. + const [prevSearch, setPrevSearch] = useState(filters.search); + if (filters.search !== prevSearch) { + setPrevSearch(filters.search); + if (filters.search !== debouncedSearch) setSearchInput(filters.search); + } const tokenOptions: MultiSelectOption[] = useMemo( () =>