Repository navigation
feat: customer UI rebuild -- 5-destination IA, real-time push, tax-lot designation, pagination - #33
Merged
Merged
Conversation
…oggle Lays the foundation later frontend tasks build on: test infra (vitest + testing-library, jsdom, one trivial passing test) so every future component can ship with tests as CLAUDE.md requires; three additive type-scale tokens (display-xl, title, section) for the upcoming 5-destination redesign; and an explicit light/dark/system theme toggle (design-system.md §2.2) with its context, persistence, and three new icon glyphs. Nothing here is mounted in the app shell yet -- that's a later task. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HV5c66D3RU7zTqGjdtBnFz
Adds a read-only security catalogue endpoint for a future watchlist feature: SecurityRepository.list_active (status-filtered, keyset-paginated) and DailyCloseRepository.latest_for_securities (one batched query, ordered market_date before recorded_at so a late correction never outranks a genuinely newer close), a SecurityCatalogService, and the controller/view pair following the profile.py four-file pattern. Any authenticated session (customer or staff) may read it, since the catalogue is not tenant-scoped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HV5c66D3RU7zTqGjdtBnFz
…0/ADR-4) Lets a customer name which tax lots to sell at order-placement time. The consumption-time domain logic in LotConsumptionService already implemented and tested the specific-ID branch -- this wires the request path to it: - order.designated_lot_ids (nullable UUID array; NULL means FIFO, unchanged for every existing order) - OrderRepository.open_sell_orders, mirroring open_buy_orders - LotDesignationService validates side, shape (non-empty, no duplicates, <=50 lots), ownership, and coverage net of what other open sell orders have already claimed from the same lots -- no FIFO fallback for a shortfall, ever - POST /orders accepts lot_ids, maps each new validation error to its own 422 code, and echoes designated_lot_ids in the response - trade_update_handler passes order.designated_lot_ids through to record_sell_fill on every fill event Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HV5c66D3RU7zTqGjdtBnFz
GET /orders, /lots, /funding/history, /valuation/history, and /statements returned every row for a customer with no upper bound. Each now accepts ?limit=&cursor= (default page size 50, max 200) and adds a purely-additive next_cursor field to its response, reusing app/core/pagination.py's established keyset (limit+1 fetch, opaque cursor) convention. Orders and funding history gained a new stability tiebreak (id) where their prior ordering had none; statements' existing tiebreak moved from publish_watermark to id per the paginated cursor-key contract.
The statements pagination cursor key was (period_start, id) DESC, but period_start is not unique per customer (FR-26: a period can be republished under multiple watermarks) and publish_watermark is what latest_for_period already uses to mean "most recent republish first." The id-only tiebreak silently discarded that order for any customer with more than one snapshot per period_start. Cursor key is now (period_start, publish_watermark, id) DESC, matching the 3-tuple shape already used for funding/history and valuation/history in this same change; id remains only as the final tamper-evident tiebreak.
…g history, and statements Task 5 of the fullstack-rebuild plan. Extends the canonical read-hook shape (Global Constraints) with a `loading-more` status and a `loadMore` action, consuming Task 4's `next_cursor` contract exactly: `useOrders.js`, `useLots.js`, `useStatements.js`, and two new hooks split out for the upcoming Task 8/10 page rewrites -- `useFundingHistory.js` (cash-summary fetching stays on the existing `useFunding.js` for now, per the plan) and `useValuationHistory.js`. `loadMore` appends rather than replaces; `refetch` still means page 1, full replacement. New `paginationQuery.js` centralizes the query-string building all five hooks share. UI: `OrderList.jsx` and `FundingHistoryTable.jsx` get a trailing "Load more" button, disabled/loading while a page is in flight, hidden once `nextCursor` is null. Built against Task 4's real, already-committed backend, not a mock. Also commits `docs/superpowers/plans/2026-09-08-trueup-fullstack-rebuild.md`, the plan this branch follows -- it needed a home in the series. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
Task 6 of the fullstack-rebuild plan, built to docs/specs/12-production-operations.md §6. New `EventBusPort` protocol (app/integrations/ports.py) with a `RedisEventBus` adapter and a `FakeEventBus` test double, added to `.importlinter`'s services-use-ports-only contract alongside the existing alpaca/plaid/stripe/azure entries. `EventPublisher` (app/services/ops/event_publisher.py) is the only place a channel name or message shape is constructed: `order_updated`, `identity_status_changed` (kyc/account_approval gates publish independently), `break_opened` (always to `adviser:events`). Three publish sites, each strictly after the triggering commit, never inside it: `AlpacaTradeUpdateHandler.handle()` on fill/terminal-non-filled status, `stripe_identity.py::_apply_verdict` on each of its two commits, and `morning_reconciliation.py` once per opened break after the job's own commit -- `ReconciliationService` itself is untouched. New `GET /api/v1/events/stream` (self-scoped, customer session) and `GET /api/v1/admin/events/stream` (adviser/admin only), same SSE framing as chat.py's existing stream: `data: <json>` per message, a `: ping` comment frame every 20s of silence, closes after ~30 minutes so `EventSource` reconnects rather than pinning a worker forever. Every outgoing frame validates against a new `EventMessage` schema. The pushed payload is a pointer only (event_type/entity_id/summary) -- the client always re-fetches; a dropped connection is never data loss. Dockerfile switches gunicorn to `--worker-class gthread` (2 workers x 32 threads, 60s timeout): a sync worker's arbiter heartbeat only refreshes between requests, so it would SIGKILL a 30-minute SSE stream regardless of --timeout, taking any other in-flight request on that worker down with it. nginx.conf.template disables proxy buffering and extends the read timeout for the two stream paths specifically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…rovider Task 7 of the fullstack-rebuild plan. New `eventStream.js` (pure infra, same category as apiClient.js) wraps a singleton EventSource against Task 6's stream, with `subscribe(eventTypes, listener)`/backoff reconnect on repeated immediate failure. `EventStreamContext.jsx` connects on authenticated session, disconnects on logout/unmount. New `useLiveRefetch(eventTypes, refetch)` hook is the sole integration surface Tasks 8-12 use to wire live updates -- built and tested here, not called anywhere yet. `App.jsx`: five destinations (Dashboard/Invest/Money/Account/Chat) replace the previous ten routes, with a `<Navigate replace>` redirect from every old path so existing links don't 404. `/onboarding` stays a sibling route outside `RequireOnboarded`, guarded only by role -- unchanged from today, traced explicitly to avoid the redirect-loop trap nesting it would cause. `AppLayout.jsx`/`.css`: five nav links replace the grouped-links nav; `useDismissablePanel`/`getFocusable`/`initialsFromEmail`/`AccountMenu`/ `NavDrawer` kept verbatim. Mounts `EventStreamProvider` and `ThemeProvider` inside `SessionProvider`; `ThemeToggle` added to the header's right-hand cluster. The one property that must survive exactly on whatever now plays `.tu-app-layout__main`'s role: `display:flex; flex-direction:column; flex:1; min-height:0` -- Chat's transcript depends on it to scroll internally instead of the whole page scrolling. New `AppLayout.test.jsx` renders the shell and asserts those four computed properties on the main content element, specifically so a future change here can't silently regress Chat without a test failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…ist, ledger band Task 8 of the fullstack-rebuild plan. New hooks: `useHoldings` (a 404 with code "no_model_assigned" maps to status 'unassigned', rendering the existing `AssignmentPrompt` rather than `ErrorState`), `usePerformance`, `useCashSummary` (split out of `useFunding.js`, cash-summary half only). New `watchlist/` feature dir -- membership in localStorage (`trueup.watchlist.v1`), every read/write wrapped in try/catch so private-mode/storage-disabled renders empty state rather than throwing; prices from the real `GET /api/v1/securities`, no simulated generator. `securities/` feature dir replaces `portfolio/hooks/useSecurities.js` (deleted), now calling `GET /api/v1/securities` directly instead of deriving the universe from portfolio target weights. New `ValuationChart` (hand-rolled SVG line chart, no charting library -- pointer/keyboard scrub with an aria-live readout, dashed rendering for the provisional trailing segment, EmptyState under 2 points, reduced-motion respected) and `DriftMeter` (fill to current weight, tick at target, signed drift text, distinct treatment when flagged, no meter for the no-target CASH row). `HoldingsPanel` and `LedgerBand` (expandable bitemporal effective-vs-recorded rows, Task 5's "Load more") compose the page. `useHoldings`/`useCashSummary`/`useBalance` each gain one line -- `useLiveRefetch(['order_updated'], refetch)`. Backend: adds `previous_close` to the security-catalogue response (`views/securities.py`, `security_catalog_service.py`, `controllers/api/ securities.py`, `models/marketdata/daily_close.py`) for the watchlist's day-over-day change display. Fixes `tests/api/test_securities.py`'s two happy-path assertions, written in Task 2 before this field existed, to include it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…-picker Task 9 of the fullstack-rebuild plan. Folds `PortfolioPage.jsx` and `LotsPage.jsx` into a new `InvestPage.jsx`, reusing Task 8's `useHoldings`/`DriftMeter` rather than duplicating them. Deletes `OrdersPage.jsx` (18 lines of pure chrome) -- its header and "New order" button move directly into `InvestPage`. `OrderNewPage.jsx`/ `OrderDetailPage.jsx` are restyle-only, same hooks and logic as before. `useOrders`/`useOrder` each gain `useLiveRefetch(['order_updated'], refetch)`. `usePlaceOrder` gains a `lotIds` param threaded to `POST /orders`'s `lot_ids` field (Task 3's contract), meaningful only for sell orders. New `LotPicker`/`useLotPicker`: shown only when the order form's side is 'sell', sourced from lots with `quantity_remaining > 0` for the selected security. Shows each candidate's remaining quantity, acquisition date, and unrealized gain/loss (already on `GET /lots`, not re-derived); disables submission when the selection under-covers the requested quantity as a UX nicety -- Task 3's server-side check remains the actual authority. No selection means FIFO, the default, undo-able state. `OrderDetail.jsx` renders `designated_lot_ids` when present. `LotTable.jsx` gets a visible wash-sale-disallowed column (`wash_sale_disallowed`, already returned by the backend, previously ignored by the UI) -- always present as a column rather than a conditional badge, since the field is always populated (Money, "0.00" when nothing was disallowed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…dling Task 10 of the fullstack-rebuild plan. Folds `FundingPage.jsx` and `FeesPage.jsx` into a new `MoneyPage.jsx`; deposit/withdraw becomes a segmented control over one form, existing `DepositForm`/`WithdrawForm` logic preserved, presentation restyled. Finishes the `useFunding.js` split Task 8 started: cash-summary and history reading are fully removed now that `useCashSummary`/ `useFundingHistory` own them, and `useFunding.js` is deleted with every remaining import updated to the two new hooks -- no half-used old/new pair left live. `useCurrentBankLink` now handles `requires_reauth` (modeled server-side per FR-43, never handled client-side before this): renders a distinct "re-link your bank" interstitial instead of treating it as `active`, and pauses -- never silently retries or fails -- any in-flight deposit/withdraw flow that depends on the link. Cash pair (withdrawable/investable) rendered at equal visual weight per ADR 5, reusing the same presentational treatment Task 8's dashboard already uses rather than rebuilding it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…statements Task 11 of the fullstack-rebuild plan. New `AccountPage.jsx` composes profile, both identity gates, bank link, and statements -- the same fold pattern Tasks 9/10 used for Invest/Money. Deletes `StatementsPage.jsx` (11 lines of pure chrome); `StatementList` inlines directly into `AccountPage`. `/account` sits inside `RequireOnboarded`; the separate `/onboarding` route stays outside it, unchanged from Task 7's trace -- `AccountPage` links out to `/onboarding` for an in-progress customer rather than embedding its steps. New `features/profile/` -- entirely net-new, `GET`/`PATCH /profile` had no frontend feature at all before this. `PATCH` is partial-update: only a field the user actually touched is included in the body, so "didn't touch" and "explicitly cleared" stay distinguishable; `null` values render as "Not set", never as a blank field that could read as still loading. `useIdentityStatus` now surfaces `kyc_status` and `account_approval_status` as two independent, separately-labeled indicators (previously only one was shown), plus `useLiveRefetch(['identity_status_changed'], refetch)`. Bonus fix riding along: `RequireOnboarded` (routes/guards.jsx) called `/identity/status` on every navigation despite its 30/min limit being shared with every other caller of that route; it now reads from `SessionContext`'s cached status, refreshed only by the live event above. `/onboarding`'s reachability re-traced end to end to confirm this doesn't change it. `OnboardingPage.jsx` restyled only; its "You're all set" CTA now points to `/invest` (was `/portfolio`, which no longer exists after Task 9). `useStatements` (Task 5) gets its first real page consumer; the restated-figure treatment (FR-26, as-published struck through alongside as-corrected) moves to a side-by-side presentation on the list itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
…t to real endpoints Task 12 of the fullstack-rebuild plan. `adminCustomersApi.js` was entirely backed by `mockClient` despite the real endpoints (`GET /admin/customers`, `GET /admin/customers/<id>`, `GET /admin/customers/<id>/fees`, `POST /admin/kyc-overrides/<id>`) already existing and working. Every method now calls `apiClient` against the real paths. `GET /admin/customers` is already server-side paginated -- given Task 5's "Load more" hook shape rather than a bespoke pagination UI. `useBreaks` gains `useLiveRefetch(['break_opened'], refetch)`, subscribing via `eventStream.js`'s admin-stream variant. `mockClient.js` itself is untouched and not deleted -- it stays as documented pure infra even with its last live consumer gone, per the approved plan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
The runtime image's apt step installed libpq5 but never upgraded the base image's own packages, so a fresh build silently ships whatever CVEs were open in python:3.12-slim's last-published layer regardless of anything this repo controls. Confirmed via CI: PR #33's Docker/Trivy scan failed on 3 CRITICAL + 9 HIGH CVEs (gzip, perl-base, libsqlite3-0, libpcre2-8-0) -- and confirmed unrelated to this branch by building main's own unmodified Dockerfile fresh and getting the byte-identical CVE list (main's last green CI run was a week before this one; the fix pulls patched Debian packages published since). `apt-get upgrade -y` after the existing install resolves all 12 -- verified locally with `trivy image --severity HIGH,CRITICAL --ignore-unfixed`: 0 findings across the OS layer and every Python dependency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VwHwPi8sLxxcmbJ1ez8u2p
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Six frontend domains (portfolio, lots, fees, chat, admin-customers, statement export) were wired
to
frontend/src/services/mockClient.jsinstead of the real, already-working backend, and thecustomer IA was ten routes spanning duplicate/overlapping pages. Backend was also missing
specific-ID tax-lot designation at sell time (FR-20/ADR-4), cursor pagination on 5 unbounded list
endpoints, and any real-time push mechanism (NFR-18) -- every data update required a manual
refresh.
Implementation
12 tasks (full spec:
docs/superpowers/specs/2026-09-05-frontend-completion-design.md; executionplan:
docs/superpowers/plans/2026-09-08-trueup-fullstack-rebuild.md, committed on this branch):before this.
GET /api/v1/securities-- watchlist read endpoint, cursor-paginated.POST /orders'slot_idsfield, server-side validated.
valuation history, statements).
EventBusPort/RedisEventBus,EventPublisher, two new SSEendpoints (
/api/v1/events/stream, self-scoped;/api/v1/admin/events/stream, adviser/adminonly), 3 publish sites strictly after their triggering commit.
the previous 10 routes, with redirects from every old path;
EventStreamProvider; alayout-contract test protecting Chat's internal scroll.
meters, a watchlist (localStorage membership, real prices), a bitemporal ledger band. Adds
previous_closeto the security-catalogue response for day-over-day change.disallowed column, previously ignored by the UI despite the backend always returning it.
requires_reauthbank-link state (FR-43, modeledserver-side, never handled client-side before this).
GET/PATCH /profilehad zero frontend before this, entirely net-new),both identity gates shown independently, statements with side-by-side restated-figure
presentation (FR-26). Bonus fix:
RequireOnboardedno longer re-fetches identity status onevery navigation, now reads the session-cached value.
mockClientto the realendpoints; live breaks via the SSE admin stream.
Validation evidence
ruff check app testsmain's own baseline, verified side by side. No new issues.mypy --strict appopenai_agent_adapter.py:171issue. No new issues.lint-importsapp.integrations.redisentry.alembic upgrade headpytest(real Postgres/Redis)npm run lint(oxlint)npm run buildnpm test(Vitest)GET /api/v1/events/stream-> 200, observed in the network panel, not just unit-tested);RequireOnboardedcorrectly gates Dashboard/Invest/Chat for an unonboarded customer and settles cleanly at/onboardingwith both identity gates shown -- no redirect loop, no crash; zero console errors throughout.Two things intentionally not closed in this PR
Frontend test coverage. 39 new/changed
.jsxcomponent/hook files have no colocated test(only
ThemeToggle,AppLayout, and one util currently do). RootCLAUDE.mdcalls for a testper component; this PR does not meet that bar for the newly built surface. Tracked as required
follow-up, not silently shipped.
SSE endpoint test coverage.
controllers/api/events.py(41%) andcontrollers/admin/events.py(55%) have real gaps -- specifically the two cases the execution plan called out as the point of
having tests here at all: that a customer session can only ever receive messages published to
its own channel, never another customer's, and that
@requires_roleactually blocks a customersession from the admin stream.
EventPublisher(82%) has incidental coverage via the 3 callsites' existing integration tests; the HTTP-facing stream endpoints themselves do not. This is a
real, named gap on new authenticated endpoints, distinct from the general frontend coverage
follow-up above, and worth prioritizing before or shortly after merge.
Not yet manually verified (would need a fully onboarded test customer -- Stripe Identity +
Plaid sandbox flows -- beyond this smoke test's scope): Chat's transcript scrolling internally
rather than the whole page (the automated
AppLayout.test.jsxlayout-contract test covers thisstructurally); a two-session live SSE fill notification; killing network mid-SSE-session and
confirming
EventSourcereconnects; a full sell-order lot-designation round trip against a realposition.
Security / DB / API impact
/api/v1/events/stream, self-scoped from thesession;
/api/v1/admin/events/stream,@requires_role("adviser","admin")). The pushed SSEpayload is a pointer only (
event_type/entity_id/summary) -- never authoritative data, theclient always re-fetches over the existing authenticated REST path. See the untested-authz
callout above.
add profile fields to customer(3 nullable columns) andadd order.designated_lot_ids.previous_closeonGET /securities,lot_idsonPOST /orders,designated_lot_idson order responses,next_cursor/afterpagination on 5 list endpoints,the two new SSE routes,
GET/PATCH /profile. No existing response shape changed or narrowed.🤖 Generated with Claude Code