feat(unit-4): Dashboard — availability widget with TanStack Query

- Add @tanstack/react-query 5.101.0; wrap app with QueryClientProvider
- Add AvailabilityStatus type and AvailabilityResponse to api/types.ts
- Implement useAvailabilityStatus (staleTime 30s, stale-on-error preserved)
- Add AvailabilityStatusBadge with green/amber/red states and stale indicator
- Replace DashboardPage placeholder card with live availability widget
- Add MSW availability handler; update test/utils with QueryClientProvider
- 55/55 tests pass (FR-05)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 13:39:29 +02:00
co-authored by Claude Haiku 4.5
parent f476e06691
commit 3c6a06028e
23 changed files with 1219 additions and 22 deletions
@@ -0,0 +1,114 @@
# Business Logic Model — Unit 4: Dashboard
## Process Overview
Unit 4 introduces TanStack Query as a shared data-fetching layer and implements the Dashboard page with a live availability widget. The logic splits into three concerns: query infrastructure setup, the availability hook, and the dashboard UI composition.
---
## TanStack Query Setup
A `QueryClient` is created once at app startup and provided via `QueryClientProvider` wrapping the `RouterProvider` in `main.tsx`. This is a one-time global change that enables all future units to use `useQuery` / `useMutation`.
```ts
// Recommended default options for the QueryClient
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: true,
},
},
});
```
---
## useAvailabilityStatus Hook
**Location**: `frontend/src/api/useAvailability.ts`
**Query key**: `['availability', 'status']`
**Fetch function**: Calls `GET /api/v1/Availability/status` via the shared `api` client.
**staleTime**: `30_000` ms — TanStack Query refetches automatically in the background once data is 30 seconds old.
**Return shape**:
```ts
{
data: AvailabilityResponse | undefined; // undefined until first successful fetch
error: Error | null;
isLoading: boolean; // true only during initial fetch with no data
isFetching: boolean; // true during any in-flight refetch
isError: boolean;
refetch: () => void; // exposed for Retry button
}
```
**Stale-with-error state**: When a background refetch fails, TanStack Query preserves the previous `data` value while setting `isError = true`. This enables the dashboard to display the last known status alongside the error state (BR-U4-11).
---
## DashboardPage Composition
The DashboardPage composes existing layout primitives with the new availability widget. No routing changes are needed — `DashboardPage` is already registered as the `/dashboard` route component in `router.tsx`.
**Sections**:
1. **Header** — welcome message with user name (already implemented; retained as-is)
2. **Availability Widget** — replaces the generic placeholder card
---
## Availability Widget Logic Flow
```mermaid
sequenceDiagram
box rgba(99,179,237,0.4) Component Layer
participant DP as DashboardPage
participant Badge as AvailabilityStatusBadge
end
box rgba(154,230,180,0.4) Query Layer
participant Q as useAvailabilityStatus
end
box rgba(233,213,255,0.4) External
participant API as GET /availability/status
end
DP->>Q: mount — useAvailabilityStatus()
Q->>API: fetch on mount
API-->>Q: 200 AvailabilityResponse
Q-->>DP: data + isLoading=false
DP->>Badge: render with status and message
Note over Q: staleTime 30s elapses
Q->>API: background refetch
alt Refetch succeeds
API-->>Q: 200 AvailabilityResponse
Q-->>DP: updated data
DP->>Badge: re-render with fresh data
else Refetch fails
API-->>Q: network error or 5xx
Q-->>DP: isError=true data=stale
DP->>Badge: render stale badge with stale indicator
DP->>DP: show error banner with Retry button
end
```
Sequence showing initial fetch, badge render, background refetch on staleTime expiry, and error handling with stale data preservation.
---
## i18n Keys (additions)
New translation keys needed for the availability widget:
| Key | English | Dutch |
|---|---|---|
| `availability.title` | System Status | Systeemstatus |
| `availability.available` | Available | Beschikbaar |
| `availability.maintenance` | Maintenance | Onderhoud |
| `availability.unavailable` | Unavailable | Niet beschikbaar |
| `availability.errorTitle` | Could not load status | Status kon niet worden geladen |
| `availability.staleLabel` | Last known status | Laatste bekende status |
| `availability.retry` | Retry | Opnieuw proberen |
@@ -0,0 +1,77 @@
# Business Rules — Unit 4: Dashboard
## Access Rules
| ID | Rule |
|---|---|
| BR-U4-01 | The Dashboard is accessible to all authenticated users regardless of role |
| BR-U4-02 | Unauthenticated users are redirected to `/login` by the existing `authenticatedRoute` guard |
## Data Fetching Rules
| ID | Rule |
|---|---|
| BR-U4-03 | Availability status is fetched via `GET /api/v1/Availability/status` on component mount |
| BR-U4-04 | The query uses TanStack Query with `staleTime: 30_000` (30 seconds) — background refetch triggers automatically when data is stale |
| BR-U4-05 | The endpoint is public (anonymous) — no Bearer token required; the API client still sends credentials for cookie consistency |
## Display Rules
| ID | Rule |
|---|---|
| BR-U4-06 | Status `Available` renders with a **green** visual indicator |
| BR-U4-07 | Status `Maintenance` renders with an **amber/yellow** visual indicator |
| BR-U4-08 | Status `Unavailable` renders with a **red** visual indicator |
| BR-U4-09 | The `message` field is displayed as a subtitle below the status badge when it is a non-empty string |
## Error Handling Rules
| ID | Rule |
|---|---|
| BR-U4-10 | If the initial fetch fails (no cached data): display an error card with a generic message and a **Retry** button |
| BR-U4-11 | If a background refetch fails but cached (stale) data exists: display the stale status badge with a visual "stale" indicator (e.g. amber border, clock icon) AND an error banner with a **Retry** button |
| BR-U4-12 | Clicking the Retry button triggers an immediate refetch via TanStack Query's `refetch()` |
| BR-U4-13 | Network errors and API errors are treated identically from a UI perspective — no internal error details are exposed to the user |
## Loading Rules
| ID | Rule |
|---|---|
| BR-U4-14 | While the initial fetch is in-flight (no cached data): display a skeleton/loading placeholder in the availability widget |
| BR-U4-15 | Background refetches (when cached data exists) do not trigger a loading skeleton — the existing status remains visible |
## Decision Flowchart
```mermaid
graph TD
Mount["Component Mounts"]
HasCache{Cached data exists?}
Loading["Show skeleton loading state"]
FetchSuccess{Fetch successful?}
ShowStatus["Show AvailabilityStatusBadge\n+ message subtitle"]
BackgroundRefetch{Background refetch error?}
ShowStale["Show stale badge\n+ stale indicator\n+ error banner + Retry"]
ShowError["Show error card\n+ Retry button"]
Mount --> HasCache
HasCache -- No --> Loading
HasCache -- Yes --> ShowStatus
Loading --> FetchSuccess
FetchSuccess -- Yes --> ShowStatus
FetchSuccess -- No --> ShowError
ShowStatus --> BackgroundRefetch
BackgroundRefetch -- Yes --> ShowStale
BackgroundRefetch -- No --> ShowStatus
classDef decision fill:#FF9800,stroke:#e65100,color:#000
classDef action fill:#2196F3,stroke:#0d47a1,color:#000
classDef error fill:#F44336,stroke:#b71c1c,color:#000
classDef start fill:#4CAF50,stroke:#2E7D32,color:#000
class Mount start
class HasCache,FetchSuccess,BackgroundRefetch decision
class Loading,ShowStatus action
class ShowError,ShowStale error
```
Flowchart showing dashboard fetch states: skeleton on first load, badge on success, error card (no data) or stale badge + error banner (with data) on failure.
@@ -0,0 +1,62 @@
# Domain Entities — Unit 4: Dashboard
## Entities
### AvailabilityStatus (enumeration)
A union type representing the three possible system states returned by the API.
| Value | Meaning |
|---|---|
| `Available` | System is fully operational |
| `Maintenance` | System is in planned maintenance mode |
| `Unavailable` | System is not accessible |
---
### AvailabilityResponse (API response shape)
Returned by `GET /api/v1/Availability/status`.
| Field | Type | Description |
|---|---|---|
| `status` | `AvailabilityStatus` | Current system status |
| `checkedAt` | `string` (ISO 8601) | Timestamp when status was last evaluated |
| `message` | `string` | Optional human-readable reason or note (may be empty string) |
---
## Entity Relationship Diagram
```mermaid
classDiagram
class AvailabilityResponse {
+status: AvailabilityStatus
+checkedAt: string
+message: string
}
class AvailabilityStatus {
<<enumeration>>
Available
Maintenance
Unavailable
}
AvailabilityResponse --> AvailabilityStatus : status
style AvailabilityResponse fill:#4CAF50,stroke:#2E7D32,color:#000
style AvailabilityStatus fill:#2196F3,stroke:#0D47A1,color:#000
```
AvailabilityResponse holds a status (one of three enumeration values) plus a timestamp and message.
---
## Type Placement
Both `AvailabilityStatus` and `AvailabilityResponse` are added to `frontend/src/api/types.ts` — the shared domain types file, consistent with `User`, `AuthResponse`, and `SetupStatus`.
---
## TanStack Query Dependency
`@tanstack/react-query` is added as a new production dependency. A shared `QueryClient` is configured in `main.tsx` and provided via `QueryClientProvider`. This enables `staleTime`, background refetching, and persistent cached data across error states.
@@ -0,0 +1,231 @@
# Frontend Components — Unit 4: Dashboard
## Component Hierarchy
```mermaid
graph TD
Main["main.tsx\n(App Entry)"]
QCP["QueryClientProvider\n(TanStack Query root)"]
AuthProv["AuthProvider"]
RouterProv["RouterProvider"]
DP["DashboardPage\n(/dashboard route)"]
Hook["useAvailabilityStatus\n(useQuery hook)"]
Welcome["Welcome Header\n(existing — unchanged)"]
Widget["Availability Widget\nsection"]
Badge["AvailabilityStatusBadge"]
Skeleton["Skeleton loading\n(isLoading)"]
ErrorBanner["Error Banner + Retry\n(isError)"]
StaleIndicator["Stale Indicator\n(isError + data)"]
Main --> QCP
QCP --> AuthProv
AuthProv --> RouterProv
RouterProv --> DP
DP --> Hook
DP --> Welcome
DP --> Widget
Widget --> Skeleton
Widget --> Badge
Widget --> ErrorBanner
Badge --> StaleIndicator
classDef infra fill:#4CAF50,stroke:#2E7D32,color:#000
classDef page fill:#2196F3,stroke:#0D47A1,color:#000
classDef hook fill:#9C27B0,stroke:#4A148C,color:#000
classDef ui fill:#FF9800,stroke:#E65100,color:#000
classDef error fill:#F44336,stroke:#B71C1C,color:#000
class Main,QCP,AuthProv,RouterProv infra
class DP page
class Hook hook
class Welcome,Widget,Badge,Skeleton,StaleIndicator ui
class ErrorBanner error
```
Component hierarchy: main.tsx wraps app with QueryClientProvider; DashboardPage consumes availability hook and renders welcome header + availability widget with conditional states.
---
## New / Modified Files
### 1. `frontend/src/main.tsx` (modify)
Add `QueryClientProvider` wrapping above `AuthProvider`. No other changes.
```tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: true },
},
});
// Wrap existing tree:
<QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} context={{ auth }} />
</AuthProvider>
</QueryClientProvider>
```
---
### 2. `frontend/src/api/types.ts` (modify)
Add two new types at the end of the file:
```ts
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'Unavailable';
export interface AvailabilityResponse {
status: AvailabilityStatus;
checkedAt: string; // ISO 8601
message: string;
}
```
---
### 3. `frontend/src/api/useAvailability.ts` (new)
```ts
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { AvailabilityResponse } from './types';
export function useAvailabilityStatus() {
return useQuery<AvailabilityResponse>({
queryKey: ['availability', 'status'],
queryFn: () => api.get<AvailabilityResponse>('/api/v1/Availability/status'),
staleTime: 30_000,
});
}
```
**Return**: standard TanStack Query result (`data`, `error`, `isLoading`, `isError`, `isFetching`, `refetch`).
---
### 4. `frontend/src/components/shared/AvailabilityStatusBadge.tsx` (new)
**Props**:
| Prop | Type | Required | Description |
|---|---|---|---|
| `status` | `AvailabilityStatus` | Yes | Current status value |
| `message` | `string` | No | Subtitle text below badge (only shown when non-empty) |
| `stale` | `boolean` | No | When true, renders a stale-data visual indicator (amber border, clock icon) |
**Status → color mapping**:
| Status | Badge color class | Icon |
|---|---|---|
| `Available` | `bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200` | `CheckCircle` |
| `Maintenance` | `bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200` | `AlertTriangle` |
| `Unavailable` | `bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200` | `XCircle` |
**`stale` prop behavior**: When `stale={true}`, the badge wrapper gets an amber dashed border and a `Clock` icon + `t('availability.staleLabel')` label is rendered below the badge.
**Test IDs**:
| Element | `data-testid` |
|---|---|
| Badge wrapper | `availability-badge` |
| Status label | `availability-status-label` |
| Message subtitle | `availability-message` |
| Stale indicator | `availability-stale-indicator` |
---
### 5. `frontend/src/pages/DashboardPage.tsx` (modify)
Replace the generic placeholder `Card` with the availability widget section.
**Conditional rendering logic**:
```
if (isLoading) → show Skeleton in widget area
if (isError && !data) → show error card with Retry (no status to show)
if (data) → show AvailabilityStatusBadge
if (isError) → also show stale indicator on badge + error banner with Retry
```
**Layout sketch** (inside existing `space-y-6` wrapper):
```
[Welcome header — unchanged]
[Card: title = t('availability.title')]
[if isLoading] → skeleton placeholder
[if !isLoading && data] → AvailabilityStatusBadge status={data.status} message={data.message} stale={isError}
[if isError] → error banner (below badge if stale, full-card if no data) + Retry button
```
---
### 6. `frontend/src/mocks/availability/handlers.ts` (new)
MSW handler for tests:
```ts
import { http, HttpResponse } from 'msw';
export const availabilityHandlers = [
http.get('*/Availability/status', () =>
HttpResponse.json({
status: 'Available',
checkedAt: new Date().toISOString(),
message: '',
})
),
];
```
Register in `frontend/src/mocks/browser.ts` and `frontend/src/mocks/server.ts`.
---
## i18n Additions
**`en/translation.json`**:
```json
{
"availability": {
"title": "System Status",
"available": "Available",
"maintenance": "Maintenance",
"unavailable": "Unavailable",
"errorTitle": "Could not load status",
"staleLabel": "Last known status",
"retry": "Retry"
}
}
```
**`nl/translation.json`**:
```json
{
"availability": {
"title": "Systeemstatus",
"available": "Beschikbaar",
"maintenance": "Onderhoud",
"unavailable": "Niet beschikbaar",
"errorTitle": "Status kon niet worden geladen",
"staleLabel": "Laatste bekende status",
"retry": "Opnieuw proberen"
}
}
```
---
## Test IDs Summary
| Element | `data-testid` |
|---|---|
| Availability badge wrapper | `availability-badge` |
| Status label text | `availability-status-label` |
| Message subtitle | `availability-message` |
| Stale indicator | `availability-stale-indicator` |
| Error banner | `availability-error` |
| Retry button | `availability-retry` |
| Widget skeleton | `availability-skeleton` |