From 4b5a12241742f75f42f0eac45e38cdf72e657f62 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Mon, 7 Sep 2026 00:32:38 -0700 Subject: [PATCH 1/3] fix(frontend): stop every page shrinking to content width, not just Lots/Funding Investigating "Orders/Portfolio/Dashboard don't use the available horizontal space" found the actual root cause, not a per-page styling gap: .tu-page (used by every page) sits in .tu-app-layout__main, a column flex container. Its own margin: 0 auto centering is a cross-axis auto margin on a flex item -- per the flexbox spec that opts the item out of stretch sizing entirely, so it shrinks to its own content's width instead of the container's, unless that content happens to be wide enough on its own to force it back open. That's exactly why Fees/Lots/ Funding/the new Orders form never showed it (wide table or filled multi-column form) while Orders' list, Portfolio, and Dashboard did. Confirmed live at 1280px, all previously unpatched: Orders list rendered at 293px, Portfolio at 307px, Dashboard at 510px. This is the identical bug already found and patched twice this session, once per page, as each was redesigned and happened to hit it: LotsPage.css's .tu-page.tu-lots-page and FundingPage.css's .tu-page.tu-funding-page, both `{ width: 100%; min-width: 0; }`. Fixed once at the source instead: moved width: 100%; min-width: 0 onto the base .tu-page rule in PageLayout.css, and deleted both now-redundant per-page overrides. Fixes Orders, Portfolio, and Dashboard's wasted-space complaint at the root, plus any future page that hits the same trap -- verified live, Portfolio's existing repeat(auto-fill, minmax(260px,1fr)) grid now correctly spreads its 4 model cards across the full width with no page-specific change needed there. Also fixed DashboardPage.css: with .tu-page now actually full width, its flex-wrap stats row would have just left BalanceCard/ReturnCard small and left-aligned with the rest of the row empty. Changed to repeat(auto-fit, minmax(280px, 1fr)), matching the same grid idiom already used in ModelDetail.css/AssignmentPrompt.css -- verified live, both cards now stretch to fill the row. --- frontend/src/pages/DashboardPage.css | 10 ++++++++-- frontend/src/pages/FundingPage.css | 8 -------- frontend/src/pages/LotsPage.css | 17 ----------------- frontend/src/pages/PageLayout.css | 11 +++++++++++ 4 files changed, 19 insertions(+), 27 deletions(-) diff --git a/frontend/src/pages/DashboardPage.css b/frontend/src/pages/DashboardPage.css index 9953b57..96ded07 100644 --- a/frontend/src/pages/DashboardPage.css +++ b/frontend/src/pages/DashboardPage.css @@ -1,5 +1,11 @@ +/* BalanceCard/ReturnCard are content-sized Cards with no width of their own -- now that + PageLayout.css's .tu-page actually stretches to the full page width, a plain flex row would + just leave them small and left-aligned with the rest of the row empty. auto-fit lets each stat + grow to share the row evenly (2 columns once there's room for both at >=280px, 1 below that), + matching the repeat(auto-fill/auto-fit, minmax(...)) idiom already used in + features/portfolio/components/ModelDetail.css and AssignmentPrompt.css. */ .tu-dashboard-page__stats { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--space-4); } diff --git a/frontend/src/pages/FundingPage.css b/frontend/src/pages/FundingPage.css index b68060a..7f2b992 100644 --- a/frontend/src/pages/FundingPage.css +++ b/frontend/src/pages/FundingPage.css @@ -19,14 +19,6 @@ } } -/* Matches LotsPage.css's own fix: FundingHistoryTable is wide enough to need its own horizontal - scroll (Table.css), which only works if this page's width is pinned to the viewport rather than - growing to the table's unwrapped content width via PageLayout.css's auto-margin centering. */ -.tu-page.tu-funding-page { - width: 100%; - min-width: 0; -} - /* This heading is only ever reached via OutstandingBalanceBanner's onMakeDeposit -> focusDepositForm()'s programmatic .focus() call (tabIndex=-1 takes it out of the natural tab order) — matching FeesPage.css's focusPaymentMethod() precedent, :focus (not :focus-visible) is diff --git a/frontend/src/pages/LotsPage.css b/frontend/src/pages/LotsPage.css index dde3f68..6c92c0b 100644 --- a/frontend/src/pages/LotsPage.css +++ b/frontend/src/pages/LotsPage.css @@ -9,20 +9,3 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } - -/* `.tu-page`'s own `margin: 0 auto` (PageLayout.css, centering it under `max-width`) is a - cross-axis auto margin on a flex item of `.tu-app-layout__main` (a column flex container) — - per the flexbox spec, a cross-axis auto margin opts the item OUT of stretch sizing entirely, so - `.tu-page` falls back to its own content-based width instead of the container's. With a wide - table inside, that content-based width is the table's full unwrapped min-content size, so the - whole page (not just the table) ends up wider than the viewport and the table's own - `.tu-table-wrapper` (Table.css's overflow-x: auto) never gets a narrow-enough box to actually - scroll inside. An explicit `width: 100%` gives the auto-margin centering something definite to - center within again; `min-width: 0` then lets that 100% actually shrink to the viewport instead - of being floored by the table's content size. Scoped to this page (the extra `tu-lots-page` - class) rather than touched at the shared PageLayout.css/Table.css source — every other `.tu-page` - user either has no table this wide or hasn't hit this at a narrow viewport yet. */ -.tu-page.tu-lots-page { - width: 100%; - min-width: 0; -} diff --git a/frontend/src/pages/PageLayout.css b/frontend/src/pages/PageLayout.css index b46cce6..8f1a41d 100644 --- a/frontend/src/pages/PageLayout.css +++ b/frontend/src/pages/PageLayout.css @@ -1,5 +1,16 @@ +/* `.tu-page` is a flex item of `.tu-app-layout__main` (a column flex container). Its own + `margin: 0 auto` centering is a cross-axis auto margin -- per the flexbox spec, that opts the + item OUT of stretch sizing entirely, so without `width: 100%` it shrinks to its own content's + width instead of the container's. A page whose content happens to be a wide table or a filled + multi-column form never shows this (the content forces it back open), which is why it went + unnoticed until Lots/Funding/Orders/Portfolio/Dashboard were checked directly: confirmed live at + 1280px, Orders list rendered at 293px, Portfolio at 307px, Dashboard at 510px. `min-width: 0` + then lets that `width: 100%` actually shrink on a narrow viewport instead of being floored by + any wide-content descendant (e.g. a table) the way `min-width: auto`'s default would. */ .tu-page { max-width: var(--content-max-width); + width: 100%; + min-width: 0; margin: 0 auto; padding: var(--space-8) var(--space-6); display: flex; From 33441066da23241158abb7259a54df6b6ffb40f9 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Mon, 7 Sep 2026 00:32:49 -0700 Subject: [PATCH 2/3] fix(onboarding): throttle Refresh status -- it had no rate limiting at all useIdentityStatus.js had zero client-side rate limiting, debounce, or cooldown on refetch(). Each call hits GET /identity/status/, which for a still-pending customer triggers a live outbound Stripe Identity poll, not a cheap read -- and shares a single 30/min-per-IP, in-memory bucket (identity.py, key_func=get_remote_address) with RequireOnboarded's own background calls on every protected-route navigation. A customer clicking "Refresh status" repeatedly could burn through that shared budget for the whole app. The only reason a double-click didn't already double-fire was incidental: flipping status away from 'loaded' unmounts the button along with the entire status card, replaced by a skeleton -- not a real cooldown, and it means every refresh flashes the whole card away rather than updating in place. Added an explicit 10s cooldown tracked by a ref (no extra renders for the tracking itself) plus a ticking cooldownRemainingSeconds state so the button can show "Try again in Ns" and disable itself, verified live: a click immediately shows "Try again in 10s" (disabled), correctly counts down 10s -> 9s -> 8s, and re-enables once it reaches zero. Applies to every caller of refetch() uniformly (the button, KycStep's onCompleted, ErrorState's onRetry) -- a spammed retry deserves the same protection as a spammed manual refresh, and centralizing it in the hook is simpler and more robust than duplicating a cooldown at each call site. The initial mount fetch is never throttled -- there's nothing to cool down from yet. --- .../onboarding/hooks/useIdentityStatus.js | 47 +++++++++++++++++-- frontend/src/pages/OnboardingPage.jsx | 10 +++- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/onboarding/hooks/useIdentityStatus.js b/frontend/src/features/onboarding/hooks/useIdentityStatus.js index ccbc3ee..93ef69a 100644 --- a/frontend/src/features/onboarding/hooks/useIdentityStatus.js +++ b/frontend/src/features/onboarding/hooks/useIdentityStatus.js @@ -1,17 +1,26 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { identityApi } from '../api/identityApi.js' const IDLE = { status: 'idle', kycStatus: null, accountApprovalStatus: null, error: null } +// A still-pending refresh triggers a live outbound Stripe Identity poll (identity.py's +// get_identity_status), not a cheap read, and shares a single 30/min-per-IP bucket with +// RequireOnboarded's own background calls on every protected-route navigation -- a customer +// repeatedly clicking "Refresh status" could burn through that shared budget for the whole app. +const REFETCH_COOLDOWN_MS = 10_000 + // GET /identity/status/; pass null for non-customer principals. export function useIdentityStatus(customerId) { const [state, setState] = useState(IDLE) + const [cooldownRemainingMs, setCooldownRemainingMs] = useState(0) + const lastFetchedAtRef = useRef(0) - const refetch = useCallback(async () => { + const fetchNow = useCallback(async () => { if (!customerId) { setState(IDLE) return } + lastFetchedAtRef.current = Date.now() setState((prev) => ({ ...prev, status: 'loading', error: null })) try { const data = await identityApi.getStatus(customerId) @@ -26,9 +35,37 @@ export function useIdentityStatus(customerId) { } }, [customerId]) + // The initial mount fetch below always bypasses this -- there is nothing to cool down from yet. + // Every other caller (the "Refresh status" button, KycStep's onCompleted, ErrorState's onRetry) + // goes through this throttled path, since a spammed retry deserves the same protection as a + // spammed manual refresh. + const refetch = useCallback(() => { + if (Date.now() - lastFetchedAtRef.current < REFETCH_COOLDOWN_MS) { + return + } + fetchNow() + }, [fetchNow]) + useEffect(() => { - refetch() - }, [refetch]) + fetchNow() + }, [fetchNow]) + + // Ticks the remaining cooldown down to 0 so the UI can show "Try again in Ns" and re-enable + // itself without polling Date.now() from the caller. + useEffect(() => { + if (state.status !== 'loaded' && state.status !== 'error') return + const update = () => { + setCooldownRemainingMs(Math.max(0, REFETCH_COOLDOWN_MS - (Date.now() - lastFetchedAtRef.current))) + } + update() + const interval = setInterval(update, 250) + return () => clearInterval(interval) + }, [state.status]) - return { ...state, refetch } + return { + ...state, + refetch, + canRefetch: cooldownRemainingMs === 0, + cooldownRemainingSeconds: Math.ceil(cooldownRemainingMs / 1000), + } } diff --git a/frontend/src/pages/OnboardingPage.jsx b/frontend/src/pages/OnboardingPage.jsx index 4d467a9..e29cebd 100644 --- a/frontend/src/pages/OnboardingPage.jsx +++ b/frontend/src/pages/OnboardingPage.jsx @@ -46,8 +46,14 @@ export function OnboardingPage() {

Identity and funding status

{identity.status === 'loaded' && ( - )}
From 7a429432122de676e66c1c1158ae896addb76227 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Mon, 7 Sep 2026 00:33:01 -0700 Subject: [PATCH 3/3] feat(frontend): remove the Transactions page Deleted TransactionsPage.jsx and everything that existed only to serve it: TransactionTable.jsx, useTransactionHistory.js, entryTypeLabels.js (each confirmed to have exactly one importer chain rooted at the page). Removed the /transactions route + import from App.jsx and the nav link from AppLayout.jsx's money group; removed valuationApi.js's now-unused getHistory (getBalance/getReturns stay -- Dashboard's). GET /valuation/history becomes unused by the frontend as a result (confirmed no other feature calls it) -- left the backend endpoint, service, and tests alone; removing working, tested backend surface is a separate decision this change doesn't make. --- frontend/src/App.jsx | 2 - .../features/valuation/api/valuationApi.js | 1 - .../valuation/components/TransactionTable.jsx | 59 ------------------- .../src/features/valuation/entryTypeLabels.js | 12 ---- .../valuation/hooks/useTransactionHistory.js | 25 -------- frontend/src/pages/AppLayout.jsx | 3 +- frontend/src/pages/TransactionsPage.jsx | 11 ---- 7 files changed, 1 insertion(+), 112 deletions(-) delete mode 100644 frontend/src/features/valuation/components/TransactionTable.jsx delete mode 100644 frontend/src/features/valuation/entryTypeLabels.js delete mode 100644 frontend/src/features/valuation/hooks/useTransactionHistory.js delete mode 100644 frontend/src/pages/TransactionsPage.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5d6a424..567e959 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,7 +20,6 @@ import { PortfolioPage } from './pages/PortfolioPage.jsx' import { RegisterPage } from './pages/RegisterPage.jsx' import { StatementDetailPage } from './pages/StatementDetailPage.jsx' import { StatementsPage } from './pages/StatementsPage.jsx' -import { TransactionsPage } from './pages/TransactionsPage.jsx' import { defaultRouteForPrincipal } from './routes/defaultRoute.js' import { RequireAuth, RequireOnboarded, RequireRole } from './routes/guards.jsx' @@ -69,7 +68,6 @@ function AppShell() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/features/valuation/api/valuationApi.js b/frontend/src/features/valuation/api/valuationApi.js index 4183962..f03e5e1 100644 --- a/frontend/src/features/valuation/api/valuationApi.js +++ b/frontend/src/features/valuation/api/valuationApi.js @@ -4,5 +4,4 @@ export const valuationApi = { getBalance: () => apiClient.get('/valuation/balance'), getReturns: (periodStart, periodEnd) => apiClient.get(`/valuation/returns?period_start=${periodStart}&period_end=${periodEnd}`), - getHistory: () => apiClient.get('/valuation/history'), } diff --git a/frontend/src/features/valuation/components/TransactionTable.jsx b/frontend/src/features/valuation/components/TransactionTable.jsx deleted file mode 100644 index a5fb204..0000000 --- a/frontend/src/features/valuation/components/TransactionTable.jsx +++ /dev/null @@ -1,59 +0,0 @@ -import { EmptyState } from '../../../components/EmptyState' -import { ErrorState } from '../../../components/ErrorState' -import { Skeleton } from '../../../components/Skeleton' -import { Table } from '../../../components/Table' -import { UnitsValue } from '../../../components/UnitsValue' -import { getErrorMessage } from '../../../utils/apiErrorMessage.js' -import { formatDate, formatMoney } from '../../../utils/format.js' -import { ENTRY_TYPE_LABEL } from '../entryTypeLabels.js' -import { useTransactionHistory } from '../hooks/useTransactionHistory.js' - -export function TransactionTable() { - const { status, entries, error, refetch } = useTransactionHistory() - - if (status === 'idle' || status === 'loading') { - return ( -
- - - -
- ) - } - - if (status === 'error') { - return - } - - if (entries.length === 0) { - return - } - - return ( - - - Date - Type - Amount - Units - Memo - - - {entries.map((entry, index) => ( - // No stable per-entry id; recorded_at + index as tiebreak. - - {formatDate(entry.effective_date)} - {ENTRY_TYPE_LABEL[entry.entry_type] || entry.entry_type} - - {entry.amount_money ? formatMoney(entry.amount_money) : '—'} - - - {entry.quantity_units ? : '—'} - - {entry.memo || '—'} - - ))} - -
- ) -} diff --git a/frontend/src/features/valuation/entryTypeLabels.js b/frontend/src/features/valuation/entryTypeLabels.js deleted file mode 100644 index f8cf464..0000000 --- a/frontend/src/features/valuation/entryTypeLabels.js +++ /dev/null @@ -1,12 +0,0 @@ -// backend/app/models/ledger/journal_entry.py's JournalEntryType, verbatim. -export const ENTRY_TYPE_LABEL = { - trade_buy: 'Buy', - trade_sell: 'Sell', - deposit: 'Deposit', - withdrawal: 'Withdrawal', - dividend: 'Dividend', - split: 'Split', - fee_adjustment: 'Fee adjustment', - correction: 'Correction', - wash_sale_adjustment: 'Wash sale adjustment', -} diff --git a/frontend/src/features/valuation/hooks/useTransactionHistory.js b/frontend/src/features/valuation/hooks/useTransactionHistory.js deleted file mode 100644 index 32be516..0000000 --- a/frontend/src/features/valuation/hooks/useTransactionHistory.js +++ /dev/null @@ -1,25 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { valuationApi } from '../api/valuationApi.js' - -const IDLE = { status: 'idle', entries: [], error: null } - -// ADR 6 -export function useTransactionHistory() { - const [state, setState] = useState(IDLE) - - const refetch = useCallback(async () => { - setState((prev) => ({ ...prev, status: 'loading', error: null })) - try { - const data = await valuationApi.getHistory() - setState({ status: 'loaded', entries: data.entries, error: null }) - } catch (error) { - setState({ status: 'error', entries: [], error }) - } - }, []) - - useEffect(() => { - refetch() - }, [refetch]) - - return { ...state, refetch } -} diff --git a/frontend/src/pages/AppLayout.jsx b/frontend/src/pages/AppLayout.jsx index b33d005..546041b 100644 --- a/frontend/src/pages/AppLayout.jsx +++ b/frontend/src/pages/AppLayout.jsx @@ -30,7 +30,6 @@ const CUSTOMER_NAV_GROUPS = [ key: 'money', label: 'Money', links: [ - { to: '/transactions', label: 'Transactions' }, { to: '/funding', label: 'Funding' }, { to: '/lots', label: 'Tax lots' }, { to: '/statements', label: 'Statements' }, @@ -296,7 +295,7 @@ export function AppLayout() { {/* Group labels show inline only for the staff console -- it's what makes 2 links read as a deliberate, named cluster rather than a leftover flat row. Customer groups lean on spacing + dividers alone; "Dashboard/Portfolio/Orders" - vs. "Transactions/Funding/..." is self-evidently grouped without a caption. */} + vs. "Funding/Tax lots/..." is self-evidently grouped without a caption. */} {group.label && variant === 'staff' && ( {group.label} )} diff --git a/frontend/src/pages/TransactionsPage.jsx b/frontend/src/pages/TransactionsPage.jsx deleted file mode 100644 index 0e5c34e..0000000 --- a/frontend/src/pages/TransactionsPage.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import { TransactionTable } from '../features/valuation/components/TransactionTable.jsx' -import './PageLayout.css' - -export function TransactionsPage() { - return ( -
-

Transactions

- -
- ) -}