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]) }