From 2e73737c298f11a7359345969e44c483d7147af2 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 5 Jul 2026 23:20:52 +0200 Subject: [PATCH] 53 - Frontend: scenario show page with embedded bucket cards --- frontend/src/lib/bucketCapacity.test.ts | 64 +++++++++++ frontend/src/lib/bucketCapacity.ts | 39 +++++++ frontend/src/lib/bucketDisplay.test.ts | 45 ++++++++ frontend/src/lib/bucketDisplay.ts | 29 +++++ frontend/src/pages/ScenarioShowPage.test.tsx | 101 ++++++++++++++++ frontend/src/pages/ScenarioShowPage.tsx | 114 +++++++++++++++++++ frontend/src/types/api.ts | 36 ++++++ 7 files changed, 428 insertions(+) create mode 100644 frontend/src/lib/bucketCapacity.test.ts create mode 100644 frontend/src/lib/bucketCapacity.ts create mode 100644 frontend/src/lib/bucketDisplay.test.ts create mode 100644 frontend/src/lib/bucketDisplay.ts create mode 100644 frontend/src/pages/ScenarioShowPage.test.tsx create mode 100644 frontend/src/pages/ScenarioShowPage.tsx diff --git a/frontend/src/lib/bucketCapacity.test.ts b/frontend/src/lib/bucketCapacity.test.ts new file mode 100644 index 0000000..bf8cde8 --- /dev/null +++ b/frontend/src/lib/bucketCapacity.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { bucketCapacity } from './bucketCapacity' +import type { Bucket } from '../types/api' + +/** + * bucketCapacity maps a Bucket's allocation fields to the {current, capacity} + * shape DigitalProgressBar expects. Each allocation type has a distinct rule + * (fixed_limit / percentage / unlimited), tested independently below. + */ +describe('bucketCapacity', () => { + it('for a fixed_limit bucket, capacity is allocationValue scaled by (1 + bufferMultiplier)', () => { + const bucket: Bucket = { + '@id': '/api/buckets/11111111-1111-1111-1111-111111111111', + '@type': 'Bucket', + name: 'Rent', + type: 'need', + priority: 1, + sortOrder: 0, + allocationType: 'fixed_limit', + allocationValue: 10000, + startingAmount: 3000, + bufferMultiplier: '0.50', + } + + expect(bucketCapacity(bucket)).toEqual({ current: 3000, capacity: 15000 }) + }) + + it('for a percentage bucket, capacity is 0 (no finite client-computable cap)', () => { + const bucket: Bucket = { + '@id': '/api/buckets/22222222-2222-2222-2222-222222222222', + '@type': 'Bucket', + name: 'Dining Out', + type: 'want', + priority: 2, + sortOrder: 1, + allocationType: 'percentage', + allocationValue: 2500, + startingAmount: 1200, + bufferMultiplier: '0.00', + } + + expect(bucketCapacity(bucket).capacity).toBe(0) + }) + + it('for an unlimited bucket, the bar is full even when startingAmount is 0', () => { + const bucket: Bucket = { + '@id': '/api/buckets/33333333-3333-3333-3333-333333333333', + '@type': 'Bucket', + name: 'Overflow', + type: 'overflow', + priority: 3, + sortOrder: 2, + allocationType: 'unlimited', + allocationValue: null, + startingAmount: 0, + bufferMultiplier: '0.00', + } + + const { current, capacity } = bucketCapacity(bucket) + + expect(capacity).toBeGreaterThan(0) + expect(current).toBe(capacity) + }) +}) diff --git a/frontend/src/lib/bucketCapacity.ts b/frontend/src/lib/bucketCapacity.ts new file mode 100644 index 0000000..ab48811 --- /dev/null +++ b/frontend/src/lib/bucketCapacity.ts @@ -0,0 +1,39 @@ +import type { Bucket } from '../types/api' + +/** The `{current, capacity}` shape DigitalProgressBar renders (fill = current / capacity). */ +export interface CapacityBar { + current: number + capacity: number +} + +/** + * Map a Bucket's allocation fields to a progress-bar fill. The API exposes no + * effective capacity (it's model-only, never serialized), so we derive one per + * allocation type: + * + * - `fixed_limit`: a real cap — `allocationValue` scaled by the buffer + * (`allocationValue * (1 + bufferMultiplier)`), filled to `startingAmount`. + * - `percentage`: no finite client-computable cap → `capacity: 0`, which + * DigitalProgressBar renders as an empty bar. The percentage is shown as a + * text label instead. + * - `unlimited`: unconstrained by design → a full bar (`current === capacity`), + * independent of `startingAmount` (which may be 0). + * + * `bufferMultiplier` arrives as a decimal string and must be Number-parsed. + */ +export function bucketCapacity(bucket: Bucket): CapacityBar { + switch (bucket.allocationType) { + case 'fixed_limit': { + const limit = bucket.allocationValue ?? 0 + const buffer = Number(bucket.bufferMultiplier) + return { + current: bucket.startingAmount, + capacity: Math.round(limit * (1 + buffer)), + } + } + case 'percentage': + return { current: bucket.startingAmount, capacity: 0 } + case 'unlimited': + return { current: 1, capacity: 1 } + } +} diff --git a/frontend/src/lib/bucketDisplay.test.ts b/frontend/src/lib/bucketDisplay.test.ts new file mode 100644 index 0000000..979a43f --- /dev/null +++ b/frontend/src/lib/bucketDisplay.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { formatAllocation } from './bucketDisplay' +import type { Bucket } from '../types/api' + +function bucket(overrides: Partial): Bucket { + return { + '@id': '/api/buckets/11111111-1111-1111-1111-111111111111', + '@type': 'Bucket', + name: 'Test', + type: 'need', + priority: 1, + sortOrder: 0, + allocationType: 'fixed_limit', + allocationValue: 0, + startingAmount: 0, + bufferMultiplier: '0.00', + ...overrides, + } +} + +describe('formatAllocation', () => { + it('formats a fixed_limit allocation as dollars from cents', () => { + expect( + formatAllocation( + bucket({ allocationType: 'fixed_limit', allocationValue: 150000 }), + ), + ).toBe('$1,500.00') + }) + + it('formats a percentage allocation from basis points', () => { + expect( + formatAllocation( + bucket({ allocationType: 'percentage', allocationValue: 2500 }), + ), + ).toBe('25%') + }) + + it('renders unlimited allocations as a word', () => { + expect( + formatAllocation( + bucket({ allocationType: 'unlimited', allocationValue: null }), + ), + ).toBe('Unlimited') + }) +}) diff --git a/frontend/src/lib/bucketDisplay.ts b/frontend/src/lib/bucketDisplay.ts new file mode 100644 index 0000000..1d6888f --- /dev/null +++ b/frontend/src/lib/bucketDisplay.ts @@ -0,0 +1,29 @@ +import type { Bucket } from '../types/api' + +const USD = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}) + +/** + * Human-readable allocation label for a bucket, formatted by allocation type: + * + * - `fixed_limit`: `allocationValue` is integer cents → a dollar amount + * (`150000` → `$1,500.00`). + * - `percentage`: `allocationValue` is basis points → a percent + * (`2500` → `25%`). + * - `unlimited`: no value → the literal `Unlimited`. + * + * A fixed_limit/percentage bucket always carries a non-null `allocationValue` + * (backend invariant); the `?? 0` guards the type only. + */ +export function formatAllocation(bucket: Bucket): string { + switch (bucket.allocationType) { + case 'fixed_limit': + return USD.format((bucket.allocationValue ?? 0) / 100) + case 'percentage': + return `${(bucket.allocationValue ?? 0) / 100}%` + case 'unlimited': + return 'Unlimited' + } +} diff --git a/frontend/src/pages/ScenarioShowPage.test.tsx b/frontend/src/pages/ScenarioShowPage.test.tsx new file mode 100644 index 0000000..f5340c0 --- /dev/null +++ b/frontend/src/pages/ScenarioShowPage.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { MemoryRouter, Route, Routes } from 'react-router' +import { describe, expect, it } from 'vitest' +import { AuthProvider } from '../auth/AuthProvider' +import { server } from '../test/server' +import ScenarioShowPage from './ScenarioShowPage' + +const SCENARIO_ID = '11111111-1111-1111-1111-111111111111' + +/** + * ScenarioShowPage renders under AuthProvider (same /api/me probe as every + * authed page) plus a minimal own route harness carrying the `:id` param — + * the real routes.tsx wiring is a separate test (14), so this page test does + * not depend on it. + */ +function renderPage() { + render( + + + + } /> + + + , + ) +} + +describe('ScenarioShowPage', () => { + it('renders each embedded bucket with its name, type, allocation and priority', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + http.get(`http://localhost/api/scenarios/${SCENARIO_ID}`, () => + HttpResponse.json({ + '@context': '/api/contexts/Scenario', + '@id': `/api/scenarios/${SCENARIO_ID}`, + '@type': 'Scenario', + name: 'Emergency Fund', + description: 'Rainy day savings', + buckets: [ + { + '@id': '/api/buckets/22222222-2222-2222-2222-222222222222', + '@type': 'Bucket', + name: 'Rent', + type: 'need', + priority: 1, + sortOrder: 0, + allocationType: 'fixed_limit', + allocationValue: 10000, + startingAmount: 3000, + bufferMultiplier: '0.00', + }, + { + '@id': '/api/buckets/33333333-3333-3333-3333-333333333333', + '@type': 'Bucket', + name: 'Dining Out', + type: 'want', + priority: 2, + sortOrder: 1, + allocationType: 'percentage', + allocationValue: 2500, + startingAmount: 1200, + bufferMultiplier: '0.00', + }, + ], + }), + ), + // Deliberately NOT stubbing /api/buckets — MSW's onUnhandledRequest: + // 'error' fails this test if the page ever issues a second fetch, + // enforcing the single-GET-with-embedded-buckets contract. + ) + + renderPage() + + expect(await screen.findByText('Rent')).toBeInTheDocument() + expect(screen.getByText('Dining Out')).toBeInTheDocument() + + expect(screen.getByText(/need/i)).toBeInTheDocument() + expect(screen.getByText(/want/i)).toBeInTheDocument() + + // Allocation: formatted per the locked display contract — fixed_limit is + // dollars-with-cents from CENTS (10000 -> '$100.00'), percentage is + // percent from BASIS POINTS (2500 -> '25%'). + expect(screen.getByText('$100.00')).toBeInTheDocument() + expect(screen.getByText('25%')).toBeInTheDocument() + + // Priority: assert each bucket's own priority is shown somewhere within + // its own rendered content, rather than a page-wide loose number match + // (which could coincidentally match allocation/other digits instead). + const rentPriorityMarker = await screen.findByText((_, element) => + element?.textContent === 'Priority 1', + ) + const diningPriorityMarker = await screen.findByText((_, element) => + element?.textContent === 'Priority 2', + ) + expect(rentPriorityMarker).toBeInTheDocument() + expect(diningPriorityMarker).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/ScenarioShowPage.tsx b/frontend/src/pages/ScenarioShowPage.tsx new file mode 100644 index 0000000..dfa8910 --- /dev/null +++ b/frontend/src/pages/ScenarioShowPage.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react' +import { useParams } from 'react-router' +import AuthedLayout from '../components/layout/AuthedLayout' +import DigitalProgressBar from '../components/ui/DigitalProgressBar' +import Panel from '../components/ui/Panel' +import { api } from '../lib/api' +import { bucketCapacity } from '../lib/bucketCapacity' +import { formatAllocation } from '../lib/bucketDisplay' +import type { Bucket, Scenario } from '../types/api' + +type LoadState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'ready'; scenario: Scenario } + +function BucketCard({ bucket }: { bucket: Bucket }) { + const { current, capacity } = bucketCapacity(bucket) + + return ( + +
+

+ {bucket.name} +

+ + {bucket.type} + +
+ +

+ {formatAllocation(bucket)} +

+ + + +

+ Priority {bucket.priority} +

+
+ ) +} + +export default function ScenarioShowPage() { + const { id } = useParams<{ id: string }>() + const [state, setState] = useState({ status: 'loading' }) + + useEffect(() => { + let active = true + + api + .get(`/scenarios/${id}`) + .then((scenario) => { + if (active) { + setState({ status: 'ready', scenario }) + } + }) + .catch(() => { + if (active) { + setState({ status: 'error' }) + } + }) + + return () => { + active = false + } + }, [id]) + + return ( + +
+ {state.status === 'loading' && ( +

+ Loading scenario… +

+ )} + + {state.status === 'error' && ( +

+ Could not load this scenario. Please try again. +

+ )} + + {state.status === 'ready' && ( + <> +
+

+ {state.scenario.name} +

+ {state.scenario.description !== null && ( +

+ {state.scenario.description} +

+ )} +
+ + {state.scenario.buckets.length === 0 ? ( +

+ This scenario has no buckets yet. +

+ ) : ( +
    + {state.scenario.buckets.map((bucket) => ( +
  • + +
  • + ))} +
+ )} + + )} +
+
+ ) +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index f746bfd..c562e1b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -27,3 +27,39 @@ export interface ScenarioSummary extends HydraResource { name: string description: string | null } + +/** + * Bucket classification. String-literal unions mirror the backend's backed + * enums (`App\Enum\BucketType` / `BucketAllocationType`); `erasableSyntaxOnly` + * bans TS enums. Invariant: `overflow` ⟺ `unlimited`, `need`/`want` pair with + * `fixed_limit`/`percentage`. + */ +export type BucketType = 'need' | 'want' | 'overflow' +export type BucketAllocationType = 'fixed_limit' | 'percentage' | 'unlimited' + +/** + * A Bucket as embedded in the Scenario item representation (`GET + * /api/scenarios/{id}`, see #64). Monetary fields are integer cents; + * `allocationValue` is cents for fixed_limit, basis-points for percentage, and + * null for unlimited. `bufferMultiplier` is a decimal string (e.g. '1.50'). + */ +export interface Bucket extends HydraResource { + name: string + type: BucketType + priority: number + sortOrder: number + allocationType: BucketAllocationType + allocationValue: number | null + startingAmount: number + bufferMultiplier: string +} + +/** + * A Scenario's item representation with its buckets embedded inline (#64), so + * the show page fetches one resource. `owner` is intentionally not exposed. + */ +export interface Scenario extends HydraResource { + name: string + description: string | null + buckets: Bucket[] +}