From 6aae50b02e4c972adedf12e0509a6591e967343b Mon Sep 17 00:00:00 2001 From: afeez Date: Fri, 31 Jul 2026 16:56:36 +0100 Subject: [PATCH] design: investor dashboard hero --- PR_DESCRIPTION_INVESTOR_HERO.md | 85 +++++ docs/uiux/investor-dashboard-hero.md | 251 +++++++++++++++ src/components/DashboardHero.test.tsx | 325 ++++++++++++++++---- src/components/DashboardHero.tsx | 174 +++++++---- src/index.css | 8 +- src/pages/DesignTokens/tokens.ts | 2 + src/pages/InvestorPortfolioSummary.test.tsx | 188 ++++++++--- src/pages/InvestorPortfolioSummary.tsx | 18 +- src/test/setup.ts | 15 + 9 files changed, 889 insertions(+), 177 deletions(-) create mode 100644 PR_DESCRIPTION_INVESTOR_HERO.md create mode 100644 docs/uiux/investor-dashboard-hero.md diff --git a/PR_DESCRIPTION_INVESTOR_HERO.md b/PR_DESCRIPTION_INVESTOR_HERO.md new file mode 100644 index 0000000..3d0694a --- /dev/null +++ b/PR_DESCRIPTION_INVESTOR_HERO.md @@ -0,0 +1,85 @@ +# Investor Dashboard Hero — Issue: Investor Portfolio Hero UI/UX + +## Summary + +Redeveloped the hero band of the Investor Portfolio Summary (`/investor/portfolio`) +to a complete, documented, WCAG 2.1 AA–compliant hero: 4 KPI tiles with full state +coverage, a responsive portfolio sparkline, and a contrast-safe primary CTA. +No unrelated dashboard sections or business logic were touched. + +## Changes + +### Hero component — `src/components/DashboardHero.tsx` +- **KPI tile states** now fully documented and accessible: + - **Normal** — value + optional trend / action link + - **Empty** — contextual messages: "No investments yet", "No payouts scheduled", + "No pending actions" (`emptyText` on `KPIData`) + - **Error** — friendly "Couldn't load this data." + icon + optional + **Try again** button (`onRetry`, per-tile overrides hero-level) + - **Loading** — skeleton with `role="status"`, `aria-busy="true"`, labelled +- **Sparkline** is now responsive: visible at all breakpoints (was hidden on + mobile), full-width on mobile, fixed 160px on `sm+`, `role="img"` + descriptive + label, token colours (`--success`/`--error`). +- Stable `data-testid`s on the section, header, KPI grid, and each tile. +- Each value/trend exposes an `aria-label`; all interactive elements keep `focus-ring`. + +### Page — `src/pages/InvestorPortfolioSummary.tsx` +- Passes contextual `emptyText` to every KPI. +- Added `__kpiStatus` injectable prop so the tile states can be driven through + the real page wiring in tests/demos (follows the existing `__` prop convention). + +### Design system — `src/index.css` + `src/pages/DesignTokens/tokens.ts` +- New tokens `--primary-btn-bg: #2563eb`, `--primary-btn-bg-hover: #1d4ed8`. +- `.btn-primary` now uses them: white text contrast rises **3.68:1 → 5.17:1** + (hover 6.70:1), meeting WCAG 2.1 AA. `--primary` is untouched so text links + keep their 4.86:1 contrast. +- Empty-state text bumped to `text-slate-400` (6.96:1). + +### Test infra — `src/test/setup.ts` +- Added a `window.matchMedia` stub (jsdom lacks it) — unblocks `usePrintMode`/ + chart widget tests that previously crashed on mount. + +## Tests + +- `src/components/DashboardHero.test.tsx` — 29 tests +- `src/pages/InvestorPortfolioSummary.test.tsx` — 21 tests (rewritten; the old + file asserted a removed `KpiHeader` and stale copy) + +Coverage: new investor (no positions), negative returns, error state, empty state, +loading state, dark mode rendering, responsive layout classes, and `jest-axe` on +nominal / loading / error / empty / dark states — **all passing**. + +## Accessibility notes + +| Check | Result | +|---|---| +| `jest-axe` | 0 violations across all states | +| Landmark + heading | `section` labelled by the single `h1` | +| Status semantics | `role="status"` on loading/empty/error tiles | +| Keyboard / focus | Native links/buttons, logical order, `focus-ring` visible | +| CTA contrast | White on `#2563eb` = **5.17:1** (AA ≥ 4.5:1) | +| Link contrast | `#3b82f6` on dark = **4.86:1** | +| Empty text contrast | `#94a3b8` on dark = **6.96:1** | +| Reduced motion | `prefers-reduced-motion` pauses pulse/fade | + +## Verification + +- `npm run lint` — no new errors in changed files (baseline repo already has 9 + pre-existing errors in unrelated files) +- Component tests — all hero/page tests pass +- Visual tests — none exist in this repo (no Storybook/Playwright); see + before/after below + +## Before / After + +> Manual screenshots at 360px / 768px / 1280px (light & dark) are attached to +> the PR per the [design doc](docs/uiux/investor-dashboard-hero.md). + +| Aspect | Before | After | +|---|---|---| +| Empty tiles | generic "No data yet" | contextual per-tile messaging | +| Error tiles | static, no action | friendly message + "Try again" | +| Loading tiles | unlabelled skeleton | `role="status"` + `aria-busy` | +| Sparkline | hidden on mobile | responsive, scales 100%→160px | +| Primary CTA | 3.68:1 (AA fail) | 5.17:1 (AA pass) | +| Docs | — | `docs/uiux/investor-dashboard-hero.md` | diff --git a/docs/uiux/investor-dashboard-hero.md b/docs/uiux/investor-dashboard-hero.md new file mode 100644 index 0000000..33a0b19 --- /dev/null +++ b/docs/uiux/investor-dashboard-hero.md @@ -0,0 +1,251 @@ +# Investor Dashboard Hero + +**Route:** `/investor/portfolio` (Investor Portfolio Summary) +**Component:** `src/components/DashboardHero.tsx` +**Page:** `src/pages/InvestorPortfolioSummary.tsx` +**Tests:** `src/components/DashboardHero.test.tsx`, `src/pages/InvestorPortfolioSummary.test.tsx` + +--- + +## Purpose + +The investor hero is the first thing an investor sees when they land on their portfolio summary. It answers four questions at a glance: + +1. **What is my portfolio worth?** — Total Value (with trend) +2. **What have I actually made?** — Realized Gains +3. **When is money coming?** — Upcoming Payouts +4. **What needs my attention?** — Pending Actions + +…and always offers a path forward: the **Explore Offerings** primary CTA plus the **Account Settings** secondary link. + +--- + +## Component Anatomy + +``` +┌────────────────────────────────────────────────────────────────────┐ +│
│ +│ │ +│ Header row (flex-col on mobile, row on md+) │ +│ ┌──────────────────────────────┐ ┌───────────────────────────┐ │ +│ │ h1 Portfolio Overview │ │ [Explore Offerings] ▸ │ │ +│ │ p Track your returns… │ │ Account Settings │ │ +│ │ ▁▂▂▃▅▆ sparkline (svg) │ └───────────────────────────┘ │ +│ └──────────────────────────────┘ │ +│ │ +│ KPI grid (1 → 2 → 4 columns) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Total │ │ Realized │ │ Upcoming │ │ Pending │ │ +│ │ Value │ │ Gains │ │ Payouts │ │ Actions │ │ +│ │ $103,000 │ │ $3,000 │ │ 3 │ │ 1 │ │ +│ │ ▲ 3.0% │ │ │ │ Calendar │ │ Review │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +└────────────────────────────────────────────────────────────────────┘ +``` + +Each KPI tile is a `glass-card` containing: + +- **Icon** (decorative, `aria-hidden`) + uppercase **label** +- **Value** (currency or number), with a descriptive `aria-label` +- Optional **trend** (▲/▼ + `%`) or **action link** (`View calendar`, `Review now`) + +--- + +## Component API + +```tsx +interface KPIData { + value?: number | null; + label: string; + status: 'loading' | 'error' | 'empty' | 'success'; + type: 'currency' | 'number' | 'date'; + trend?: number; // percentage change vs prior period + actionText?: string; // e.g. "View calendar" + actionLink?: string; // e.g. "/investor/calendar" + emptyText?: string; // contextual empty message, e.g. "No payouts scheduled" + onRetry?: () => void; // per-tile retry handler +} + +interface DashboardHeroProps { + totalValue: KPIData; + realizedGains: KPIData; + upcomingPayouts: KPIData; + pendingActions: KPIData; + sparklineData?: number[]; + isNewInvestor?: boolean; + onRetry?: () => void; // hero-level retry (used when a tile has no own handler) +} +``` + +--- + +## Layout — Desktop vs Mobile + +### Desktop (≥ 1024px) + +| Zone | Behaviour | +|---|---| +| Hero title | `h1` (text-3xl/4xl) + supporting copy, left-aligned | +| Sparkline | Fixed `w-40` (160px) inline next to the title | +| CTA area | Right-aligned row: primary button + secondary text link | +| KPI row | 4-column grid `lg:grid-cols-4`, `gap-6` | + +### Mobile (< 640px) + +| Zone | Behaviour | +|---|---| +| Header | Stacked (`flex-col`) — title → sparkline → CTAs | +| Sparkline | Full-width (`w-full`), `preserveAspectRatio="none"` scales horizontally | +| CTA | Remains prominent: primary button full-width of its flex item, stacked above the secondary link | +| KPI row | Single column `grid-cols-1` (2 columns on `sm`) | + +Breakpoints reuse the Tailwind default scale already used across the app +(`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4`, `flex-col md:flex-row`). + +--- + +## KPI Tile Variants + +| Status | Rendering | ARIA | +|---|---|---| +| **Normal** (`success`) | Value + optional trend / action link | Value wrapped in an `aria-label` describing label + value | +| **Empty** (`empty` or `value == null`) | Dash icon (`Minus`) + contextual `emptyText` | `role="status"` + `aria-label="{label}: {emptyText}"` | +| **Error** (`error`) | Red-tinted card, `AlertTriangle` icon, friendly message, optional **Try again** button | `role="status"` + `aria-label="{label} failed to load"` | +| **Loading** (`loading`) | Animated skeleton (`animate-pulse`) | `role="status"` + `aria-busy="true"` + `aria-label="{label} loading"` | + +### Empty states + +| Tile | Message | +|---|---| +| Total Value | "No investments yet" | +| Realized Gains | "No investments yet" | +| Upcoming Payouts | "No payouts scheduled" | +| Pending Actions | "No pending actions" | + +When `emptyText` is omitted the tile falls back to the generic "No data yet". +Empty text uses `text-slate-400` (~6.96:1 on the glass surface) to stay WCAG AA. + +### Error states + +Error tiles show a friendly, non-technical message — "Couldn't load this data." — +with an icon and an optional **Try again** button. The retry button is wired by +the caller: + +- `KPIData.onRetry` (per-tile) wins over the hero-level `onRetry` +- When neither is provided the tile renders the message without a button +- The button carries an explicit `aria-label` (`Retry loading {label}`) and `focus-ring` + +--- + +## Primary CTA + +| Action | Type | Route | Style | +|---|---|---|---| +| **Explore Offerings** | Primary | `/investor/portal` | `btn btn-primary` | +| **Account Settings** | Secondary link | `/investor/settings` | `text-primary` link + `focus-ring` | + +Both follow existing dashboard navigation conventions — no new navigation +concepts were introduced. + +--- + +## Accessibility (WCAG 2.1 AA) + +- **Landmark**: `
` labelled by the `h1`. +- **Semantic headings**: single `h1` per page; widget cards below use `h2`. +- **Status announcements**: loading/empty/error tiles use `role="status"` so + state changes are announced politely. +- **Keyboard navigation**: all interactive elements are native links/buttons; + logical tab order (CTA row → tile action links → retry buttons). +- **Visible focus**: every interactive element carries `focus-ring` + (2px `var(--primary)` outline + offset). +- **Accessible names**: sparkline is `role="img"` with a descriptive + `aria-label` (e.g. "Portfolio performance sparkline trending up"); icons are + `aria-hidden`; values/trends expose `aria-label`s. +- **Contrast** (on dark surface `#0f172a` / `#020617`): + +| Element | Colours | Ratio | +|---|---|---| +| Primary CTA text | `#ffffff` on `--primary-btn-bg` `#2563eb` | **5.17:1** ✓ | +| Primary CTA hover | `#ffffff` on `#1d4ed8` | **6.70:1** ✓ | +| Secondary link | `--primary` `#3b82f6` on dark | **4.86:1** ✓ | +| Body / muted text | `--text-main` / `--text-muted` | **12:1+** ✓ | +| Empty state text | `text-slate-400` `#94a3b8` | **6.96:1** ✓ | +| Trend up / down | `#4ade80` / `#f87171` | **6.8:1+** ✓ | + +- **Reduced motion**: `animate-pulse` / `animate-fade-in` pause under + `prefers-reduced-motion` via the global stylesheet. + +> **Primary button contrast fix.** The CTA previously used `--primary` +> (`#3b82f6`) as its background, which gives **3.68:1** with white text — +> below the 4.5:1 AA threshold. `--primary` was left unchanged for text links +> (where it passes at 4.86:1) and two new tokens were added for button fills: +> `--primary-btn-bg: #2563eb` and `--primary-btn-bg-hover: #1d4ed8`. +> `.btn-primary` now uses these, so every primary button in the app meets AA. + +--- + +## Dark Mode + +The app is dark-first (default `--bg-color: #020617`). The hero is verified +against the dark theme: + +- KPI cards use `glass-card` surfaces + `text-main`/`text-muted` tokens. +- Sparkline uses `var(--success)` / `var(--error)` — both dark-safe. +- Empty/error tiles use translucent overlays that read correctly on dark glass. +- The `data-theme="dark"` render path is exercised in tests with `jest-axe`. + +--- + +## Test Coverage + +`DashboardHero.test.tsx` (29 tests) and `InvestorPortfolioSummary.test.tsx` (21 tests) cover: + +- Existing investor (nominal) and **new investor** (no positions) +- **Negative returns** → red trend + down-trending sparkline label +- **Error state** → friendly message + retry wiring (hero + per-tile) +- **Empty state** → contextual messages per tile +- **Loading state** → `role="status"` + `aria-busy` +- **Dark mode** rendering +- **Responsive layout** classes (1→2→4 grid, mobile sparkline, stacked header) +- **Accessibility** — `jest-axe` on nominal, loading, error, empty, and dark-mode states + +> Visual/snapshot tests are **not present** in this repo (no Storybook or +> Playwright configuration). Layout behaviour is asserted structurally via +> responsive class checks. Before/after rendering should be screenshotted +> manually at 360px, 768px, and 1280px viewports and attached to the PR. + +--- + +## Design Tokens Used + +All tokens come from `src/index.css` `:root`: + +``` +--glass-bg / .glass-card / --shadow-xl +--primary / --primary-hover +--primary-btn-bg / --primary-btn-bg-hover (new, CTA AA contrast) +--success / --error / --text-accent +--text-main / --text-muted +btn-primary / btn-secondary / focus-ring / animate-fade-in / animate-pulse +--spacing-* / --radius-* +``` + +--- + +## Before / After + +**Before:** the hero rendered generic "No data yet" empty tiles, hid the +sparkline entirely on mobile, offered no retry affordance on error, and the +primary CTA failed WCAG AA contrast (3.68:1). + +**After:** + +| Aspect | Before | After | +|---|---|---| +| Empty tiles | "No data yet" (generic) | Contextual: "No investments yet", "No payouts scheduled" | +| Error tiles | Static "Failed to load data" | Friendly message + optional "Try again" with accessible label | +| Loading tiles | Skeleton, no semantics | `role="status"` + `aria-busy="true"` + label | +| Sparkline | Hidden on mobile | Visible at all breakpoints, scales (full-width → 160px) | +| CTA contrast | 3.68:1 (fails AA) | 5.17:1 (passes AA) | +| a11y verification | — | `jest-axe` green on all states; dark-mode verified | diff --git a/src/components/DashboardHero.test.tsx b/src/components/DashboardHero.test.tsx index c34d9f6..c572c3e 100644 --- a/src/components/DashboardHero.test.tsx +++ b/src/components/DashboardHero.test.tsx @@ -1,92 +1,285 @@ +/** + * DashboardHero — Investor Portfolio Hero tests + * + * Covers: + * • Nominal render (existing + new investor) + * • KPI tile states: success / empty / error / loading + * • Contextual empty messages ("No investments yet", "No payouts scheduled") + * • Friendly error retry messaging (hero + per-tile handlers) + * • Negative trend styling + * • Responsive layout classes + mobile-visible sparkline + * • Dark mode rendering + * • Accessibility (jest-axe, landmarks, focus-ring) + */ + import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; -import { DashboardHero, KPIData } from './DashboardHero'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { BrowserRouter } from 'react-router-dom'; +import { DashboardHero, KPIData } from './DashboardHero'; + +expect.extend(toHaveNoViolations); + +const defaultKPIs = { + totalValue: { label: 'Total Value', value: 100000, type: 'currency', status: 'success', trend: 5 } as KPIData, + realizedGains: { label: 'Realized Gains', value: 5000, type: 'currency', status: 'success' } as KPIData, + upcomingPayouts: { label: 'Upcoming Payouts', value: 2, type: 'number', status: 'success' } as KPIData, + pendingActions: { label: 'Pending Actions', value: 1, type: 'number', status: 'success' } as KPIData, +}; -describe('DashboardHero', () => { - const defaultKPIs = { - totalValue: { label: 'Total Value', value: 100000, type: 'currency', status: 'success', trend: 5 } as KPIData, - realizedGains: { label: 'Realized Gains', value: 5000, type: 'currency', status: 'success' } as KPIData, - upcomingPayouts: { label: 'Upcoming Payouts', value: 2, type: 'number', status: 'success' } as KPIData, - pendingActions: { label: 'Pending Actions', value: 1, type: 'number', status: 'success' } as KPIData, - }; - - it('renders correctly for existing investor', () => { - render( - - - - ); - expect(screen.getByText('Portfolio Overview')).toBeInTheDocument(); +function renderHero(overrides: Partial[0]> = {}) { + return render( + + + + ); +} + +afterEach(() => { + document.documentElement.removeAttribute('data-theme'); +}); + +describe('DashboardHero – nominal render', () => { + it('renders heading, KPI values and CTAs for an existing investor', () => { + renderHero(); + expect(screen.getByRole('heading', { level: 1, name: /Portfolio Overview/i })).toBeInTheDocument(); expect(screen.getByText('Total Value')).toBeInTheDocument(); expect(screen.getByText('$100,000')).toBeInTheDocument(); expect(screen.getByText('5.0%')).toBeInTheDocument(); - expect(screen.getByText('Explore Offerings')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Explore Offerings/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Account Settings/i })).toBeInTheDocument(); + }); + + it('renders a welcome heading for a new investor', () => { + renderHero({ isNewInvestor: true, sparklineData: [] }); + expect(screen.getByRole('heading', { level: 1, name: /Welcome to Revora/i })).toBeInTheDocument(); + }); + + it('renders a region landmark labelled by the heading', () => { + renderHero(); + const heading = screen.getByRole('heading', { level: 1 }); + const region = screen.getByRole('region', { name: /Portfolio Overview/i }); + expect(region.getAttribute('aria-labelledby')).toBe(heading.id); + }); + + it('renders the four KPI tiles with stable test ids', () => { + renderHero(); + expect(screen.getByTestId('kpi-tile-total-value')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-tile-realized-gains')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-tile-upcoming-payouts')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-tile-pending-actions')).toBeInTheDocument(); + }); + + it('links primary CTA to /investor/portal and secondary to /investor/settings', () => { + renderHero(); + expect(screen.getByRole('link', { name: /Explore Offerings/i })).toHaveAttribute('href', '/investor/portal'); + expect(screen.getByRole('link', { name: /Account Settings/i })).toHaveAttribute('href', '/investor/settings'); + }); + + it('renders tile action links when provided', () => { + const kpis = { + ...defaultKPIs, + upcomingPayouts: { + label: 'Upcoming Payouts', + value: 3, + type: 'number', + status: 'success', + actionText: 'View calendar', + actionLink: '/investor/calendar', + } as KPIData, + }; + renderHero(kpis); + expect(screen.getByRole('link', { name: /View calendar/i })).toHaveAttribute('href', '/investor/calendar'); }); +}); - it('renders correctly for new investor', () => { +describe('DashboardHero – KPI tile states', () => { + it('shows contextual empty messages for a new investor', () => { const emptyKPIs = { - totalValue: { label: 'Total Value', value: 0, type: 'currency', status: 'empty' } as KPIData, - realizedGains: { label: 'Realized Gains', value: 0, type: 'currency', status: 'empty' } as KPIData, - upcomingPayouts: { label: 'Upcoming Payouts', value: 0, type: 'number', status: 'empty' } as KPIData, - pendingActions: { label: 'Pending Actions', value: 1, type: 'number', status: 'success' } as KPIData, + totalValue: { label: 'Total Value', value: 0, type: 'currency', status: 'empty', emptyText: 'No investments yet' } as KPIData, + realizedGains: { label: 'Realized Gains', value: 0, type: 'currency', status: 'empty', emptyText: 'No investments yet' } as KPIData, + upcomingPayouts: { label: 'Upcoming Payouts', value: 0, type: 'number', status: 'empty', emptyText: 'No payouts scheduled' } as KPIData, + pendingActions: { label: 'Pending Actions', value: 0, type: 'number', status: 'empty', emptyText: 'No pending actions' } as KPIData, }; + renderHero(emptyKPIs); + expect(screen.getAllByText('No investments yet').length).toBeGreaterThan(0); + expect(screen.getByText('No payouts scheduled')).toBeInTheDocument(); + expect(screen.getByText('No pending actions')).toBeInTheDocument(); + }); - render( - - - - ); - - expect(screen.getByText('Welcome to Revora')).toBeInTheDocument(); + it('falls back to "No data yet" when emptyText is not provided', () => { + const emptyKPIs = { + ...defaultKPIs, + totalValue: { label: 'Total Value', value: 0, type: 'currency', status: 'empty' } as KPIData, + }; + renderHero(emptyKPIs); expect(screen.getAllByText('No data yet').length).toBeGreaterThan(0); }); - it('renders error state correctly', () => { - const errorKPIs = { + it('treats a null value as empty', () => { + const kpis = { ...defaultKPIs, - totalValue: { ...defaultKPIs.totalValue, status: 'error' } as KPIData, + totalValue: { label: 'Total Value', value: null, type: 'currency', status: 'success' } as KPIData, }; + renderHero(kpis); + expect(screen.getByText('No data yet')).toBeInTheDocument(); + }); - render( - - - - ); - - expect(screen.getByText('Failed to load data')).toBeInTheDocument(); + it('renders error state with friendly retry messaging', () => { + renderHero({ + totalValue: { ...defaultKPIs.totalValue, status: 'error' } as KPIData, + onRetry: vi.fn(), + }); + expect(screen.getByText(/load this data/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Retry loading Total Value/i })).toBeInTheDocument(); }); - it('renders loading state correctly', () => { - const loadingKPIs = { - ...defaultKPIs, + it('invokes hero-level onRetry when the retry button is clicked', async () => { + const onRetry = vi.fn(); + renderHero({ + totalValue: { ...defaultKPIs.totalValue, status: 'error' } as KPIData, + onRetry, + }); + await userEvent.click(screen.getByRole('button', { name: /Retry loading Total Value/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('prefers a per-tile onRetry over the hero-level handler', async () => { + const tileRetry = vi.fn(); + const heroRetry = vi.fn(); + renderHero({ + totalValue: { ...defaultKPIs.totalValue, status: 'error', onRetry: tileRetry } as KPIData, + onRetry: heroRetry, + }); + await userEvent.click(screen.getByRole('button', { name: /Retry loading Total Value/i })); + expect(tileRetry).toHaveBeenCalledTimes(1); + expect(heroRetry).not.toHaveBeenCalled(); + }); + + it('renders loading state with role=status and aria-busy', () => { + renderHero({ totalValue: { ...defaultKPIs.totalValue, status: 'loading' } as KPIData }); + const loading = screen.getByTestId('kpi-tile-total-value'); + expect(loading).toHaveAttribute('role', 'status'); + expect(loading).toHaveAttribute('aria-busy', 'true'); + expect(loading).toHaveAttribute('aria-label', 'Total Value loading'); + expect(screen.queryByText('Total Value')).not.toBeInTheDocument(); + }); + + it('renders negative trend in red with a down arrow', () => { + renderHero({ totalValue: { ...defaultKPIs.totalValue, trend: -3.5 } as KPIData }); + const trend = screen.getByText('3.5%'); + expect(trend.parentElement).toHaveClass('text-red-400'); + expect(trend.parentElement).toHaveAttribute('aria-label', 'Total Value change: -3.5%'); + }); + + it('renders positive trend in green', () => { + renderHero(); + const trend = screen.getByText('5.0%'); + expect(trend.parentElement).toHaveClass('text-green-400'); + }); +}); + +describe('DashboardHero – sparkline', () => { + it('renders an accessible sparkline for an existing investor', () => { + renderHero(); + expect(screen.getByTestId('portfolio-sparkline')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: /Portfolio performance sparkline/i })).toBeInTheDocument(); + }); + + it('does not render a sparkline for a new investor', () => { + renderHero({ isNewInvestor: true, sparklineData: [] }); + expect(screen.queryByTestId('portfolio-sparkline')).not.toBeInTheDocument(); + }); + + it('does not render a sparkline with fewer than two data points', () => { + renderHero({ sparklineData: [42] }); + expect(screen.queryByTestId('portfolio-sparkline')).not.toBeInTheDocument(); + }); + + it('renders a down-trending sparkline label when the series ends lower', () => { + renderHero({ sparklineData: [300, 200, 100] }); + expect(screen.getByRole('img', { name: /sparkline trending down/i })).toBeInTheDocument(); + }); +}); + +describe('DashboardHero – responsive layout', () => { + it('uses a 1→2→4 column KPI grid', () => { + renderHero(); + const grid = screen.getByTestId('kpi-grid'); + expect(grid).toHaveClass('grid-cols-1', 'sm:grid-cols-2', 'lg:grid-cols-4'); + }); + + it('keeps the sparkline visible on mobile (full width) and fixed on sm+', () => { + renderHero(); + const sparkline = screen.getByTestId('portfolio-sparkline'); + expect(sparkline).toHaveClass('w-full', 'sm:w-40'); + expect(sparkline.className).not.toContain('hidden'); + }); + + it('stacks header content on mobile via flex-col', () => { + renderHero(); + const header = screen.getByTestId('hero-header'); + expect(header).toHaveClass('flex-col', 'md:flex-row'); + }); +}); + +describe('DashboardHero – dark mode', () => { + it('renders with token-based dark surfaces and passes axe', async () => { + document.documentElement.setAttribute('data-theme', 'dark'); + const { container } = renderHero(); + const tile = screen.getByTestId('kpi-tile-total-value'); + expect(tile).toHaveClass('glass-card'); + expect(screen.getByRole('heading', { level: 1 })).toHaveClass('text-main'); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('keeps empty tiles legible with a light-enough slate text', () => { + document.documentElement.setAttribute('data-theme', 'dark'); + renderHero({ + totalValue: { label: 'Total Value', value: 0, type: 'currency', status: 'empty', emptyText: 'No investments yet' } as KPIData, + }); + const emptyText = screen.getByText('No investments yet'); + expect(emptyText.parentElement).toHaveClass('text-slate-400'); + }); +}); + +describe('DashboardHero – accessibility', () => { + it('has no axe violations in nominal state', async () => { + const { container } = renderHero(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no axe violations in loading state', async () => { + const { container } = renderHero({ totalValue: { ...defaultKPIs.totalValue, status: 'loading' } as KPIData, - }; + realizedGains: { ...defaultKPIs.realizedGains, status: 'loading' } as KPIData, + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - render( - - - - ); - - // The skeleton does not have text, we just verify it renders without crashing - expect(screen.getByText('Realized Gains')).toBeInTheDocument(); - expect(screen.queryByText('Total Value')).not.toBeInTheDocument(); // Loading state doesn't show label + it('has no axe violations in error state', async () => { + const { container } = renderHero({ + totalValue: { ...defaultKPIs.totalValue, status: 'error' } as KPIData, + onRetry: vi.fn(), + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); }); - it('renders negative trend correctly', () => { - const negativeKPIs = { - ...defaultKPIs, - totalValue: { ...defaultKPIs.totalValue, trend: -3.5 } as KPIData, - }; + it('has no axe violations in empty state', async () => { + const { container } = renderHero({ + totalValue: { ...defaultKPIs.totalValue, status: 'empty', emptyText: 'No investments yet' } as KPIData, + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); - render( - - - - ); - - expect(screen.getByText('3.5%')).toBeInTheDocument(); - expect(screen.getByText('3.5%').parentElement).toHaveClass('text-red-400'); + it('exposes visible focus treatment on interactive elements', () => { + renderHero(); + expect(screen.getByRole('link', { name: /Explore Offerings/i })).toHaveClass('btn-primary'); + expect(screen.getByRole('link', { name: /Account Settings/i })).toHaveClass('focus-ring'); }); }); diff --git a/src/components/DashboardHero.tsx b/src/components/DashboardHero.tsx index a1c9a59..d9034b4 100644 --- a/src/components/DashboardHero.tsx +++ b/src/components/DashboardHero.tsx @@ -1,6 +1,17 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { TrendingUp, TrendingDown, DollarSign, Calendar, AlertCircle, ArrowRight, Wallet, AlertTriangle, Minus } from 'lucide-react'; +import { + TrendingUp, + TrendingDown, + DollarSign, + Calendar, + AlertCircle, + ArrowRight, + Wallet, + AlertTriangle, + Minus, + RefreshCw, +} from 'lucide-react'; export interface KPIData { value?: number | null; @@ -10,6 +21,10 @@ export interface KPIData { trend?: number; // percentage actionText?: string; actionLink?: string; + /** Contextual message shown in the empty state, e.g. "No payouts scheduled" */ + emptyText?: string; + /** Per-tile retry handler (falls back to the hero-level onRetry) */ + onRetry?: () => void; } interface DashboardHeroProps { @@ -19,15 +34,32 @@ interface DashboardHeroProps { pendingActions: KPIData; sparklineData?: number[]; isNewInvestor?: boolean; + /** Called when the user retries an errored KPI tile */ + onRetry?: () => void; } -const formatCurrency = (val: number) => +const formatCurrency = (val: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(val); -const KpiTile: React.FC<{ data: KPIData, icon: React.ReactNode }> = ({ data, icon }) => { +interface KpiTileProps { + data: KPIData; + icon: React.ReactNode; + testId?: string; + onRetry?: () => void; +} + +const KpiTile: React.FC = ({ data, icon, testId, onRetry }) => { + const retry = data.onRetry ?? onRetry; + if (data.status === 'loading') { return ( -
+
@@ -37,56 +69,84 @@ const KpiTile: React.FC<{ data: KPIData, icon: React.ReactNode }> = ({ data, ico if (data.status === 'error') { return ( -
-
+
+
{data.label}
-
Failed to load data
+
Couldn’t load this data.
+ {retry && ( + + )}
); } if (data.status === 'empty' || data.value == null) { + const emptyText = data.emptyText ?? 'No data yet'; return ( -
-
+
+
{icon}
{data.label}
-
- - No data yet +
+
); } const isPositiveTrend = data.trend !== undefined && data.trend >= 0; - + return ( -
+
{icon}
{data.label}
-
+
{data.type === 'currency' ? formatCurrency(data.value) : data.value}
- +
{data.trend !== undefined && ( -
- {isPositiveTrend ? : } +
= 0 ? '+' : ''}${data.trend.toFixed(1)}%`} + > + {isPositiveTrend ?
)} - + {data.actionText && data.actionLink && ( - {data.actionText} + {data.actionText}
@@ -101,8 +161,9 @@ export const DashboardHero: React.FC = ({ pendingActions, sparklineData = [], isNewInvestor = false, + onRetry, }) => { - // A simple SVG sparkline + // A simple SVG sparkline — responsive (full-width on mobile, fixed on sm+). const renderSparkline = () => { if (sparklineData.length < 2) return null; const max = Math.max(...sparklineData); @@ -110,50 +171,57 @@ export const DashboardHero: React.FC = ({ const range = max - min || 1; const height = 40; const width = 120; - - const points = sparklineData.map((val, i) => { - const x = (i / (sparklineData.length - 1)) * width; - const y = height - ((val - min) / range) * height; - return `${x},${y}`; - }).join(' '); + + const points = sparklineData + .map((val, i) => { + const x = (i / (sparklineData.length - 1)) * width; + const y = height - ((val - min) / range) * height; + return `${x},${y}`; + }) + .join(' '); const isPositive = sparklineData[sparklineData.length - 1] >= sparklineData[0]; - const strokeColor = isPositive ? '#10b981' : '#ef4444'; // success or error colors + const strokeColor = isPositive ? 'var(--success, #10b981)' : 'var(--error, #ef4444)'; return ( -
- - - -
+ + + ); }; return ( -
-
-
-
+
+
+
+

{isNewInvestor ? 'Welcome to Revora' : 'Portfolio Overview'}

- {isNewInvestor - ? 'Discover high-yield revenue share offerings and start building your portfolio.' + {isNewInvestor + ? 'Discover high-yield revenue share offerings and start building your portfolio.' : 'Track your personal returns and manage upcoming actions.'}

{!isNewInvestor && renderSparkline()}
- -
+ +
Explore Offerings @@ -163,11 +231,11 @@ export const DashboardHero: React.FC = ({
-
- } /> - } /> - } /> - } /> +
+ } testId="kpi-tile-total-value" onRetry={onRetry} /> + } testId="kpi-tile-realized-gains" onRetry={onRetry} /> + } testId="kpi-tile-upcoming-payouts" onRetry={onRetry} /> + } testId="kpi-tile-pending-actions" onRetry={onRetry} />
); diff --git a/src/index.css b/src/index.css index 14d0da5..4b9530e 100644 --- a/src/index.css +++ b/src/index.css @@ -85,6 +85,10 @@ --primary: #3b82f6; --primary-hover: #2563eb; + /* Primary button fill — darkened to meet WCAG 2.1 AA (white text 4.5:1+). + --primary is retained for text links / accents where 4.86:1 on dark holds. */ + --primary-btn-bg: #2563eb; + --primary-btn-bg-hover: #1d4ed8; --text-main: #e5e7eb; --text-muted: #cbd5e1; --text-accent: #38bdf8; @@ -356,7 +360,7 @@ body { } .btn-primary { - background: var(--primary); + background: var(--primary-btn-bg); color: white; border: none; padding: 0.75rem 1.5rem; @@ -368,7 +372,7 @@ body { } .btn-primary:hover { - background: var(--primary-hover); + background: var(--primary-btn-bg-hover); transform: translateY(-1px); } diff --git a/src/pages/DesignTokens/tokens.ts b/src/pages/DesignTokens/tokens.ts index 8dad6d0..8a5cd37 100644 --- a/src/pages/DesignTokens/tokens.ts +++ b/src/pages/DesignTokens/tokens.ts @@ -42,6 +42,8 @@ export const TOKEN_GROUPS: TokenGroup[] = [ tokens: [ { name: "Primary", variable: "--primary", value: "#3b82f6", description: "Brand primary blue" }, { name: "Primary Hover", variable: "--primary-hover", value: "#2563eb", description: "Primary interactive state" }, + { name: "Primary Button BG", variable: "--primary-btn-bg", value: "#2563eb", description: "Primary button fill — 5.17:1 contrast with white text (WCAG 2.1 AA)" }, + { name: "Primary Button BG Hover", variable: "--primary-btn-bg-hover", value: "#1d4ed8", description: "Primary button hover — 6.70:1 contrast with white text" }, { name: "Text Main", variable: "--text-main", value: "#e5e7eb", description: "Primary text" }, { name: "Text Muted", variable: "--text-muted", value: "#cbd5e1", description: "Secondary text, WCAG AA on dark bg" }, { name: "Text Accent", variable: "--text-accent", value: "#38bdf8", description: "Accent/highlight text" }, diff --git a/src/pages/InvestorPortfolioSummary.test.tsx b/src/pages/InvestorPortfolioSummary.test.tsx index dd5eecb..44e4984 100644 --- a/src/pages/InvestorPortfolioSummary.test.tsx +++ b/src/pages/InvestorPortfolioSummary.test.tsx @@ -1,15 +1,28 @@ /** * InvestorPortfolioSummary.test.tsx - * Issue #163 – Investor Portfolio Summary page - * Coverage target ≥95% on InvestorPortfolioSummary.tsx + * Investor Portfolio Summary page — hero-focused coverage + * + * Covers: + * • Hero section wiring (heading, CTA, KPI tiles, sparkline) + * • New investor (no positions) empty states + * • Negative return trend rendering + * • KPI error + loading states driven through __kpiStatus + * • Responsive layout classes + * • Dark mode rendering + * • Accessibility (jest-axe on the hero) + * • Widgets still render alongside the hero */ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it } from "vitest"; +import { axe, toHaveNoViolations } from "jest-axe"; import { InvestorPortfolioSummary } from "./InvestorPortfolioSummary"; import type { AllocationSlice } from "../components/AllocationWidget"; import type { PerformanceDataPoint } from "../components/PerformanceTrendWidget"; +expect.extend(toHaveNoViolations); + const ALLOCS: AllocationSlice[] = [ { id: "1", label: "TechFlow AI", value: 45000, percentage: 45 }, { id: "2", label: "Quantum Ledger", value: 30000, percentage: 30 }, @@ -22,95 +35,168 @@ const PERF: PerformanceDataPoint[] = [ { month: "Mar", value: 103000 }, ]; -const renderPage = (overrides = {}) => +const renderPage = (overrides: Record = {}) => render( ); -describe("InvestorPortfolioSummary", () => { - it("renders portfolio-summary testid", () => { +const hero = () => screen.getByTestId("investor-hero"); + +afterEach(() => { + document.documentElement.removeAttribute("data-theme"); +}); + +describe("InvestorPortfolioSummary – hero", () => { + it("renders the hero section and page shell", () => { renderPage(); + expect(hero()).toBeInTheDocument(); expect(screen.getByTestId("portfolio-summary")).toBeInTheDocument(); }); - it("renders page heading", () => { + it("renders Portfolio Overview heading for an existing investor", () => { renderPage(); - expect(screen.getByRole("heading", { level: 1, name: /Portfolio Summary/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1, name: /Portfolio Overview/i })).toBeInTheDocument(); }); - it("renders back navigation link to /investor/portal", () => { - renderPage(); - const link = screen.getByRole("link", { name: /Back to Investor Discovery/i }); - expect(link).toHaveAttribute("href", "/investor/portal"); + it("renders Welcome to Revora heading for a new investor", () => { + renderPage({ __allocations: [], __performance: [] }); + expect(screen.getByRole("heading", { level: 1, name: /Welcome to Revora/i })).toBeInTheDocument(); }); - it("renders KPI header", () => { + it("renders the primary and secondary CTAs with the right routes", () => { renderPage(); - expect(screen.getByTestId("kpi-header")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Explore Offerings/i })).toHaveAttribute("href", "/investor/portal"); + expect(screen.getByRole("link", { name: /Account Settings/i })).toHaveAttribute("href", "/investor/settings"); }); - it("renders allocation widget", () => { + it("renders the four KPI tiles", () => { renderPage(); - expect(screen.getByTestId("allocation-widget")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-tile-total-value")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-tile-realized-gains")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-tile-upcoming-payouts")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-tile-pending-actions")).toBeInTheDocument(); }); - it("renders performance widget", () => { + it("shows the current portfolio value from the last performance point", () => { renderPage(); - expect(screen.getByTestId("performance-widget")).toBeInTheDocument(); + expect(within(hero()).getByText("$103,000")).toBeInTheDocument(); + }); + + it("falls back to totalInvested when performance is empty", () => { + renderPage({ __performance: [] }); + expect(within(hero()).getByText("$100,000")).toBeInTheDocument(); }); - it("computes totalInvested from allocations", () => { + it("renders the sparkline for an existing investor", () => { renderPage(); - // 45000+30000+25000 = 100000 → $100,000 (appears in KPI + allocation bar) - expect(screen.getAllByText("$100,000").length).toBeGreaterThan(0); + expect(within(hero()).getByTestId("portfolio-sparkline")).toBeInTheDocument(); }); - it("shows currentValue = last performance point", () => { + it("renders back navigation link to /investor/portal", () => { renderPage(); - // last point value = 103000 → $103,000 - expect(screen.getByText("$103,000")).toBeInTheDocument(); + const link = screen.getByRole("link", { name: /Back to Investor Discovery/i }); + expect(link).toHaveAttribute("href", "/investor/portal"); }); - it("shows activeHoldings = 3", () => { + it("renders the allocation and performance widgets alongside the hero", () => { renderPage(); - // KPI header "3" for activeHoldings - expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByTestId("allocation-widget")).toBeInTheDocument(); + expect(screen.getByTestId("performance-widget")).toBeInTheDocument(); }); +}); - it("renders with default mock data when no props given", () => { - render( - - - - ); - expect(screen.getByTestId("portfolio-summary")).toBeInTheDocument(); - expect(screen.getByTestId("kpi-header")).toBeInTheDocument(); +describe("InvestorPortfolioSummary – new investor (no positions)", () => { + it("shows contextual empty states on each KPI tile", () => { + renderPage({ __allocations: [], __performance: [] }); + expect(screen.getAllByText("No investments yet").length).toBeGreaterThan(0); + expect(screen.getByText("No payouts scheduled")).toBeInTheDocument(); + expect(screen.getByText("No pending actions")).toBeInTheDocument(); }); - it("handles empty allocations gracefully", () => { - renderPage({ __allocations: [] }); - // totalInvested = 0, currentValue = last perf value - expect(screen.getByTestId("portfolio-summary")).toBeInTheDocument(); + it("does not render a sparkline for a new investor", () => { + renderPage({ __allocations: [], __performance: [] }); + expect(within(hero()).queryByTestId("portfolio-sparkline")).not.toBeInTheDocument(); }); +}); - it("handles empty performance gracefully (currentValue falls back to totalInvested)", () => { - renderPage({ __performance: [] }); - expect(screen.getByTestId("portfolio-summary")).toBeInTheDocument(); +describe("InvestorPortfolioSummary – negative returns", () => { + it("renders a negative trend in red in the hero", () => { + const NEGATIVE_PERF: PerformanceDataPoint[] = [ + { month: "Jan", value: 110000 }, + { month: "Feb", value: 100000 }, + { month: "Mar", value: 95000 }, + ]; + renderPage({ __performance: NEGATIVE_PERF }); + // totalInvested = $100,000, current = $95,000 → -5.0% + const trend = within(hero()).getByText("5.0%"); + expect(trend.parentElement).toHaveClass("text-red-400"); + }); + + it("renders a down-trending sparkline label", () => { + const NEGATIVE_PERF: PerformanceDataPoint[] = [ + { month: "Jan", value: 110000 }, + { month: "Feb", value: 100000 }, + { month: "Mar", value: 95000 }, + ]; + renderPage({ __performance: NEGATIVE_PERF }); + expect(within(hero()).getByRole("img", { name: /sparkline trending down/i })).toBeInTheDocument(); }); +}); - it("handles single holding", () => { - renderPage({ - __allocations: [{ id: "1", label: "Solo Fund", value: 10000, percentage: 100 }], - }); - expect(screen.getByText("Solo Fund")).toBeInTheDocument(); +describe("InvestorPortfolioSummary – KPI error and loading states", () => { + it("renders friendly retry messaging on every tile when errored", () => { + renderPage({ __kpiStatus: "error" }); + expect(screen.getAllByText(/load this data/i).length).toBe(4); + // Retry buttons are wired by the data layer via DashboardHero's onRetry — + // the page (mock data) does not provide a handler. + expect(screen.queryByRole("button", { name: /Retry loading/i })).not.toBeInTheDocument(); + }); + + it("renders loading skeletons with role=status and aria-busy", () => { + renderPage({ __kpiStatus: "loading" }); + const statuses = screen.getAllByRole("status"); + expect(statuses.length).toBe(4); + statuses.forEach((node) => expect(node).toHaveAttribute("aria-busy", "true")); + }); +}); + +describe("InvestorPortfolioSummary – responsive layout", () => { + it("uses a 1→2→4 column KPI grid", () => { + renderPage(); + const grid = screen.getByTestId("kpi-grid"); + expect(grid).toHaveClass("grid-cols-1", "sm:grid-cols-2", "lg:grid-cols-4"); }); - it("renders subtitle text", () => { + it("keeps the sparkline responsive and mobile-visible", () => { renderPage(); - expect( - screen.getByText(/Your holdings, allocation, and 12-month performance at a glance/i) - ).toBeInTheDocument(); + const sparkline = within(hero()).getByTestId("portfolio-sparkline"); + expect(sparkline).toHaveClass("w-full", "sm:w-40"); + }); +}); + +describe("InvestorPortfolioSummary – dark mode", () => { + it("renders the hero with token-based dark surfaces and passes axe", async () => { + document.documentElement.setAttribute("data-theme", "dark"); + renderPage(); + expect(screen.getByTestId("kpi-tile-total-value")).toHaveClass("glass-card"); + expect(screen.getByRole("heading", { level: 1 })).toHaveClass("text-main"); + const results = await axe(hero()); + expect(results).toHaveNoViolations(); + }); +}); + +describe("InvestorPortfolioSummary – accessibility", () => { + it("has no axe violations on the hero in nominal state", async () => { + renderPage(); + const results = await axe(hero()); + expect(results).toHaveNoViolations(); + }); + + it("has no axe violations on the hero in empty state", async () => { + renderPage({ __allocations: [], __performance: [] }); + const results = await axe(hero()); + expect(results).toHaveNoViolations(); }); }); diff --git a/src/pages/InvestorPortfolioSummary.tsx b/src/pages/InvestorPortfolioSummary.tsx index 402149f..48785bd 100644 --- a/src/pages/InvestorPortfolioSummary.tsx +++ b/src/pages/InvestorPortfolioSummary.tsx @@ -33,38 +33,45 @@ interface InvestorPortfolioSummaryProps { /** Inject mock data for testing */ __allocations?: AllocationSlice[]; __performance?: PerformanceDataPoint[]; + /** Force every KPI tile into a given status (demo/testing of tile states) */ + __kpiStatus?: KPIData['status']; } export const InvestorPortfolioSummary: React.FC = ({ __allocations = MOCK_ALLOCATIONS, __performance = MOCK_PERFORMANCE, + __kpiStatus, }) => { const totalInvested = __allocations.reduce((s, a) => s + a.value, 0); const currentValue = __performance.length > 0 ? __performance[__performance.length - 1].value : totalInvested; const totalReturn = totalInvested > 0 ? ((currentValue - totalInvested) / totalInvested) * 100 : 0; - + const isNewInvestor = __allocations.length === 0; + const kpiStatus: KPIData['status'] = __kpiStatus ?? (isNewInvestor ? 'empty' : 'success'); const totalValueKPI: KPIData = { label: "Total Value", value: currentValue, type: 'currency', - status: isNewInvestor ? 'empty' : 'success', + status: kpiStatus, trend: totalReturn, + emptyText: "No investments yet", }; const realizedGainsKPI: KPIData = { label: "Realized Gains", value: isNewInvestor ? null : currentValue - totalInvested, // simplified for mock type: 'currency', - status: isNewInvestor ? 'empty' : 'success', + status: kpiStatus, + emptyText: "No investments yet", }; const upcomingPayoutsKPI: KPIData = { label: "Upcoming Payouts", value: isNewInvestor ? null : 3, type: 'number', - status: isNewInvestor ? 'empty' : 'success', + status: kpiStatus, + emptyText: "No payouts scheduled", actionText: isNewInvestor ? undefined : 'View calendar', actionLink: isNewInvestor ? undefined : '/investor/calendar' }; @@ -73,7 +80,8 @@ export const InvestorPortfolioSummary: React.FC = label: "Pending Actions", value: 1, type: 'number', - status: 'success', // Always show 1 for mock purposes + status: kpiStatus, + emptyText: "No pending actions", actionText: 'Review now', actionLink: '/investor/actions' }; diff --git a/src/test/setup.ts b/src/test/setup.ts index 8391917..9a38810 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -13,6 +13,21 @@ global.ResizeObserver = class ResizeObserver { disconnect() {} }; +// matchMedia stub — jsdom does not implement it. Components that detect print +// mode (usePrintMode) and some chart widgets call window.matchMedia() on mount. +if (!window.matchMedia) { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} + // jsdom does not implement scrollIntoView — provide a minimal stub if (!Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = function () {};