From 7d72760dfe80237f7d24d0b3b7e19cd3bc13e185 Mon Sep 17 00:00:00 2001 From: emeraldleaf Date: Wed, 1 Jul 2026 20:37:44 -0600 Subject: [PATCH 1/2] feat(frontend): saga timeline + narrator on order detail (epic #130 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The centerpiece feature: the order-detail page now renders a live Placed → Paid → Shipped timeline off the polled order status, with a narrator panel explaining what the backend just did at each hop (202 + transactional outbox → RabbitMQ fanout → idempotent consumers → choreography, no orchestrator). Unblocked by the saga actually flowing locally on RabbitMQ (PR #159). - features/orders/saga.ts: OrderStatus union mirroring the backend enum, step model with teaching-grade narration copy, deriveStepStates + isSagaSettled. - api/orders.ts: status typed as OrderStatus; orderByIdQuery polls every 2s via dynamic refetchInterval while the saga is in flight and stops once it settles (Shipped/Delivered/Cancelled/PaymentFailed). Polling lives in the query definition, not component timers. - components/SagaTimeline.tsx: pure-render timeline + narrator panel; aria-current marks the live step; failure branch (PaymentFailed/Cancelled) renders loud, not stuck. - Tests (11 new, AAA-narrated per canon): saga helper table tests pin the full visual contract per status + the polling lifecycle; RTL component tests cover in-flight, settled, and failure states by role/label. typecheck + eslint clean; vitest 24/24. Co-Authored-By: Claude Fable 5 --- frontend/src/features/orders/api/orders.ts | 16 +++- .../orders/components/OrderDetail.tsx | 9 +- .../orders/components/SagaTimeline.test.tsx | 62 +++++++++++++ .../orders/components/SagaTimeline.tsx | 86 ++++++++++++++++++ frontend/src/features/orders/saga.test.ts | 40 ++++++++ frontend/src/features/orders/saga.ts | 91 +++++++++++++++++++ 6 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 frontend/src/features/orders/components/SagaTimeline.test.tsx create mode 100644 frontend/src/features/orders/components/SagaTimeline.tsx create mode 100644 frontend/src/features/orders/saga.test.ts create mode 100644 frontend/src/features/orders/saga.ts diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 653b51b9..7a08dab0 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -4,6 +4,8 @@ import { userManager } from '@/core/auth' import { env } from '@/core/env' import { getJsonAuthed } from '@/shared/api/http' +import { isSagaSettled, type OrderStatus } from '../saga' + export interface OrderLineSummary { productId: string productName: string @@ -14,13 +16,16 @@ export interface OrderLineSummary { export interface OrderSummary { orderId: string buyerId: string - status: string + status: OrderStatus totalAmount: number currency: string placedAt: string lines: OrderLineSummary[] } +/** Poll cadence while the saga is in flight — it completes in seconds on the live stack. */ +export const SAGA_POLL_INTERVAL_MS = 2000 + // Query-key factory per frontend/CLAUDE.md "Server state": [feature, entity, params]. export const orderKeys = { byBuyer: (buyerId: string) => ['orders', 'byBuyer', buyerId] as const, @@ -51,5 +56,14 @@ export function orderByIdQuery(orderId: string) { const { data } = await getJsonAuthed(env.orderApiUrl, `/api/v1/orders/${orderId}`, await token(), signal) return data }, + // The saga narrator: poll while the order is still moving through the saga + // (Placed/Paid), stop the moment it settles (Shipped/Delivered/Cancelled/ + // PaymentFailed). Polling lives HERE, in the query definition, so every consumer + // of this query gets the same lifecycle — no component-level timers. + refetchInterval: (query) => { + const status = query.state.data?.status + if (status == null || isSagaSettled(status)) return false + return SAGA_POLL_INTERVAL_MS + }, }) } diff --git a/frontend/src/features/orders/components/OrderDetail.tsx b/frontend/src/features/orders/components/OrderDetail.tsx index f43b0337..f4409330 100644 --- a/frontend/src/features/orders/components/OrderDetail.tsx +++ b/frontend/src/features/orders/components/OrderDetail.tsx @@ -4,10 +4,12 @@ import { formatPrice } from '@/shared/format' import { orderByIdQuery } from '../api/orders' +import { SagaTimeline } from './SagaTimeline' + /** - * Single order view. Phase 3 turns the status line into the saga timeline + narrator - * (Placed → Paid → Shipped with "what the backend just did" panels) and polls this query; - * for now it's a static read of current status. + * Single order view — the saga-narrator page (epic #130 Phase 3). orderByIdQuery polls + * while the saga is in flight, so the timeline below advances live as PaymentService and + * ShippingService consume and re-publish events; polling stops once the order settles. */ export function OrderDetail({ orderId }: Readonly<{ orderId: string }>) { const { data, isPending, isError, fetchStatus } = useQuery(orderByIdQuery(orderId)) @@ -33,6 +35,7 @@ export function OrderDetail({ orderId }: Readonly<{ orderId: string }>) {

Order {data.orderId.slice(0, 8)}

{data.status} +
    {data.lines.map((line) => (
  • diff --git a/frontend/src/features/orders/components/SagaTimeline.test.tsx b/frontend/src/features/orders/components/SagaTimeline.test.tsx new file mode 100644 index 00000000..03412cb6 --- /dev/null +++ b/frontend/src/features/orders/components/SagaTimeline.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { SagaTimeline } from './SagaTimeline' + +// Pure-render component (polling lives in the query, derivation in saga.ts), so these +// tests assert the user-visible contract per status — RTL by role/label, no internals. +describe('SagaTimeline', () => { + it('marks the live step and explains the async 202 while the order is Placed', () => { + // ARRANGE/ACT — freshly placed order: the saga has NOT run yet; this is the state a + // buyer lands on right after checkout redirects here. + render() + + // ASSERT — + // 1. The Placed step carries aria-current="step": screen readers and tests agree on + // which step is live (the pulsing dot alone would be color-only signaling). + const steps = screen.getAllByRole('listitem') + expect(steps[0]).toHaveAttribute('aria-current', 'step') + // 2. The narrator explains the load-bearing backend fact — 202 + outbox — because + // "why is my order not confirmed instantly" is exactly what the panel exists to teach. + expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/202 Accepted/) + expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/transactional outbox/) + // 3. The polling hint is shown — the saga is still moving. + expect(screen.getByText(/polls the order every 2/)).toBeInTheDocument() + }) + + it('narrates the payment hop when the order reaches Paid', () => { + // ARRANGE/ACT — mid-saga: PaymentService has consumed and re-published. + render() + + // ASSERT — the narration advances with the saga: now it teaches the consume→publish + // hop and idempotency (at-least-once delivery), not the 202 story. + const panel = screen.getByLabelText('What the backend just did') + expect(panel).toHaveTextContent(/PaymentService consumed OrderPlacedEvent/) + expect(panel).toHaveTextContent(/idempotent/) + }) + + it('shows a fully-complete timeline with no polling hint once Shipped', () => { + // ARRANGE/ACT — the saga settled on the happy path. + render() + + // ASSERT — + // 1. No step is "current" anymore — nothing is in flight. + for (const step of screen.getAllByRole('listitem')) { + expect(step).not.toHaveAttribute('aria-current') + } + // 2. The final narration teaches the punchline: choreography, no orchestrator. + expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/choreography saga/) + // 3. Polling hint gone — isSagaSettled stopped the refetch loop, the UI says so implicitly. + expect(screen.queryByText(/polls the order every 2/)).not.toBeInTheDocument() + }) + + it('surfaces the failure branch when payment is declined', () => { + // ARRANGE/ACT — the saga's compensating path: PaymentFailedEvent came back instead. + render() + + // ASSERT — the failure is loud and explained, not a silent stuck timeline: + // the panel switches to the terminal narration naming the failure event. + expect(screen.getByText(/Saga stopped: PaymentFailed/)).toBeInTheDocument() + expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/PaymentFailedEvent/) + }) +}) diff --git a/frontend/src/features/orders/components/SagaTimeline.tsx b/frontend/src/features/orders/components/SagaTimeline.tsx new file mode 100644 index 00000000..04bafb71 --- /dev/null +++ b/frontend/src/features/orders/components/SagaTimeline.tsx @@ -0,0 +1,86 @@ +import { SAGA_STEPS, TERMINAL_NARRATION, deriveStepStates, isSagaSettled, type OrderStatus, type StepState } from '../saga' + +/** + * The saga narrator (epic #130 Phase 3): a Placed → Paid → Shipped timeline that renders + * live off the polled order status, plus a panel explaining what the backend just did to + * reach the current step. The demo IS the engineering — this component turns the + * choreography saga (outbox → RabbitMQ fanout → idempotent consumers) into something you + * can watch happen. + * + * Pure render off the status prop: polling cadence lives in orderByIdQuery, narration + * copy + step derivation live in ../saga.ts. This file only draws. + */ + +const DOT_CLASSES: Record = { + complete: 'bg-emerald-600 text-white', + active: 'animate-pulse bg-blue-600 text-white', + failed: 'bg-red-600 text-white', + pending: 'bg-zinc-200 text-zinc-500', +} + +function dotGlyph(state: StepState, index: number): string { + if (state === 'complete') return '✓' + if (state === 'failed') return '✕' + return String(index + 1) +} + +export function SagaTimeline({ status }: Readonly<{ status: OrderStatus }>) { + const stepStates = deriveStepStates(status) + const settled = isSagaSettled(status) + // Narrate the terminal branch if the saga died, otherwise the furthest step it reached. + const terminalNarration = TERMINAL_NARRATION[status] + let narratedIndex = -1 + for (let index = stepStates.length - 1; index >= 0; index--) { + if (stepStates[index] !== 'pending') { + narratedIndex = index + break + } + } + const narration = terminalNarration ?? SAGA_STEPS[narratedIndex]?.narration + const panelClasses = + terminalNarration == null ? 'border-zinc-200 bg-zinc-50 text-zinc-700' : 'border-red-200 bg-red-50 text-red-900' + const panelHeading = terminalNarration == null ? 'What the backend just did' : `Saga stopped: ${status}` + + return ( +
    +
      + {SAGA_STEPS.map((step, index) => { + // deriveStepStates maps 1:1 over SAGA_STEPS; the fallback only satisfies + // noUncheckedIndexedAccess (the index can't actually miss). + const state = stepStates[index] ?? 'pending' + return ( +
    1. + + {dotGlyph(state, index)} + + + {step.title} + {/* Live step gets a visually-quiet hint that the page is polling. */} + {state === 'active' && (in progress…)} + + {index < SAGA_STEPS.length - 1 && ( + + )} +
    2. + ) + })} +
    + + {status === 'Delivered' &&

    Delivered — the saga completed end-to-end.

    } + + {narration != null && ( + + )} +
    + ) +} diff --git a/frontend/src/features/orders/saga.test.ts b/frontend/src/features/orders/saga.test.ts new file mode 100644 index 00000000..a9e8cf67 --- /dev/null +++ b/frontend/src/features/orders/saga.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' + +import { deriveStepStates, isSagaSettled, type OrderStatus } from './saga' + +describe('isSagaSettled', () => { + it('keeps polling while the saga is in flight and stops once it settles', () => { + // ARRANGE/ACT/ASSERT — table test: this predicate IS the polling lifecycle + // (orderByIdQuery's refetchInterval returns false exactly when this returns true), + // so a wrong entry here means either polling forever (battery/network burn) or + // a timeline frozen at Placed while the backend moves on. + expect(isSagaSettled('Placed')).toBe(false) // saga just started — keep watching + expect(isSagaSettled('Paid')).toBe(false) // mid-saga — shipping still to come + expect(isSagaSettled('Shipped')).toBe(true) // demo happy-path terminal + expect(isSagaSettled('Delivered')).toBe(true) // post-shipping terminal + expect(isSagaSettled('Cancelled')).toBe(true) // cancel branch — nothing more will happen + expect(isSagaSettled('PaymentFailed')).toBe(true) // failure branch — saga stopped + }) +}) + +describe('deriveStepStates', () => { + // Timeline is [Placed, Paid, Shipped]; each case pins the full visual contract for a status. + const cases: [OrderStatus, ReturnType][] = [ + // In-flight: the reached step pulses "active" (page is polling), the rest are pending. + ['Placed', ['active', 'pending', 'pending']], + ['Paid', ['complete', 'active', 'pending']], + // Settled happy path: everything reached is complete — no "active" pulse once polling stops. + ['Shipped', ['complete', 'complete', 'complete']], + ['Delivered', ['complete', 'complete', 'complete']], + // Failure branch: Placed succeeded, the payment step is where the saga died. + ['PaymentFailed', ['complete', 'failed', 'pending']], + ['Cancelled', ['complete', 'failed', 'pending']], + ] + + it.each(cases)('%s renders as %j', (status, expected) => { + // ACT — derive the per-step visual states from the polled status. + // ASSERT — the whole array at once: a step silently flipping between + // active/complete is exactly the regression class this guards. + expect(deriveStepStates(status)).toEqual(expected) + }) +}) diff --git a/frontend/src/features/orders/saga.ts b/frontend/src/features/orders/saga.ts new file mode 100644 index 00000000..a2253c42 --- /dev/null +++ b/frontend/src/features/orders/saga.ts @@ -0,0 +1,91 @@ +// The saga-narrator model: what the timeline shows and what each step teaches. +// +// This feature IS the documentation (frontend/CLAUDE.md "Conventions") — the narration +// strings explain, in the UI, what the backend just did. They mirror the real mechanics +// in OrderService/PaymentService/ShippingService: transactional outbox, RabbitMQ fanout +// exchanges, idempotent consumers, choreography (no orchestrator). If the backend's saga +// changes shape, this copy changes with it — it's a projection of the architecture, not +// marketing text. + +/** Mirror of OrderService.Domain.OrderStatus — persisted (and serialized) as strings. */ +export type OrderStatus = 'Placed' | 'Paid' | 'Shipped' | 'Delivered' | 'Cancelled' | 'PaymentFailed' + +export type StepState = 'complete' | 'active' | 'pending' | 'failed' + +export interface SagaStep { + status: OrderStatus + title: string + /** What the backend just did to reach this step — shown in the narrator panel. */ + narration: string +} + +/** The happy-path choreography, in order. Failure/cancel states overlay onto these. */ +export const SAGA_STEPS: readonly SagaStep[] = [ + { + status: 'Placed', + title: 'Placed', + narration: + 'POST /orders returned 202 Accepted, not 201 — placement is asynchronous and the order row itself is the tracking record. ' + + 'OrderService saved the order and staged OrderPlacedEvent in the SAME database transaction (transactional outbox — the event cannot be lost, even if the broker is down), ' + + 'then Wolverine published it to the order-events fanout exchange on RabbitMQ. This page is polling while the saga runs.', + }, + { + status: 'Paid', + title: 'Paid', + narration: + 'PaymentService consumed OrderPlacedEvent from its payment-orders queue, processed the payment, and published PaymentCompletedEvent to the payment-events exchange. ' + + 'OrderService consumed that and marked the order Paid. Delivery is at-least-once, so the handler is idempotent — a duplicate event hits the status guard and is ignored.', + }, + { + status: 'Shipped', + title: 'Shipped', + narration: + 'ShippingService reacted to the same PaymentCompletedEvent (its own queue on the fanout exchange), dispatched a shipment, and published ShipmentDispatchedEvent; ' + + 'OrderService marked the order Shipped. No orchestrator coordinated any of this — every service just reacted to events. That is the choreography saga.', + }, +] + +/** Narration for the non-happy-path terminals (rendered in place of the next step's panel). */ +export const TERMINAL_NARRATION: Partial> = { + PaymentFailed: + 'PaymentService declined the payment and published PaymentFailedEvent; OrderService consumed it and marked the order PaymentFailed. ' + + 'This is the saga’s failure branch — same event mechanics as the happy path, different event.', + Cancelled: 'The order was cancelled before it shipped. Cancel is allowed from any non-shipped state.', +} + +/** How far along the happy path a status is. Delivered sits past Shipped; terminals sit where they interrupted. */ +const PROGRESS: Record = { + Placed: 0, + Paid: 1, + Shipped: 2, + Delivered: 3, + // Failure terminals: how many happy steps completed before the saga stopped. + PaymentFailed: 1, // Placed completed; payment step failed. + Cancelled: 1, // Placed completed; cancelled before shipping. +} + +/** The saga stopped moving — stop polling. */ +export function isSagaSettled(status: OrderStatus): boolean { + return status === 'Shipped' || status === 'Delivered' || status === 'Cancelled' || status === 'PaymentFailed' +} + +const FAILURE_TERMINALS: ReadonlySet = new Set(['PaymentFailed', 'Cancelled']) + +/** + * Derive each happy-path step's visual state from the order's current status. + * A step is complete once reached, active if it's the furthest reached and the saga is + * still moving, failed if the saga terminated at that step, pending otherwise. + */ +export function deriveStepStates(status: OrderStatus): StepState[] { + const progress = PROGRESS[status] + return SAGA_STEPS.map((_, index) => { + if (index < progress) return 'complete' + if (index === progress) { + if (FAILURE_TERMINALS.has(status)) return 'failed' + // The furthest-reached step: completed if the saga already moved past or settled + // here (Shipped/Delivered), otherwise it's the live step we're polling on. + return isSagaSettled(status) ? 'complete' : 'active' + } + return 'pending' + }) +} From 4eb6c62e62590d681c688ab3c500337e6b6c9e7f Mon Sep 17 00:00:00 2001 From: emeraldleaf Date: Sun, 12 Jul 2026 17:43:16 -0600 Subject: [PATCH 2/2] =?UTF-8?q?fix(frontend):=20Cancelled=20renders=20hone?= =?UTF-8?q?stly=20=E2=80=94=20no=20failure=20glyph=20on=20a=20step=20the?= =?UTF-8?q?=20status=20can't=20blame?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit findings on #167: Cancel() is legal from both Placed and Paid, and the DTO carries only the current status — so PROGRESS[Cancelled]=1 rendering Paid as 'failed' misstated a cancel-before-payment. Cancelled now renders Placed complete + remaining steps pending; the terminal panel (Saga stopped: Cancelled) carries the story. Adds the missing Cancelled error-path tests (derivation table + timeline render), per the frontend canon's error-path coverage rule. Co-Authored-By: Claude Fable 5 --- .../orders/components/SagaTimeline.test.tsx | 16 ++++++++++++++++ frontend/src/features/orders/saga.test.ts | 6 +++++- frontend/src/features/orders/saga.ts | 10 +++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/orders/components/SagaTimeline.test.tsx b/frontend/src/features/orders/components/SagaTimeline.test.tsx index 03412cb6..f377afe3 100644 --- a/frontend/src/features/orders/components/SagaTimeline.test.tsx +++ b/frontend/src/features/orders/components/SagaTimeline.test.tsx @@ -59,4 +59,20 @@ describe('SagaTimeline', () => { expect(screen.getByText(/Saga stopped: PaymentFailed/)).toBeInTheDocument() expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/PaymentFailedEvent/) }) + + it('surfaces the cancel branch without blaming the payment step', () => { + // ARRANGE/ACT — the other terminal: the buyer (or a compensation) cancelled the order. + render() + + // ASSERT — + // 1. The terminal panel is loud and names the state, same contract as PaymentFailed. + expect(screen.getByText(/Saga stopped: Cancelled/)).toBeInTheDocument() + expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/cancelled/i) + // 2. No step renders the ✕ failure glyph: Cancel() is legal from Placed AND Paid, and + // the status alone can't say whether payment happened — a red Paid step would + // misstate a cancel-before-payment. (The failure glyph is PaymentFailed's contract.) + expect(screen.queryByText('✕')).not.toBeInTheDocument() + // 3. Settled: no polling hint — nothing more will happen to this order. + expect(screen.queryByText(/polls the order every 2/)).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/features/orders/saga.test.ts b/frontend/src/features/orders/saga.test.ts index a9e8cf67..b78ec711 100644 --- a/frontend/src/features/orders/saga.test.ts +++ b/frontend/src/features/orders/saga.test.ts @@ -28,7 +28,11 @@ describe('deriveStepStates', () => { ['Delivered', ['complete', 'complete', 'complete']], // Failure branch: Placed succeeded, the payment step is where the saga died. ['PaymentFailed', ['complete', 'failed', 'pending']], - ['Cancelled', ['complete', 'failed', 'pending']], + // Cancelled: terminal but NOT a step failure. The DTO carries only the current status + // and the backend allows Cancel() from both Placed and Paid, so whether payment ever + // happened is unknowable client-side — marking Paid as failed would misstate a + // cancel-before-payment. Remaining steps stay pending; the terminal panel tells the story. + ['Cancelled', ['complete', 'pending', 'pending']], ] it.each(cases)('%s renders as %j', (status, expected) => { diff --git a/frontend/src/features/orders/saga.ts b/frontend/src/features/orders/saga.ts index a2253c42..0f2dc576 100644 --- a/frontend/src/features/orders/saga.ts +++ b/frontend/src/features/orders/saga.ts @@ -69,8 +69,6 @@ export function isSagaSettled(status: OrderStatus): boolean { return status === 'Shipped' || status === 'Delivered' || status === 'Cancelled' || status === 'PaymentFailed' } -const FAILURE_TERMINALS: ReadonlySet = new Set(['PaymentFailed', 'Cancelled']) - /** * Derive each happy-path step's visual state from the order's current status. * A step is complete once reached, active if it's the furthest reached and the saga is @@ -81,7 +79,13 @@ export function deriveStepStates(status: OrderStatus): StepState[] { return SAGA_STEPS.map((_, index) => { if (index < progress) return 'complete' if (index === progress) { - if (FAILURE_TERMINALS.has(status)) return 'failed' + if (status === 'PaymentFailed') return 'failed' + // Cancelled is terminal but NOT a step failure: the DTO carries only the current + // status, and the backend allows Cancel() from both Placed and Paid — so whether + // payment ever happened is unknowable here. Marking Paid as failed would misstate + // a cancel-before-payment; leave the remaining steps pending and let the terminal + // panel (TERMINAL_NARRATION) tell the story. + if (status === 'Cancelled') return 'pending' // The furthest-reached step: completed if the saga already moved past or settled // here (Shipped/Delivered), otherwise it's the live step we're polling on. return isSagaSettled(status) ? 'complete' : 'active'