Skip to content

feat: customer UI rebuild -- 5-destination IA, real-time push, tax-lot designation, pagination - #33

Merged
Badri-Rajendran merged 14 commits into
mainfrom
feature_ui_revamp_fullstack
Sep 16, 2026
Merged

Badri-Rajendran merged 14 commits into
mainfrom
feature_ui_revamp_fullstack

Conversation

@Badri-Rajendran

Copy link
Copy Markdown
Owner

Problem

Six frontend domains (portfolio, lots, fees, chat, admin-customers, statement export) were wired
to frontend/src/services/mockClient.js instead of the real, already-working backend, and the
customer 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; execution
plan: docs/superpowers/plans/2026-09-08-trueup-fullstack-rebuild.md, committed on this branch):

  1. Vitest harness, extended type tokens, theme toggle -- frontend test infra existed nowhere
    before this.
  2. GET /api/v1/securities -- watchlist read endpoint, cursor-paginated.
  3. Specific-ID tax-lot designation at sell time (FR-20/ADR-4) -- POST /orders's lot_ids
    field, server-side validated.
  4. Cursor pagination on 5 unbounded customer list endpoints (orders, lots, funding history,
    valuation history, statements).
  5. "Load more" pagination on the corresponding 5 frontend hooks, consuming (4)'s contract.
  6. Real-time push (NFR-18): EventBusPort/RedisEventBus, EventPublisher, two new SSE
    endpoints (/api/v1/events/stream, self-scoped; /api/v1/admin/events/stream, adviser/admin
    only), 3 publish sites strictly after their triggering commit.
  7. App shell rewrite: 5 customer destinations (Dashboard/Invest/Money/Account/Chat) replace
    the previous 10 routes, with redirects from every old path; EventStreamProvider; a
    layout-contract test protecting Chat's internal scroll.
  8. Dashboard: holdings, a hand-rolled SVG valuation chart with keyboard/pointer scrub, drift
    meters, a watchlist (localStorage membership, real prices), a bitemporal ledger band. Adds
    previous_close to the security-catalogue response for day-over-day change.
  9. Invest: folds Portfolio+Lots+Orders; a lot-picker for sell orders (FIFO default); wash-sale
    disallowed column, previously ignored by the UI despite the backend always returning it.
  10. Money: folds Funding+Fees; handles the requires_reauth bank-link state (FR-43, modeled
    server-side, never handled client-side before this).
  11. Account: profile (GET/PATCH /profile had zero frontend before this, entirely net-new),
    both identity gates shown independently, statements with side-by-side restated-figure
    presentation (FR-26). Bonus fix: RequireOnboarded no longer re-fetches identity status on
    every navigation, now reads the session-cached value.
  12. Admin rewiring: customer directory/KYC overrides move from mockClient to the real
    endpoints; live breaks via the SSE admin stream.

Validation evidence

Gate Result
ruff check app tests 75 errors -- identical to main's own baseline, verified side by side. No new issues.
mypy --strict app 2 errors, both the pre-existing openai_agent_adapter.py:171 issue. No new issues.
lint-imports 5 contracts kept, 0 broken -- including the new app.integrations.redis entry.
alembic upgrade head Applies cleanly through all pending revisions.
pytest (real Postgres/Redis) 999 passed, 7 skipped, 2 xpassed, 84.27% coverage (floor 77%).
npm run lint (oxlint) Clean.
npm run build Succeeds, 299 modules.
npm test (Vitest) 3 files / 7 tests passing.
Manual smoke test Registered a fresh customer against the real dev stack (Postgres/Redis/backend/frontend). Confirmed: the SSE stream connects live (GET /api/v1/events/stream -> 200, observed in the network panel, not just unit-tested); RequireOnboarded correctly gates Dashboard/Invest/Chat for an unonboarded customer and settles cleanly at /onboarding with 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 .jsx component/hook files have no colocated test
(only ThemeToggle, AppLayout, and one util currently do). Root CLAUDE.md calls for a test
per 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%) and controllers/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_role actually blocks a customer
session from the admin stream. EventPublisher (82%) has incidental coverage via the 3 call
sites' 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.jsx layout-contract test covers this
structurally); a two-session live SSE fill notification; killing network mid-SSE-session and
confirming EventSource reconnects; a full sell-order lot-designation round trip against a real
position.

Security / DB / API impact

  • Security: two new authenticated endpoints (/api/v1/events/stream, self-scoped from the
    session; /api/v1/admin/events/stream, @requires_role("adviser","admin")). The pushed SSE
    payload is a pointer only (event_type/entity_id/summary) -- never authoritative data, the
    client always re-fetches over the existing authenticated REST path. See the untested-authz
    callout above.
  • DB: two migrations, already reviewed and applied cleanly in this branch's own commits --
    add profile fields to customer (3 nullable columns) and add order.designated_lot_ids.
  • API: additive only -- previous_close on GET /securities, lot_ids on POST /orders,
    designated_lot_ids on order responses, next_cursor/after pagination on 5 list endpoints,
    the two new SSE routes, GET/PATCH /profile. No existing response shape changed or narrowed.

🤖 Generated with Claude Code

Badri-Rajendran and others added 13 commits September 8, 2026 20:06
…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
Copilot AI lite review requested due to automatic review settings September 16, 2026 19:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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
@Badri-Rajendran
Badri-Rajendran merged commit 6d911dd into main Sep 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants