diff --git a/pr.md b/pr.md new file mode 100644 index 0000000..ec2b2ed --- /dev/null +++ b/pr.md @@ -0,0 +1,47 @@ +# Fix: InvoiceFilterBar Date Range Picker, OfflineBanner SW Integration, LPYieldComparison Historical Data, VoteProgressBar Animation + +Closes #363, #364, #365, #366 + +## Summary + +This PR implements four features across the governance, LP, invoice, and offline UX areas. + +### #366 — InvoiceFilterBar Date Range Picker + +- Added a **Due Date / Funded Date** toggle so users can filter invoices by the date field that matters for their context. +- Added **quick preset buttons** — Today, This Week, This Month — that apply and toggle a date range in one click. +- Added an inline **Clear date** button that appears only when a date range is active. +- Added `min`/`max` constraints on the custom date inputs to prevent invalid ranges. +- Extended `useInvoiceFilters` with a `dateType` field (`'due' | 'funded'`) serialised to URL search params; `applyInvoiceFilters` now filters by `invoice.funded_at` when `dateType === 'funded'`. + +### #365 — OfflineBanner Service Worker Integration + +- Detects whether the service worker is registered and active via `navigator.serviceWorker.ready` and the `controllerchange` event. +- Displays an **"Offline cache active"** indicator (with ShieldCheck icon) when the SW is running, so users know cached invoices and portfolio data are available offline. +- Displays **"No offline cache"** when the SW is unavailable, setting clear expectations. +- On reconnection, triggers `SyncManager.register('sync-queued-requests')` (where supported) and posts a `SYNC_ON_RECONNECT` message to the active SW worker, in addition to the existing React Query resume/refetch. +- Added `role="alert"` and `aria-live="assertive"` for screen-reader announcement of the offline state. + +### #364 — LPYieldComparison Historical Data + +- Added a **7D / 30D / 90D range selector** that drives `buildYieldTimeSeries`; updated `YieldRange` type to include `7`. +- Replaced the single-token line chart with a **comparative multi-token chart** showing USDC, EURC, and XLM as separate lines with a Legend — the selected token renders at full opacity while others are dimmed. +- Added an **Export CSV** button that downloads `yield-comparison-.csv` with date, per-token yield, and total columns. + +### #363 — VoteProgressBar Animation + +- Animation is now **suppressed on initial render** using `useRef` + `useEffect`; bars fill instantly on mount and only animate on subsequent vote data changes. +- Changed easing to **`ease-in-out`** on all bars (500 ms in compact mode, 700 ms in full mode). +- Added a **color transition at the 50 % threshold**: the For bar and label shift from `emerald-500` to `emerald-600` when votes for exceed 50 %, providing a clear winning-state signal. + +## Test plan + +- [ ] Open the invoice list, expand Filters, and verify the Date Range section shows Due Date / Funded Date toggle, preset buttons, and custom inputs. +- [ ] Click "Today", "This Week", "This Month" — confirm dates populate correctly and clicking again clears them. +- [ ] Switch to "Funded Date" and confirm invoices filter by `funded_at`. +- [ ] Go offline in DevTools → confirm the OfflineBanner appears with the SW cache status message; go online → confirm the sync toast fires and the banner dismisses. +- [ ] Open the LP Yield Comparison, switch between 7D / 30D / 90D, confirm the chart updates. +- [ ] Confirm all three token trend lines render with the selected token highlighted. +- [ ] Click Export CSV and verify the downloaded file contains the correct columns and data. +- [ ] Open a governance proposal, observe the vote bars appear instantly without animation on load. +- [ ] Update vote counts (or simulate via Storybook) and verify the 500/700 ms ease-in-out animation fires and the For bar color changes when it crosses 50 %. diff --git a/src/components/InvoiceFilterBar.tsx b/src/components/InvoiceFilterBar.tsx index bcfacc8..e0b9d80 100644 --- a/src/components/InvoiceFilterBar.tsx +++ b/src/components/InvoiceFilterBar.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { INVOICE_STATUSES, + type DateFilterType, type InvoiceFilters, type InvoiceStatus, } from "@/hooks/useInvoiceFilters"; @@ -17,6 +18,37 @@ type InvoiceFilterBarProps = { const TOKEN_OPTIONS = ["USDC", "EURC", "XLM"] as const; +const DATE_TYPE_LABELS: Record = { + due: "Due Date", + funded: "Funded Date", +}; + +function toDateStr(date: Date): string { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, "0"); + const d = String(date.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function getPresetRange(preset: "today" | "week" | "month"): { start: string; end: string } { + const now = new Date(); + const todayStr = toDateStr(now); + + if (preset === "today") { + return { start: todayStr, end: todayStr }; + } + + if (preset === "week") { + const weekStart = new Date(now); + weekStart.setUTCDate(now.getUTCDate() - now.getUTCDay()); + return { start: toDateStr(weekStart), end: todayStr }; + } + + // month + const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + return { start: toDateStr(monthStart), end: todayStr }; +} + function handleStatusKeyDown(e: React.KeyboardEvent) { if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) return; e.preventDefault(); @@ -43,9 +75,20 @@ export default function InvoiceFilterBar({ const containerClass = className ? `space-y-3 ${className}` : "space-y-3"; + const hasDateFilter = Boolean(filters.startDate || filters.endDate); + + function applyPreset(preset: "today" | "week" | "month") { + const { start, end } = getPresetRange(preset); + onFiltersChange((current) => ({ ...current, startDate: start, endDate: end })); + } + + function clearDateFilter() { + onFiltersChange((current) => ({ ...current, startDate: "", endDate: "" })); + } + return (
- {/* Screen reader live region — announces filter state changes */} + {/* Screen reader live region */}
{activeFilterCount > 0 ? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied` @@ -164,12 +207,73 @@ export default function InvoiceFilterBar({
-
-

Due Date

+ {/* Date range picker with type selector and quick presets */} +
+
+

+ Date Range +

+ {hasDateFilter && ( + + )} +
+ + {/* Date type selector */} +
+ {(["due", "funded"] as DateFilterType[]).map((type) => ( + + ))} +
+ + {/* Quick presets */} +
+ {(["today", "week", "month"] as const).map((preset) => { + const label = preset === "today" ? "Today" : preset === "week" ? "This Week" : "This Month"; + const { start, end } = getPresetRange(preset); + const isActive = filters.startDate === start && filters.endDate === end; + return ( + + ); + })} +
+ + {/* Custom date inputs */}
onFiltersChange((current) => ({ ...current, startDate: event.target.value })) } @@ -177,7 +281,9 @@ export default function InvoiceFilterBar({ /> onFiltersChange((current) => ({ ...current, endDate: event.target.value })) } diff --git a/src/components/LPYieldComparison.tsx b/src/components/LPYieldComparison.tsx index 0cf50f2..8209879 100644 --- a/src/components/LPYieldComparison.tsx +++ b/src/components/LPYieldComparison.tsx @@ -8,6 +8,7 @@ import { YAxis, CartesianGrid, Tooltip, + Legend, ResponsiveContainer, } from "recharts"; import type { Invoice } from "@/utils/soroban"; @@ -16,7 +17,7 @@ import { buildTokenYieldComparison, formatPremiumBps, } from "@/utils/lp-yield-comparison"; -import { buildYieldTimeSeries } from "@/utils/yield-timeseries"; +import { buildYieldTimeSeries, type YieldRange } from "@/utils/yield-timeseries"; import { useApprovedTokens } from "@/hooks/useApprovedTokens"; import { useBalances } from "@/hooks/useBalances"; import { RiskLevel } from "@/utils/risk"; @@ -31,6 +32,18 @@ interface LPYieldComparisonProps { const TOKENS = ["USDC", "EURC", "XLM"] as const; type ComparisonToken = (typeof TOKENS)[number]; +const RANGE_OPTIONS: { label: string; value: YieldRange }[] = [ + { label: "7D", value: 7 }, + { label: "30D", value: 30 }, + { label: "90D", value: 90 }, +]; + +const TOKEN_COLORS: Record = { + USDC: "#6366f1", + EURC: "#06b6d4", + XLM: "#8b5cf6", +}; + function getTokenRiskProfile(symbol: string): RiskLevel { switch (symbol.toUpperCase()) { case "USDC": @@ -44,12 +57,6 @@ function getTokenRiskProfile(symbol: string): RiskLevel { } } -const TOKEN_COLORS: Record = { - USDC: "#6366f1", - EURC: "#06b6d4", - XLM: "#8b5cf6", -}; - function AssetRiskBadge({ risk }: { risk: RiskLevel }) { const BADGE_STYLES: Record = { Low: { pill: "bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400", dot: "bg-green-500" }, @@ -68,6 +75,25 @@ function AssetRiskBadge({ risk }: { risk: RiskLevel }) { ); } +function exportToCSV( + data: { date: string; isoDate: string; USDC: number; EURC: number; XLM: number }[], + rangeLabel: string, +) { + const header = "Date,USDC Yield,EURC Yield,XLM Yield,Total\n"; + const rows = data.map((row) => { + const total = (row.USDC + row.EURC + row.XLM).toFixed(6); + return `${row.isoDate},${row.USDC.toFixed(6)},${row.EURC.toFixed(6)},${row.XLM.toFixed(6)},${total}`; + }); + const csv = header + rows.join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `yield-comparison-${rangeLabel.toLowerCase()}.csv`; + link.click(); + URL.revokeObjectURL(url); +} + export default function LPYieldComparison({ invoices, lpAddress, @@ -75,6 +101,7 @@ export default function LPYieldComparison({ }: LPYieldComparisonProps) { const [tBillYieldPct, setTBillYieldPct] = useState(null); const [selectedToken, setSelectedToken] = useState("USDC"); + const [selectedRange, setSelectedRange] = useState(90); const { tokens, isLoading: tokensLoading } = useApprovedTokens(); const { balances, isLoading: balancesLoading } = useBalances(tokens); @@ -97,25 +124,28 @@ export default function LPYieldComparison({ const selectedRow = rows.find((r) => r.symbol === selectedToken); const timeseries = useMemo(() => { - return buildYieldTimeSeries(invoices, lpAddress, 90); - }, [invoices, lpAddress]); + return buildYieldTimeSeries(invoices, lpAddress, selectedRange); + }, [invoices, lpAddress, selectedRange]); + // All tokens on one chart for comparative view const chartData = useMemo(() => { return timeseries.map((d) => ({ date: d.date, - yield: d[selectedToken] ?? 0, + isoDate: d.isoDate, + USDC: d.USDC, + EURC: d.EURC, + XLM: d.XLM, })); - }, [timeseries, selectedToken]); + }, [timeseries]); - // Compute available liquidity for the selected token const selectedTokenMetadata = tokens.find( (t) => t.symbol.toUpperCase() === selectedToken ); - - const rawBalance = selectedTokenMetadata - ? balances.get(selectedTokenMetadata.contractId) ?? 0n + + const rawBalance = selectedTokenMetadata + ? balances.get(selectedTokenMetadata.contractId) ?? 0n : 0n; - + const formattedLiquidity = selectedTokenMetadata ? formatTokenAmount(rawBalance, selectedTokenMetadata) : "0.00"; @@ -131,6 +161,9 @@ export default function LPYieldComparison({ ); } + const hasAnyData = chartData.some((d) => d.USDC > 0 || d.EURC > 0 || d.XLM > 0); + const rangeLabel = RANGE_OPTIONS.find((o) => o.value === selectedRange)?.label ?? "90D"; + return (

- Based on volatility & contract risk + Based on volatility & contract risk

-
-

Historical Yield (90 Days)

- {chartData.every((d) => d.yield === 0) ? ( -

- No historical yield data available for {selectedToken}. -

- ) : ( -
- - - - - v.toFixed(2)} - /> - [`${value.toFixed(4)} ${selectedToken}`, "Yield"]} - /> + {/* Historical chart header with range selector and export */} +
+

+ Historical Yield Trends +

+
+ {/* Range selector */} +
+ {RANGE_OPTIONS.map(({ label, value }) => ( + + ))} +
+ + {/* CSV export */} + {hasAnyData && ( + + )} +
+
+ + {!hasAnyData ? ( +

+ No historical yield data available for the selected range. +

+ ) : ( +
+ + + + + v.toFixed(2)} + /> + [ + `${value.toFixed(4)} ${name}`, + `${name} Yield`, + ]} + /> + ( + {value} + )} + /> + {TOKENS.map((sym) => ( - - -
- )} -
+ ))} + + +
+ )}

T-bill rates ({tBillYieldPct?.toFixed(2)}%) are fetched from U.S. Treasury public data for illustration only and @@ -258,4 +355,3 @@ export default function LPYieldComparison({ ); } - diff --git a/src/components/OfflineBanner.tsx b/src/components/OfflineBanner.tsx index e16c775..49b2025 100644 --- a/src/components/OfflineBanner.tsx +++ b/src/components/OfflineBanner.tsx @@ -2,18 +2,57 @@ import { useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { WifiOff, X } from "lucide-react"; +import { WifiOff, X, ShieldCheck } from "lucide-react"; import { useToast } from "@/context/ToastContext"; const DISMISS_ANIMATION_MS = 250; +type SwStatus = "unknown" | "active" | "inactive"; + +async function triggerBackgroundSync(reg: ServiceWorkerRegistration): Promise { + try { + // SyncManager is available in supporting browsers + if ("sync" in reg) { + await (reg as ServiceWorkerRegistration & { + sync: { register(tag: string): Promise }; + }).sync.register("sync-queued-requests"); + } + } catch { + // Background sync not supported or not permitted — no-op + } +} + export default function OfflineBanner() { const queryClient = useQueryClient(); const { addToast } = useToast(); const [isVisible, setIsVisible] = useState(false); const [isExiting, setIsExiting] = useState(false); + const [swStatus, setSwStatus] = useState("unknown"); const dismissTimerRef = useRef | null>(null); + // Detect service worker registration status + useEffect(() => { + if (!("serviceWorker" in navigator)) { + setSwStatus("inactive"); + return; + } + + navigator.serviceWorker.ready + .then(() => setSwStatus("active")) + .catch(() => setSwStatus("inactive")); + + // Also listen for SW state changes + const handleControllerChange = () => { + if (navigator.serviceWorker.controller) { + setSwStatus("active"); + } + }; + navigator.serviceWorker.addEventListener("controllerchange", handleControllerChange); + return () => { + navigator.serviceWorker.removeEventListener("controllerchange", handleControllerChange); + }; + }, []); + useEffect(() => { const updateConnectionState = () => { setIsVisible(!navigator.onLine); @@ -21,15 +60,30 @@ export default function OfflineBanner() { updateConnectionState(); - const handleOnline = () => { + const handleOnline = async () => { setIsExiting(true); + + // Resume React Query paused mutations and refetch active queries + queryClient.resumePausedMutations(); + void queryClient.refetchQueries({ type: "active" }); + + // Trigger service worker background sync when available + if ("serviceWorker" in navigator) { + try { + const reg = await navigator.serviceWorker.ready; + await triggerBackgroundSync(reg); + // Notify SW to sync any queued state + reg.active?.postMessage({ type: "SYNC_ON_RECONNECT" }); + } catch { + // SW may not be ready — React Query sync above is sufficient + } + } + addToast({ type: "success", title: "Back Online", message: "Connection restored. Syncing queued requests now.", }); - queryClient.resumePausedMutations(); - void queryClient.refetchQueries({ type: "active" }); if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current); dismissTimerRef.current = setTimeout(() => { @@ -60,14 +114,29 @@ export default function OfflineBanner() { return (

-
- - You're offline. Some features may not be available. +
+ +
+ You're offline. Some features may not be available. + {swStatus === "active" && ( + + + Offline cache active — cached invoices and portfolio are available. + + )} + {swStatus === "inactive" && ( + + No offline cache — connect to the internet to use the app. + + )} +