From bfaf5c49ed41a7dbef71329501c8e9018b90fa63 Mon Sep 17 00:00:00 2001
From: myrmidex
Date: Sun, 5 Jul 2026 23:27:17 +0200
Subject: [PATCH] 53 - Frontend: wire scenario show route and distinct
not-found state
---
frontend/src/pages/ScenarioShowPage.test.tsx | 40 ++++++++++++++++++++
frontend/src/pages/ScenarioShowPage.tsx | 20 ++++++++--
frontend/src/router.test.tsx | 32 ++++++++++++++++
frontend/src/router.tsx | 6 ++-
4 files changed, 94 insertions(+), 4 deletions(-)
diff --git a/frontend/src/pages/ScenarioShowPage.test.tsx b/frontend/src/pages/ScenarioShowPage.test.tsx
index f5340c0..2be3109 100644
--- a/frontend/src/pages/ScenarioShowPage.test.tsx
+++ b/frontend/src/pages/ScenarioShowPage.test.tsx
@@ -98,4 +98,44 @@ describe('ScenarioShowPage', () => {
expect(rentPriorityMarker).toBeInTheDocument()
expect(diningPriorityMarker).toBeInTheDocument()
})
+
+ it('renders a distinct not-found state when the scenario 404s (e.g. not owned)', 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({ message: 'Not Found' }, { status: 404 }),
+ ),
+ )
+
+ renderPage()
+
+ expect(await screen.findByText(/not found/i)).toBeInTheDocument()
+ expect(
+ screen.queryByText(/could not load this scenario/i),
+ ).not.toBeInTheDocument()
+
+ // Lock in the accessibility semantics distinction from the error state
+ // (role="alert") — a copy-paste that swaps this to "alert" should fail
+ // here even if the visible copy still says "not found".
+ expect(await screen.findByRole('status')).toHaveTextContent(/not found/i)
+ })
+
+ it('renders the generic error state for a non-404 failure', 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({ message: 'Internal Server Error' }, { status: 500 }),
+ ),
+ )
+
+ renderPage()
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ /could not load this scenario/i,
+ )
+ })
})
diff --git a/frontend/src/pages/ScenarioShowPage.tsx b/frontend/src/pages/ScenarioShowPage.tsx
index dfa8910..c5c5882 100644
--- a/frontend/src/pages/ScenarioShowPage.tsx
+++ b/frontend/src/pages/ScenarioShowPage.tsx
@@ -3,7 +3,7 @@ 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 { ApiError, api } from '../lib/api'
import { bucketCapacity } from '../lib/bucketCapacity'
import { formatAllocation } from '../lib/bucketDisplay'
import type { Bucket, Scenario } from '../types/api'
@@ -11,6 +11,7 @@ import type { Bucket, Scenario } from '../types/api'
type LoadState =
| { status: 'loading' }
| { status: 'error' }
+ | { status: 'not-found' }
| { status: 'ready'; scenario: Scenario }
function BucketCard({ bucket }: { bucket: Bucket }) {
@@ -54,8 +55,15 @@ export default function ScenarioShowPage() {
setState({ status: 'ready', scenario })
}
})
- .catch(() => {
- if (active) {
+ .catch((error: unknown) => {
+ if (!active) {
+ return
+ }
+ // Owner scoping (#50) returns 404 for a scenario you don't own — surface
+ // it as a distinct not-found state rather than a generic failure.
+ if (error instanceof ApiError && error.status === 404) {
+ setState({ status: 'not-found' })
+ } else {
setState({ status: 'error' })
}
})
@@ -80,6 +88,12 @@ export default function ScenarioShowPage() {
)}
+ {state.status === 'not-found' && (
+
+ Scenario not found.
+
+ )}
+
{state.status === 'ready' && (
<>
diff --git a/frontend/src/router.test.tsx b/frontend/src/router.test.tsx
index cd2a6d4..d79569d 100644
--- a/frontend/src/router.test.tsx
+++ b/frontend/src/router.test.tsx
@@ -68,6 +68,38 @@ describe('app router config', () => {
expect(await screen.findByLabelText(/email/i)).toBeInTheDocument()
})
+ it('navigating to /scenarios/:id renders the scenario show page', async () => {
+ const SCENARIO_ID = '11111111-1111-1111-1111-111111111111'
+
+ 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: [],
+ }),
+ ),
+ )
+
+ const router = createMemoryRouter(routes, {
+ initialEntries: [`/scenarios/${SCENARIO_ID}`],
+ })
+
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
+ })
+
it('an authenticated user at /login is redirected to the app', async () => {
server.use(
http.get('http://localhost/api/me', () =>
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index d33ac3b..0ed147c 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
import LoginPage from './auth/LoginPage'
import RegisterPage from './auth/RegisterPage'
import { RequireAuth, RequireGuest } from './auth/guards'
+import ScenarioShowPage from './pages/ScenarioShowPage'
import ScenariosPage from './pages/ScenariosPage'
/**
@@ -23,7 +24,10 @@ export const routes: RouteObject[] = [
},
{
element:
,
- children: [{ path: '/', element:
}],
+ children: [
+ { path: '/', element:
},
+ { path: '/scenarios/:id', element:
},
+ ],
},
// Unknown paths fall back to the app root (which re-guards).
{ path: '*', element:
},