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/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/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/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/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' && ( - )}
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; 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

- -
- ) -}