53 - Frontend: scenario show page with embedded bucket cards
This commit is contained in:
parent
35d83c774f
commit
2e73737c29
7 changed files with 428 additions and 0 deletions
64
frontend/src/lib/bucketCapacity.test.ts
Normal file
64
frontend/src/lib/bucketCapacity.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
39
frontend/src/lib/bucketCapacity.ts
Normal file
39
frontend/src/lib/bucketCapacity.ts
Normal file
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
45
frontend/src/lib/bucketDisplay.test.ts
Normal file
45
frontend/src/lib/bucketDisplay.test.ts
Normal file
|
|
@ -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>): 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')
|
||||
})
|
||||
})
|
||||
29
frontend/src/lib/bucketDisplay.ts
Normal file
29
frontend/src/lib/bucketDisplay.ts
Normal file
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
101
frontend/src/pages/ScenarioShowPage.test.tsx
Normal file
101
frontend/src/pages/ScenarioShowPage.test.tsx
Normal file
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={[`/scenarios/${SCENARIO_ID}`]}>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/scenarios/:id" element={<ScenarioShowPage />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
114
frontend/src/pages/ScenarioShowPage.tsx
Normal file
114
frontend/src/pages/ScenarioShowPage.tsx
Normal file
|
|
@ -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 (
|
||||
<Panel className="space-y-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="text-primary font-bold uppercase tracking-wider">
|
||||
{bucket.name}
|
||||
</h3>
|
||||
<span className="text-muted-foreground font-mono text-xs uppercase">
|
||||
{bucket.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="font-digital text-primary text-xl">
|
||||
{formatAllocation(bucket)}
|
||||
</p>
|
||||
|
||||
<DigitalProgressBar current={current} capacity={capacity} />
|
||||
|
||||
<p className="text-muted-foreground font-mono text-xs">
|
||||
Priority {bucket.priority}
|
||||
</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ScenarioShowPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [state, setState] = useState<LoadState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
api
|
||||
.get<Scenario>(`/scenarios/${id}`)
|
||||
.then((scenario) => {
|
||||
if (active) {
|
||||
setState({ status: 'ready', scenario })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setState({ status: 'error' })
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<AuthedLayout>
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
{state.status === 'loading' && (
|
||||
<p role="status" className="text-muted-foreground font-mono text-sm">
|
||||
Loading scenario…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<p role="alert" className="text-destructive font-mono text-sm">
|
||||
Could not load this scenario. Please try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === 'ready' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||
{state.scenario.name}
|
||||
</h2>
|
||||
{state.scenario.description !== null && (
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
{state.scenario.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.scenario.buckets.length === 0 ? (
|
||||
<p className="text-muted-foreground font-mono text-sm">
|
||||
This scenario has no buckets yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{state.scenario.buckets.map((bucket) => (
|
||||
<li key={bucket['@id']}>
|
||||
<BucketCard bucket={bucket} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AuthedLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue