From 5ff74ea22d8da765d63a33c570ed86aba6bbeb22 Mon Sep 17 00:00:00 2001 From: Yashas Date: Tue, 8 Sep 2026 12:42:40 +0530 Subject: [PATCH] console: numbers in the navigation, and reasoning behind one click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE SIDEBAR NOW CARRIES COUNTS. It deliberately did not, on the argument that a tally could only come from a second full list call that would then disagree with the queue's own total. Right about the cost, wrong about the conclusion: with no numbers the only way to learn whether anything was waiting on you was to open all nine queues in turn, which is the question navigation exists to answer. There is no second call. The overview's existing fetch moved into a PortfolioProvider that both surfaces read, so this is one call fewer than before and the two agree by construction. Bounded at 200 rows, as the overview always was; past that the honest answer is an aggregate endpoint, not a bigger limit. What the badge counts is what needs a PERSON — phaseOf again, so a lead whose documents are in and whose AI chain is running is reported beside the badge rather than inside it. An amber dot marks a queue holding a renewal inside a week, which is the only reason to open one queue before another and was previously invisible. Zero is shown rather than hidden: "nothing here" is an answer, and a queue that disappears when it empties makes the sidebar move under the cursor. Closed is folded into a summary. Three of the twelve queues, opened about once a week, and at equal weight they made the live ones harder to find. REASONING OPENS IN A DIALOG, NOT INLINE. The audit rail is 320px wide with one entry per step, and a 1,500-character rationale expanding in place pushed a lead's whole history off screen to read one sentence of it. Nobody reads a paragraph in a sidebar. The finding — the AI's own first sentence, which is already the conclusion — stays on the line; the working is one click away and one Escape back, headed by what is being read ("Call — Log Contact") rather than by nothing. Escape closes, the page behind does not scroll, and focus returns to the button that opened it so a keyboard reader keeps their place. Two headings that described the container rather than the contents: Record → "Lead file", and the audit trail's subtitle now says what a reader gets from it. Co-Authored-By: Claude Opus 5 (1M context) --- src/App.jsx | 3 +- src/api/portfolio.jsx | 109 ++++++++++++++++++++++++++++++++ src/components/ClampText.css | 103 +++++++++++++++++++++++++++--- src/components/ClampText.jsx | 109 ++++++++++++++++++++++++-------- src/components/Timeline.jsx | 2 +- src/layout/Shell.css | 117 +++++++++++++++++++++++++++++++++++ src/layout/Shell.jsx | 93 +++++++++++++++++++++------- src/screens/Lead.jsx | 8 +-- src/screens/Overview.jsx | 36 +++-------- 9 files changed, 490 insertions(+), 90 deletions(-) create mode 100644 src/api/portfolio.jsx diff --git a/src/App.jsx b/src/App.jsx index 758a770..f00e451 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useZino } from './api/provider.jsx' import Login from './pages/Login.jsx' import Shell from './layout/Shell.jsx' +import { PortfolioProvider } from './api/portfolio.jsx' import Overview from './screens/Overview.jsx' import Pipeline from './screens/Pipeline.jsx' import Lead from './screens/Lead.jsx' @@ -22,7 +23,7 @@ export default function App() { return ( } /> - }> + }> {/* The morning page. This used to redirect to the underwriting queue — one stage, usually empty, and somebody else's. */} } /> diff --git a/src/api/portfolio.jsx b/src/api/portfolio.jsx new file mode 100644 index 0000000..4d46450 --- /dev/null +++ b/src/api/portfolio.jsx @@ -0,0 +1,109 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useZino } from './provider.jsx' +import { RV_LEADS, STAGES, phaseOf } from './config.js' + +/** + * The whole book, fetched ONCE and shared. + * + * The sidebar used to carry no counts, on the reasoning that a tally could only + * come from a second full list call that would then disagree with the queue's + * own total. That reasoning was right about the cost and wrong about the + * conclusion: a sidebar with no numbers means the only way to learn whether + * anything is waiting on you is to click all nine queues, which is the one + * question a console's navigation exists to answer. + * + * So there is no second call. The overview already fetched the whole book on a + * timer; that fetch moves here and both surfaces read it, which is one call + * fewer than before and makes the two agree by construction. + * + * Bounded at 200 rows, as the overview always was. Beyond that the honest + * answer is an aggregate endpoint rather than a bigger limit, and the counts + * would need to say they are partial — worth knowing before this app meets a + * real book. + */ + +const PortfolioContext = createContext(null) +const EMPTY = [] + +export function PortfolioProvider({ children }) { + const { client, user } = useZino() + // Identity, not just presence: switching user must not show the previous + // one's book while the new fetch is in flight. + const who = user?.id ?? user?.email ?? null + // Seeded from `who` rather than set inside the effect. Nothing here writes + // state synchronously during a render pass — a signed-out shell is 'ready' + // and empty from the first frame, and the first fetch only ever moves it + // forwards, so there is no loading flash and no cascading render. + const [state, setState] = useState(() => ({ + status: who ? 'loading' : 'ready', rows: EMPTY, at: null, error: null, + })) + const cancelled = useRef(false) + + const load = useCallback(() => { + if (!who) return Promise.resolve() + return client.recordView(RV_LEADS, { limit: 200 }) + .then((res) => { + if (cancelled.current) return + setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? EMPTY, at: new Date(), error: null }) + }) + .catch((err) => { + if (cancelled.current) return + // A failed poll leaves a good book on screen. Only a first load is an error. + setState((p) => (p.status === 'ready' ? p : { status: 'error', rows: EMPTY, at: null, error: err })) + }) + }, [client, who]) + + useEffect(() => { + cancelled.current = false + load() + const id = setInterval(() => { if (document.visibilityState === 'visible') load() }, 30000) + return () => { cancelled.current = true; clearInterval(id) } + }, [load]) + + const value = useMemo(() => ({ ...state, reload: load }), [state, load]) + return {children} +} + +export function usePortfolio() { + const ctx = useContext(PortfolioContext) + if (!ctx) throw new Error('usePortfolio must be used inside PortfolioProvider') + return ctx +} + +const DAY = 86400000 +const daysToExpiry = (v) => { + if (!v) return null + const d = Date.parse(String(v).substring(0, 10) + 'T00:00:00Z') + return isNaN(d) ? null : Math.round((d - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / DAY) +} + +/** + * Per-stage tallies for the sidebar. + * + * `waiting` is the number that goes on the badge: leads whose PHASE still needs + * a person. `working` is the rest — in the state, carried by an agent, not + * anyone's task. Badging the state total told an operator four leads needed + * them when two did, which is how a queue stops being believed. + * + * `urgent` marks a queue holding a renewal that has lapsed or expires within a + * week. It is the only reason to look at one queue before another, and it was + * invisible until you opened each one. + */ +export function useStageCounts() { + const { rows, status } = usePortfolio() + return useMemo(() => { + const out = {} + for (const s of STAGES) out[s.uid] = { total: 0, waiting: 0, working: 0, urgent: 0 } + for (const r of rows) { + const s = STAGES.find((x) => x.name === r.current_state_name) + if (!s) continue + const t = out[s.uid] + t.total += 1 + if (s.kind !== 'end' && phaseOf(s, r).kind === 'auto') t.working += 1 + else if (s.kind !== 'end') t.waiting += 1 + const d = daysToExpiry(r.renewal_due_date) + if (s.kind !== 'end' && d !== null && d <= 7) t.urgent += 1 + } + return { counts: out, ready: status === 'ready' } + }, [rows, status]) +} diff --git a/src/components/ClampText.css b/src/components/ClampText.css index 08de61e..30131b2 100644 --- a/src/components/ClampText.css +++ b/src/components/ClampText.css @@ -24,11 +24,6 @@ overflow: visible; } -.clamp.is-open .clamp__text { - display: block; - overflow: visible; -} - .clamp__more { font: inherit; font-size: 0.76rem; @@ -54,10 +49,6 @@ transition: transform var(--t); } -.clamp.is-open .clamp__more svg { - transform: rotate(180deg); -} - /* The headline lifted out of an AI paragraph. Slightly heavier than the body and never clamped — it is one sentence by construction, and the point of it is that the finding can be read without opening anything. */ @@ -81,3 +72,97 @@ color: var(--zk-ink); } .clamp__lead + .clamp__text { margin-top: 8px; } + +/* ── The full text, over the page ───────────────────────────────────────── + Expanding inline was the wrong shape for the audit rail: 320px wide, one + entry per step, and a 1,500-character rationale pushed a lead's whole + history off the screen to read one sentence of it. */ +.prose__scrim { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(23, 31, 40, 0.42); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + animation: zk-fade 0.14s var(--ease) both; +} + +.prose__dlg { + width: min(680px, 100%); + max-height: min(76vh, 720px); + display: flex; + flex-direction: column; + background: var(--zk-white); + border-radius: var(--r-lg); + box-shadow: 0 24px 60px -12px rgba(23, 31, 40, 0.3); + outline: none; + animation: zk-rise 0.18s var(--ease) both; +} + +.prose__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px 14px 24px; + border-bottom: 1px solid var(--zk-line-soft); +} + +.prose__head h3 { + margin: 0; + font-size: 0.95rem; + font-weight: 500; + line-height: 1.35; + color: var(--zk-ink); +} + +.prose__head button { + flex: none; + display: grid; + place-items: center; + width: 28px; + height: 28px; + cursor: pointer; + border: none; + border-radius: var(--r-sm, 6px); + background: transparent; + color: var(--zk-muted); +} +.prose__head button svg { width: 14px; height: 14px; } +.prose__head button:hover { background: var(--zk-tint); color: var(--zk-ink); } +.prose__head button:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; } + +.prose__body { + overflow-y: auto; + padding: 18px 24px 26px; +} + +.prose__body p { + margin: 0 0 12px; + font-size: 0.875rem; + line-height: 1.62; + color: var(--zk-muted); + white-space: pre-wrap; +} +.prose__body p:last-child { margin-bottom: 0; } + +/* The finding, kept at the top and kept emphasised: a reader who opened this + to check one thing should not have to find it again. */ +.prose__lead { + font-size: 0.92rem !important; + font-weight: 500; + color: var(--zk-ink) !important; + padding-bottom: 12px; + border-bottom: 1px solid var(--zk-line-soft); + margin-bottom: 16px !important; +} + +@keyframes zk-fade { from { opacity: 0 } to { opacity: 1 } } + +@media (prefers-reduced-motion: reduce) { + .prose__scrim, .prose__dlg { animation: none; } +} diff --git a/src/components/ClampText.jsx b/src/components/ClampText.jsx index 4e559ff..166cf66 100644 --- a/src/components/ClampText.jsx +++ b/src/components/ClampText.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import './ClampText.css' /** @@ -21,17 +21,79 @@ function splitLead(value) { } /** - * Prose, folded. + * The full text, in a dialog. + * + * The reasoning used to expand INLINE, and in the audit rail — 320px wide, one + * entry per step — a 1,500-character rationale pushed the rest of the lead's + * history off the screen to read one sentence of it. Nobody reads a paragraph + * in a sidebar. It opens over the page instead: the finding stays on the line, + * the working is one click away and one Escape back. + */ +function ProseDialog({ title, lead, body, onClose }) { + const ref = useRef(null) + const restore = useRef(null) + + useEffect(() => { + restore.current = document.activeElement + ref.current?.focus() + const onKey = (e) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', onKey) + // The page behind must not scroll under the dialog. + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = prev + // Focus goes back to the button that opened this, not to the top of the + // document — otherwise a keyboard reader loses their place in the trail. + if (restore.current instanceof HTMLElement) restore.current.focus() + } + }, [onClose]) + + return ( +
+
e.stopPropagation()} + > +
+

{title || 'In full'}

+ +
+
+ {/* The finding stays at the top and stays emphasised — a reader who + opened this to check one thing should not have to find it again. */} + {lead ?

{lead}

: null} + {body.split(/\n{2,}/).map((para, i) =>

{para}

)} +
+
+
+ ) +} + +/** + * Prose, reduced to its point. * * The AI employees write at length — a recommendation rationale runs past 1,500 - * characters — and a lead file that prints all of it is a document, not a - * screen. Anything long shows its finding, with the reasoning one click away. + * characters — and a screen that prints all of it is a document. Short text is + * shown as it is; anything longer shows its finding and puts the working behind + * one click. * * Whether to fold is decided on length, not by measuring the rendered box: a * ref-and-measure pass would reflow on every resize to answer a question the * string itself already answers. */ -export default function ClampText({ text, lines = 3, threshold = 150 }) { +export default function ClampText({ text, title, lines = 3, threshold = 150 }) { const [open, setOpen] = useState(false) const value = String(text) @@ -39,33 +101,26 @@ export default function ClampText({ text, lines = 3, threshold = 150 }) { const split = splitLead(value) - // No sensible headline — fold the whole thing, as before. - if (!split) { - return ( -
-

{value}

- -
- ) - } - return ( -
-

{split.lead}

- {open ?

{split.rest}

: null} - + {open ? ( + setOpen(false)} + /> + ) : null}
) } diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 6e47a75..3c46f7a 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -298,7 +298,7 @@ export default function Timeline({ instanceId }) { {it.narrative.map(([label, v]) => (
{label} - +
))} diff --git a/src/layout/Shell.css b/src/layout/Shell.css index 03c77ff..413cdda 100644 --- a/src/layout/Shell.css +++ b/src/layout/Shell.css @@ -289,3 +289,120 @@ } .shell__today:hover { background: var(--zk-tint); } .shell__today.is-active { background: var(--zk-tint-blue); color: var(--zk-blue-dark); } + +/* ── Queue tallies ───────────────────────────────────────────────────────── + Three numbers, deliberately unequal in weight, because they are unequal in + importance: what needs YOU, what an agent holds, and whether anything in + there is about to lapse. */ +.shell__tally { + display: flex; + align-items: center; + gap: 6px; + flex: none; +} + +/* What is waiting on a person. The only figure with full contrast. */ +.shell__n { + min-width: 20px; + padding: 1px 6px; + border-radius: var(--r-pill); + text-align: center; + font-size: 0.74rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--zk-blue-dark); + background: var(--zk-tint-blue); +} + +/* Zero is shown, not hidden. "Nothing here" is an answer, and a queue that + disappears when it empties makes the sidebar move under the cursor. */ +.shell__n.is-zero { + color: var(--zk-grey); + background: transparent; + font-weight: 400; +} + +/* What an agent is carrying. Present, quiet, and never added to the badge — + it is in the queue, it is not your work. */ +.shell__auto { + font-style: normal; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; + color: var(--zk-blue-mid); +} +.shell__auto::after { + content: '·'; + margin-left: 5px; + color: var(--zk-line); +} + +/* A renewal inside a week sitting in this queue. */ +.shell__urgent { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--zk-amber); + flex: none; +} + +.shell__stage.is-empty { color: var(--zk-grey); } +.shell__stage.is-empty::before { border-color: var(--zk-line-soft); } +.shell__stage.is-empty.is-needed .shell__stagename { color: var(--zk-muted); font-weight: 400; } + +/* The one number worth carrying on a group heading: everything this role has + to clear, across its queues. */ +.shell__navlabel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.shell__grouptally { + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0; + font-variant-numeric: tabular-nums; + color: var(--zk-white); + background: var(--zk-blue); + border-radius: var(--r-pill); + padding: 1px 7px; +} + +/* ── Closed, folded ─────────────────────────────────────────────────────── + Three of the twelve queues, opened about once a week. */ +.shell__closed { margin-top: 22px; } + +.shell__closed > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + list-style: none; + padding: 0 10px 10px; + font-size: 0.64rem; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--zk-grey); +} +.shell__closed > summary::-webkit-details-marker { display: none; } +.shell__closed > summary:hover { color: var(--zk-muted); } +.shell__closed > summary b { + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0; + font-variant-numeric: tabular-nums; + color: var(--zk-grey); +} +.shell__closed > summary::after { + content: ''; + order: -1; + width: 5px; height: 5px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform var(--t-fast); +} +.shell__closed[open] > summary::after { transform: rotate(45deg); } diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx index 78e634f..9952df7 100644 --- a/src/layout/Shell.jsx +++ b/src/layout/Shell.jsx @@ -1,6 +1,7 @@ import { NavLink, Outlet } from 'react-router-dom' import { ENTRY, NAV_GROUPS } from '../api/config.js' import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js' +import { useStageCounts } from '../api/portfolio.jsx' import { useZino } from '../api/provider.jsx' import UserMenu from './UserMenu.jsx' import logo from '../assets/brand/zurich_logo.webp' @@ -12,11 +13,22 @@ import './Shell.css' * is not the question anyone signing in has; these answer "is anything waiting * on me?". Queues that need a person are named by what they want done. * - * It deliberately carries no counts. A tally here can only come from fetching - * every lead on a timer and counting client-side, which is a second full list - * call per open tab that disagrees with the queue's own total the moment either - * one is paged. The count belongs on the queue, which already has it from the - * server. + * IT CARRIES COUNTS NOW. It used not to, on the reasoning that a tally could + * only come from a second full list call that would then disagree with the + * queue's own total. That was right about the cost and wrong about the + * conclusion — with no numbers, the only way to learn whether anything is + * waiting on you is to open all nine queues in turn, which is precisely the + * question navigation exists to answer. The overview's existing fetch moved + * into PortfolioProvider and both surfaces read it, so this is one call fewer + * than before rather than one more. + * + * The badge counts leads whose PHASE needs a person, not leads in the state — + * see phaseOf. A dot marks a queue holding a renewal inside a week, which is + * the only reason to open one queue before another. + * + * The closed states are folded away. They are three of the twelve, they are + * reporting rather than work, and at equal weight they made the live queues + * harder to find. */ export default function Shell() { const { user } = useZino() @@ -25,6 +37,37 @@ export default function Shell() { // lives on the overview, which counts the whole book for everyone. const stages = visibleStages(roles) const canFile = entryDoorsFor(roles, ENTRY).length > 0 + const { counts, ready } = useStageCounts() + + // One queue row. Extracted because the live groups and the folded Closed + // group render the same thing and drifted apart when they did not. + const queue = (s) => { + const c = counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 } + const n = s.kind === 'end' ? c.total : c.waiting + return ( + + 'shell__stage' + (isActive ? ' is-active' : '') + + (s.kind === 'needs' ? ' is-needed' : '') + + (ready && !n && !c.working ? ' is-empty' : '') + } + > + {s.need ?? s.name} + {ready ? ( + + {c.urgent ? : null} + {/* The agents' share is shown in the middle dot notation rather + than added in: it is in the queue, it is not your work. */} + {c.working ? {c.working} : null} + {n} + + ) : null} + + ) + } return (
@@ -65,29 +108,37 @@ export default function Shell() { ) : null} - {NAV_GROUPS.map((group) => { + {NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => { const inGroup = stages.filter((s) => s.kind === group.kind) if (!inGroup.length) return null + const outstanding = inGroup.reduce((t, s) => t + (counts[s.uid]?.waiting || 0), 0) return (
-

{group.label}

-
- {inGroup.map((s) => ( - - 'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'needs' ? ' is-needed' : '') - } - > - {s.need ?? s.name} - - ))} -
+

+ {group.label} + {ready && group.kind === 'needs' && outstanding ? ( + {outstanding} + ) : null} +

+
{inGroup.map(queue)}
) })} + + {/* Closed. Folded, because it is reporting rather than work — and at + equal weight with the live queues it made them harder to find. */} + {stages.some((s) => s.kind === 'end') ? ( +
+ + Closed + {ready ? ( + {stages.filter((s) => s.kind === 'end') + .reduce((t, s) => t + (counts[s.uid]?.total || 0), 0)} + ) : null} + +
{stages.filter((s) => s.kind === 'end').map(queue)}
+
+ ) : null}
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index bc4b2a0..4ecf97a 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -511,8 +511,8 @@ export default function Lead() {
-

Record

-

Captured data, grouped by stage of collection.

+

Lead file

+

Everything captured, grouped by the step that captured it.

@@ -550,7 +550,7 @@ export default function Lead() { ) : v.length > LONG ? (
{labels[k] ?? label(k)}
-
+
) : (
@@ -571,7 +571,7 @@ export default function Lead() {

Audit trail

-

Every action recorded against this lead.

+

Every step, who took it, and what they wrote.

diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx index f5d018d..e80f344 100644 --- a/src/screens/Overview.jsx +++ b/src/screens/Overview.jsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { Link, useNavigate } from 'react-router-dom' +import { usePortfolio } from '../api/portfolio.jsx' import { useZino } from '../api/provider.jsx' -import { RV_LEADS, STAGES, phaseOf } from '../api/config.js' +import { STAGES, phaseOf } from '../api/config.js' import { rolesOf, visibleStages } from '../api/permissions.js' import { describeError } from '../api/errors.js' import './screens.css' @@ -9,10 +10,10 @@ import './screens.css' /** * Portfolio overview — the landing screen. * - * Everything is derived from one record-view call and computed here. That is - * honest at this size and it is the same call every queue makes; if the book - * outgrows a single page the answer is an aggregate endpoint, not a larger - * limit. + * Everything is derived from ONE record-view call and computed here. That call + * now lives in PortfolioProvider and is shared with the sidebar's tallies, so + * the two cannot disagree and there is no second fetch. If the book outgrows a + * single page the answer is an aggregate endpoint, not a larger limit. * * Counts cover the WHOLE portfolio for every role, including queues the role * cannot work in. Reporting and permission are different questions: an agent @@ -54,29 +55,10 @@ function expiryTone(d) { } export default function Overview() { - const { client, user } = useZino() + const { user } = useZino() const navigate = useNavigate() const roles = rolesOf(user) - const [state, setState] = useState({ status: 'loading', rows: [], at: null, error: null }) - - useEffect(() => { - let cancelled = false - function load(quiet) { - if (!quiet) setState((p) => ({ ...p, status: 'loading' })) - return client.recordView(RV_LEADS, { limit: 200 }) - .then((res) => { - if (cancelled) return - setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? [], at: new Date(), error: null }) - }) - .catch((err) => { - if (cancelled) return - setState((p) => (quiet && p.status === 'ready' ? p : { status: 'error', rows: [], at: null, error: err })) - }) - } - load(false) - const id = setInterval(() => { if (document.visibilityState === 'visible') load(true) }, 30000) - return () => { cancelled = true; clearInterval(id) } - }, [client]) + const state = usePortfolio() const m = useMemo(() => { const rows = state.rows