zurich_kotak/src/api/portfolio.jsx
Yashas 5ff74ea22d console: numbers in the navigation, and reasoning behind one click
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) <noreply@anthropic.com>
2026-09-08 12:42:40 +05:30

110 lines
4.5 KiB
JavaScript

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 <PortfolioContext.Provider value={value}>{children}</PortfolioContext.Provider>
}
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])
}