Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions pr.md
Original file line number Diff line number Diff line change
@@ -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-<range>.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 %.
112 changes: 109 additions & 3 deletions src/components/InvoiceFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from "react";
import {
INVOICE_STATUSES,
type DateFilterType,
type InvoiceFilters,
type InvoiceStatus,
} from "@/hooks/useInvoiceFilters";
Expand All @@ -17,6 +18,37 @@ type InvoiceFilterBarProps = {

const TOKEN_OPTIONS = ["USDC", "EURC", "XLM"] as const;

const DATE_TYPE_LABELS: Record<DateFilterType, string> = {
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<HTMLDivElement>) {
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) return;
e.preventDefault();
Expand All @@ -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 (
<div className={containerClass}>
{/* Screen reader live region — announces filter state changes */}
{/* Screen reader live region */}
<div role="status" aria-live="polite" className="sr-only">
{activeFilterCount > 0
? `${activeFilterCount} filter${activeFilterCount === 1 ? "" : "s"} applied`
Expand Down Expand Up @@ -164,20 +207,83 @@ export default function InvoiceFilterBar({
</div>
</div>

<div className="space-y-2">
<p className="text-xs font-bold uppercase tracking-wide text-on-surface-variant">Due Date</p>
{/* Date range picker with type selector and quick presets */}
<div className="space-y-2 md:col-span-2 lg:col-span-1">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wide text-on-surface-variant">
Date Range
</p>
{hasDateFilter && (
<button
type="button"
onClick={clearDateFilter}
className="text-[11px] font-semibold text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded"
>
Clear date
</button>
)}
</div>

{/* Date type selector */}
<div className="flex gap-1 rounded-lg bg-surface-container-high p-0.5">
{(["due", "funded"] as DateFilterType[]).map((type) => (
<button
key={type}
type="button"
onClick={() =>
onFiltersChange((current) => ({ ...current, dateType: type }))
}
className={`flex-1 rounded-md px-2 py-1 text-[11px] font-semibold transition-all ${
filters.dateType === type
? "bg-primary text-white shadow-sm"
: "text-on-surface-variant hover:text-on-surface"
}`}
>
{DATE_TYPE_LABELS[type]}
</button>
))}
</div>

{/* Quick presets */}
<div className="flex gap-1.5">
{(["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 (
<button
key={preset}
type="button"
onClick={() => (isActive ? clearDateFilter() : applyPreset(preset))}
className={`flex-1 rounded-lg border px-2 py-1.5 text-[11px] font-semibold transition-all ${
isActive
? "border-primary/50 bg-primary/10 text-primary"
: "border-outline-variant/30 bg-surface-container-lowest text-on-surface-variant hover:border-primary/30 hover:text-primary"
}`}
>
{label}
</button>
);
})}
</div>

{/* Custom date inputs */}
<div className="grid grid-cols-2 gap-2">
<input
type="date"
aria-label={`${DATE_TYPE_LABELS[filters.dateType]} from`}
value={filters.startDate}
max={filters.endDate || undefined}
onChange={(event) =>
onFiltersChange((current) => ({ ...current, startDate: event.target.value }))
}
className="rounded-lg border border-outline-variant/30 bg-surface-container-lowest px-3 py-2 text-sm"
/>
<input
type="date"
aria-label={`${DATE_TYPE_LABELS[filters.dateType]} to`}
value={filters.endDate}
min={filters.startDate || undefined}
onChange={(event) =>
onFiltersChange((current) => ({ ...current, endDate: event.target.value }))
}
Expand Down
Loading
Loading