src/blackrock/EconomyOverview.tsx
import {IntelligenceBrief} from './IntelligenceBrief';
import type {ResearchSection} from './navigation';
import {useId,useEffect,useMemo,useRef,useState} from 'react';
import {Badge,Button,Card,Heading,Select,Text} from '@whop/react/components';
import {Bar,BarChart,CartesianGrid,Line,LineChart,ReferenceLine,ResponsiveContainer,Tooltip,XAxis,YAxis} from 'recharts';
import {ArrowUpRight,RefreshCw,Star,MessageSquare} from 'lucide-react';
import {economyVerdicts,type EconomyEnvelope,type EconomyItem,type EconomyDays,type EconomyJob} from '../../shared/economy';
import {BASE,number,time,type Entity} from './api';
import {ItemArt} from './ItemArt';
import {ChartPatterns,DitherBar,ChartTooltip} from './DitherChart';
import {fillStyle} from '../lib/dither/style';
import './economy.css';
const baskets=[['core6-v1','Six tracked goods'],['resources-v1','Resources'],['supplies-v1','Supplies']];
const pct=(n:number|null|undefined)=>n==null?'Not available':`${n>=0?'+':''}${number(n)}%`;
const utc=(n:number)=>new Date(n).toISOString().replace('.000Z',' UTC');
const entity=(item:EconomyItem,view?:Entity['view']):Entity=>({kind:'item',key:String(item.id),name:item.name,itemId:item.id,...view?{view}:{}});
function initial(key:string,fallback:string){return new URLSearchParams(location.search).get(key)??fallback;}
function persist(key:string,value:string){const url=new URL(location.href);url.searchParams.set(key,value);history.replaceState(history.state,'',url.pathname+url.search+url.hash);}
export function EconomyOverview({active,onSelect,saved,onWatch,busy,onAsk,onNavigate}:{onNavigate?:(section:ResearchSection)=>void;active:boolean;onSelect:(e:Entity)=>void;saved:(e:Entity)=>boolean;onWatch:(e:Entity)=>void;busy:boolean;onAsk:(prompt:string)=>void}){
const [evidenceOpen,setEvidenceOpen]=useState(false);
const evidenceRef=useRef<HTMLDivElement>(null);
const showEvidence=()=>{setEvidenceOpen(true);requestAnimationFrame(()=>evidenceRef.current?.scrollIntoView({block:'start',behavior:'auto'}));};
const [days,setDays]=useState<EconomyDays>(()=>[1,7,30].includes(Number(initial('economyPeriod','7')))?Number(initial('economyPeriod','7')) as EconomyDays:7);
const [basket,setBasket]=useState(()=>baskets.some(([id])=>id===initial('economyBasket','core6-v1'))?initial('economyBasket','core6-v1'):'core6-v1');
const [sort,setSort]=useState(()=>initial('economySort','absolute')),[revision,setRevision]=useState(0),[snapshotId,setSnapshotId]=useState<string|null>(null);
const [result,setResult]=useState<EconomyEnvelope|null>(null),[job,setJob]=useState<EconomyJob|null>(null),[error,setError]=useState<string|null>(null),[loading,setLoading]=useState(false),[cooldown,setCooldown]=useState<number|null>(null);
useEffect(()=>{if(!cooldown)return;const timer=setTimeout(()=>setCooldown(null),Math.max(1,cooldown-Date.now()+50));return()=>clearTimeout(timer)},[cooldown]);
const consumedRefresh=useRef(0);
useEffect(()=>{if(!active)return;const controller=new AbortController();let timer:ReturnType<typeof setTimeout>|undefined;let alive=true;
setLoading(true);setError(null);setResult(old=>old?.data.periodDays===days&&old.data.basket.id===basket&&(!snapshotId||old.data.snapshotId===snapshotId)?old:null);setJob(null);
const manualRefresh=!snapshotId&&revision!==consumedRefresh.current;consumedRefresh.current=revision;
const base=snapshotId?`${BASE}/economy/snapshots/${encodeURIComponent(snapshotId)}`:`${BASE}/economy?periodDays=${days}&basket=${basket}`;
const load=async(url:string,isJob=false)=>{try{
const response=await fetch(url,{signal:controller.signal});const body=await response.json();if(!alive)return;
if(response.status===202){setJob(body.data);setLoading(false);timer=setTimeout(()=>void load(`${BASE}/economy/jobs/${encodeURIComponent(body.data.jobId)}?basket=${basket}`,true),2000);return;}
if(!response.ok){if(isJob){await load(base);return;}setError(body.error?.message??'Economy evidence unavailable.');if(body.retryAfterSeconds)setCooldown(Date.now()+body.retryAfterSeconds*1000);setJob(null);setLoading(false);return;}
if(isJob){await load(base);return;}
setResult(body);setJob(body.refreshJob??null);setCooldown(body.refreshCooldownUntil??null);setLoading(false);
if(body.refreshJob)timer=setTimeout(()=>void load(`${BASE}/economy/jobs/${encodeURIComponent(body.refreshJob.jobId)}?basket=${basket}`,true),2000);
else if(!snapshotId)timer=setTimeout(()=>void load(base),15*60000);
}catch(e){if(alive){setError((e as Error).message);setLoading(false);setJob(null);}}};
void load(base+(manualRefresh?'&refresh=1':''));
return()=>{alive=false;controller.abort();if(timer)clearTimeout(timer)};
},[days,basket,revision,snapshotId,active]);
const chartId=useId().replace(/:/g,'');
const data=result?.data;
const rows=useMemo(()=>[...data?.items??[]].sort((a,b)=>{
if(sort==='name')return a.name.localeCompare(b.name)||a.id-b.id;
if(sort==='fills')return (b.current?.fills??-1)-(a.current?.fills??-1)||a.id-b.id;
if(sort==='value')return (b.current?.gross??-1)-(a.current?.gross??-1)||a.id-b.id;
if(a.eligible!==b.eligible)return a.eligible?-1:1;
return (sort==='rise'?(b.changePct??-Infinity)-(a.changePct??-Infinity):sort==='fall'?(a.changePct??Infinity)-(b.changePct??Infinity):Math.abs(b.changePct??0)-Math.abs(a.changePct??0))||a.id-b.id;
}),[data,sort]);
const chooseBasket=(v:string)=>{setBasket(v);persist('economyBasket',v);setSnapshotId(null);setRevision(0)};
const ask=()=>{if(data)onAsk(`Explain this RS-BLACKROCK economy evidence. Use rs_research with operation economy_snapshot and entityId ${data.snapshotId}. Basket: ${data.basket.label}; completed ${data.periodDays}-day comparison; verdict ${economyVerdicts[data.verdict]}; eligible ${data.breadth.eligible}/${data.breadth.total}; excluded IDs ${data.items.filter(i=>!i.eligible).map(i=>i.id).join(', ')||'none'}. Insight IDs: ${data.insights.map(i=>i.id).join(', ')}. Evidence through ${utc(data.current.toExclusive)}. Delivery freshness: ${result.delivery.freshness}. Explain observations separately from hypotheses, cite dates/quantities/fills, and do not describe this selected basket as an economy-wide inflation rate.`)};
return <section className="br-economy" aria-label="Economy overview"><div className="economy-heading"><div><Heading as="h2" size="6">Price evidence</Heading><Text as="p" color="gray">Compare the same goods across completed periods, with the evidence behind every conclusion.</Text></div><div className="economy-controls"><label>Completed period<Select.Root value={String(days)} onValueChange={v=>{setDays(Number(v) as EconomyDays);persist('economyPeriod',v);setSnapshotId(null);setRevision(0)}}><Select.Trigger aria-label="Completed comparison period"/><Select.Content>{[1,7,30].map(d=><Select.Item key={d} value={String(d)}>{d} day{d>1?'s':''}</Select.Item>)}</Select.Content></Select.Root></label><label>Reference basket<Select.Root value={basket} onValueChange={chooseBasket}><Select.Trigger aria-label="Reference basket"/><Select.Content>{baskets.map(([id,label])=><Select.Item key={id} value={id}>{label}</Select.Item>)}</Select.Content></Select.Root></label><Button variant="soft" disabled={!!job||loading||!!cooldown&&cooldown>Date.now()} onClick={()=>{setSnapshotId(null);setRevision(n=>n+1)}}><RefreshCw size={16}/>Refresh</Button></div></div>
{onNavigate&&<IntelligenceBrief {...{result,active,onSelect,saved,onWatch,busy,onAsk,onNavigate}} onEvidence={showEvidence}/>}
<div aria-live="polite" className="economy-status">{loading&&<p>Loading saved economy evidence…</p>}{job&&<p>Reading public evidence · {job.completedReads} of up to {job.plannedReads} source reads. {job.waitingReason??'Existing research remains available.'}</p>}{cooldown&&cooldown>Date.now()&&<p>Refresh available after {time(cooldown)}.</p>}{error&&<p role="alert">{error} <Button variant="soft" onClick={()=>setRevision(n=>n+1)}>Retry</Button></p>}</div>
{onNavigate&&<Button variant="soft" aria-expanded={evidenceOpen} onClick={()=>setEvidenceOpen(!evidenceOpen)}>{evidenceOpen?'Collapse detailed evidence':'Open charts, methodology & source evidence'}</Button>}<div ref={evidenceRef}>{(!onNavigate||evidenceOpen)&&<>
{data&&<><Card className="economy-verdict"><div><Badge color={result.delivery.freshness==='fresh'?'green':'amber'}>{result.delivery.freshness==='unknown'?'Source freshness unknown':result.delivery.freshness==='stale'?'Saved evidence · stale':'Fresh evidence'}</Badge><Heading as="h2" size="7">{economyVerdicts[data.verdict]}</Heading><Text as="p" size="3">{data.basket.label}: {data.breadth.rising} rising, {data.breadth.falling} falling, {data.breadth.flat} within ±1%. {data.breadth.eligible} of {data.breadth.total} meet the trading screen.</Text><p>{data.basket.note} {data.verdict==='insufficient'?'Too few eligible goods to establish a basket-wide direction.':'The finding applies only to this selected basket.'}</p><p>Evidence through <strong>{utc(data.current.toExclusive)}</strong>{result.delivery.freshness!=='fresh'?` · As of collection ${time(data.collectionFinishedAt)}`:''}</p>{result.delivery.reasons.map(r=><p key={r}>{r}</p>)}</div><div className="economy-verdict-number"><span>Tracked goods price change</span><strong>{pct(data.contributions.ordinaryChangePct)}</strong><span>{data.contributions.complete?'Fixed weights · geometric index':'Index unavailable · missing/thin members'}</span><Button onClick={ask} variant="soft"><MessageSquare size={16}/>Explain this evidence</Button></div></Card>
{result.latestAttempt&&result.latestAttempt.state!=='complete'&&<Card className="economy-warning"><strong>Latest refresh {result.latestAttempt.state} · {time(result.latestAttempt.finishedAt)}</strong><p>{result.latestAttempt.error?.message} {data.state==='complete'?'Retaining the last complete result.':'Showing partial evidence; gaps remain unknown.'}</p>{result.latestAttempt.snapshotId&&result.latestAttempt.snapshotId!==data.snapshotId&&<Button variant="soft" onClick={()=>setSnapshotId(result.latestAttempt!.snapshotId)}>Inspect partial attempt</Button>}</Card>}
{snapshotId&&<Button variant="soft" onClick={()=>{setSnapshotId(null);setRevision(0)}}>Return to latest complete overview</Button>}
<details className="economy-methodology"><summary>Exact comparison windows & methodology</summary><p>Previous: [{utc(data.previous.from)}, {utc(data.previous.toExclusive)})</p><p>Current: [{utc(data.current.from)}, {utc(data.current.toExclusive)})</p><p>Local display: {time(data.previous.from)} to {time(data.current.toExclusive)}. Endpoints are exclusive. Current partial buckets are excluded.</p><p>VWAP = executed gross GP ÷ executed units. Each period needs {days===30?'30 fills and 10 active daily':'10 fills and 3 active '+(days===1?'hourly':'daily')} buckets. Broad direction needs ≥5 eligible goods, ≥80% basket coverage and ≥70% of eligible goods agreeing. Rising >+1%, falling <−1%.</p><p>Methodology {data.methodologyVersion} · snapshot <code>{data.snapshotId}</code>. Basket version {data.basket.version}. No forecasts or annualization.</p></details>
<div className="economy-chart-grid"><Card><Heading as="h2" size="4">Tracked goods price index</Heading><p>Rebased to prior comparison period = 100. Changing the period changes the reference. Gaps remain gaps.</p><div className="economy-chart" role="img" aria-label="Geometric price index; exact values in the table below"><ResponsiveContainer width="100%" height="100%"><LineChart accessibilityLayer data={data.index.points}><CartesianGrid stroke="var(--gray-a5)" strokeDasharray="3 3"/><XAxis dataKey="toExclusive" tickFormatter={v=>new Date(v).toLocaleDateString(undefined,{month:'short',day:'numeric'})} minTickGap={28}/><YAxis domain={['auto','auto']} width={48}/><ReferenceLine y={100} ifOverflow="extendDomain" stroke="var(--gray-9)" strokeDasharray="4 4" label={{value:"Reference 100",position:"insideTopRight",fill:"var(--gray-11)",fontSize:11}}/><Tooltip content={<ChartTooltip unit="index"/>} cursor={{stroke:"var(--gray-6)"}}/><Line dataKey="value" name="Price index" type="linear" stroke="var(--blue-11)" strokeWidth={2} connectNulls={false} isAnimationActive={false} dot={{r:4}}/></LineChart></ResponsiveContainer></div><details><summary>Index data table</summary><div className="economy-table-scroll"><table><thead><tr><th>Period ending (exclusive)</th><th>Index</th><th>Missing / thin IDs</th></tr></thead><tbody>{data.index.points.map(p=><tr key={p.toExclusive}><td>{utc(p.toExclusive)}</td><td>{p.value==null?'Unavailable':number(p.value)}</td><td>{p.missingItemIds.join(', ')||'None'}</td></tr>)}</tbody></table></div></details></Card>
<Card><Heading as="h2" size="4">{data.contributions.complete?'Price contributions':'Partial price contributions'}</Heading><p>Log percentage points · fixed weights. {data.contributions.complete?`Total ${number(data.contributions.totalLogPoints)} log points → ${pct(data.contributions.ordinaryChangePct)} basket change.`:'No summed total until every member is eligible.'}</p><div className="economy-chart" role="group" aria-label="Signed log percentage point contributions; exact values below"><ResponsiveContainer width="100%" height="100%"><BarChart accessibilityLayer layout="vertical" data={data.contributions.rows.map(r=>({...r,name:data.items.find(i=>i.id===r.itemId)?.name}))}><ChartPatterns id={chartId}/><CartesianGrid stroke="var(--gray-a5)"/><XAxis type="number"/><YAxis type="category" dataKey="name" width={95} tick={{fontSize:11}}/><ReferenceLine x={0} stroke="var(--gray-9)"/><Tooltip cursor={{fill:'var(--gray-a3)'}} content={<ChartTooltip dates={false} unit="log points"/>}/><Bar dataKey="logPoints" name="Contribution" maxBarSize={28} shape={(props:any)=>{const negative=props.payload.logPoints<0,item=data.items.find(i=>i.id===props.payload.itemId);return <DitherBar {...props} horizontal negative={negative} pattern={`${chartId}-${negative?'fall':'rise'}`} color={negative?'var(--red-11)':'var(--green-11)'} label={`${item?.name??'Item'}: ${number(props.payload.logPoints)} log points. Inspect item.`} onPick={item?()=>onSelect(entity(item,'charts')):undefined}/>;}} isAnimationActive={false}/></BarChart></ResponsiveContainer></div><p className="dither-chart-key"><span><i style={fillStyle('dotted',{color:'var(--green-11)',cell:1.5})}/>Positive</span><span><i style={fillStyle('hatched',{color:'var(--red-11)',cell:1.5})}/>Negative</span><span>Select a bar to inspect its item.</span></p><details><summary>Contribution data table</summary><div className="economy-table-scroll"><table><thead><tr><th>Item</th><th>Weight</th><th>Item change</th><th>Log points</th><th>Eligibility</th></tr></thead><tbody>{data.contributions.rows.map(r=>{const i=data.items.find(i=>i.id===r.itemId)!;return <tr key={r.itemId}><td><Button variant="ghost" onClick={()=>onSelect(entity(i,'charts'))}>{i.name}</Button></td><td>{number(r.weight*100)}%</td><td>{pct(i.changePct)}</td><td>{r.logPoints==null?'Unavailable':number(r.logPoints)}</td><td>{r.eligible?'Eligible':i.reasons.join(', ')}</td></tr>})}</tbody></table></div></details></Card></div>
<Card><Heading as="h2" size="4">Compare baskets · same dates, different goods</Heading><p>Baskets overlap; these changes cannot be averaged into an economy figure.</p><div className="economy-table-scroll"><table><thead><tr><th>Basket</th><th>Eligible</th><th>Index</th><th>Change</th><th>Finding</th><th>Missing / thin IDs</th></tr></thead><tbody>{data.basketComparisons.map(b=><tr key={b.basketId}><td><Button variant="ghost" onClick={()=>chooseBasket(b.basketId)}>{b.label}</Button></td><td>{b.eligible}/{b.total}</td><td>{b.index==null?'Unavailable':number(b.index)}</td><td>{pct(b.changePct)}</td><td>{economyVerdicts[b.verdict]}</td><td>{b.missingItemIds.join(', ')||'None'}</td></tr>)}</tbody></table></div></Card>
<Card><div className="economy-evidence-heading"><Heading as="h2" size="4">Evidence by item</Heading><Select.Root value={sort} onValueChange={v=>{setSort(v);persist('economySort',v)}}><Select.Trigger aria-label="Sort economy evidence"/><Select.Content>{[['absolute','Largest absolute change'],['rise','Largest rise'],['fall','Largest fall'],['name','Name A–Z'],['value','Executed value, highest first'],['fills','Fills, highest first']].map(([v,label])=><Select.Item key={v} value={v}>{label}</Select.Item>)}</Select.Content></Select.Root></div><p aria-live="polite">Sorted by {sort}. Eligible goods first for price sorts; item ID breaks ties. Median eligible change: {pct(data.breadth.medianChangePct)}.</p><div className="economy-evidence-grid">{rows.map(i=><article key={i.id} className="economy-item"><div className="economy-item-title"><ItemArt id={i.id} name={i.name}/><Button variant="ghost" onClick={()=>onSelect(entity(i))}>{i.name}</Button><Badge color={i.eligible?'green':'amber'}>{i.eligible?'Eligible':'Excluded'}</Badge></div><dl><div><dt>Previous average</dt><dd>{i.previous?i.previous.vwap==null?'No executions':`${number(i.previous.vwap)} GP`:'Unknown'}</dd></div><div><dt>Current average</dt><dd>{i.current?i.current.vwap==null?'No executions':`${number(i.current.vwap)} GP`:'Unknown'}</dd></div><div><dt>Change</dt><dd>{pct(i.changePct)}</dd></div></dl><details><summary>Units, fills & eligibility</summary><dl>{(['previous','current'] as const).map(k=><div key={k}><dt>{k==='previous'?'Previous':'Current'} period</dt><dd>{i[k]?`${number(i[k]!.volume)} units · ${number(i[k]!.fills)} fills · ${i[k]!.activeBuckets} active buckets`:'Unknown'}</dd><dt>Largest bucket share</dt><dd>{i[k]?.largestBucketShare==null?'Not observed':`${number(i[k]!.largestBucketShare!*100)}%`}</dd><dt>Execution range</dt><dd>{i[k]?.low==null?'No observed range':`${number(i[k]!.low)}–${number(i[k]!.high)} GP`}</dd></div>)}</dl><p>{i.reasons.join(' · ')||'Both periods meet the liquidity screen.'}</p>{i.sourceIds.map(id=>{const source=data.sources.find(s=>s.id===id);return source&&<p key={id}><a href={source.url} target="_blank" rel="noreferrer">Public execution source <ArrowUpRight size={12}/></a> · fetched {time(source.fetchedAt)}</p>})}</details><div className="economy-item-actions"><Button variant="soft" color="gray" disabled={busy} aria-pressed={saved(entity(i))} onClick={()=>onWatch(entity(i))}><Star size={14} fill={saved(entity(i))?'currentColor':'none'}/>{saved(entity(i))?'Watching':'Watch'}</Button>{[['charts','Chart'],['holders','Holders'],['production','Production']].map(([view,label])=><Button key={view} variant="ghost" onClick={()=>onSelect(entity(i,view as Entity['view']))}>{label}</Button>)}<Button variant="ghost" onClick={()=>onAsk(`Research ${i.name} (item ${i.id}) from economy snapshot ${data.snapshotId}. Compare its completed-period prices, liquidity and exclusions; distinguish observed bank previews from total supply.`)}>Ask assistant</Button></div></article>)}</div></Card>
<Card><Heading as="h2" size="4">Market structure · rolling {data.market.windowDays} days</Heading><p>{data.market.catalogComplete?'Complete catalog pagination, collected non-atomically.':'Partial catalog; shares refer only to the observed sample.'} Separate from the completed-period comparison above.</p><div className="economy-metrics">{[['Observed / expected items',`${data.market.observed} / ${data.market.expected??'unknown'}`],['Actively traded',number(data.market.summary.tradedItems)],['Two-sided books',number(data.market.summary.twoSidedItems)],['Executed GP',number(data.market.summary.tradeValue.totalGp)],['Executed units',number(data.market.summary.unitsTraded)],['Measured tax sink · GP',number(data.market.summary.taxBurnedGp)],['Top-five observed turnover share',data.market.summary.tradeValue.top5Share==null?'Unknown':`${number(data.market.summary.tradeValue.top5Share*100)}%`],['Largest item turnover share',data.market.summary.largestItemTurnoverShare==null?'Unknown':`${number(data.market.summary.largestItemTurnoverShare*100)}%`]].map(([label,value])=><div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div><p>Tax burned measures one sink. GP issuance and money stock are unknown; it cannot establish net inflation.</p></Card>
<div className="economy-insights">{data.insights.map(i=><Card key={i.id}><Badge color="gray">{i.kind}</Badge><p>{i.text}</p>{i.itemIds.map(id=>{const item=data.items.find(v=>v.id===id);return <Button key={id} variant="ghost" onClick={()=>onSelect(item?entity(item,'charts'):{kind:'item',key:String(id),itemId:id,name:`Item ${id}`,view:'charts'})}>Inspect {item?.name??`item ${id}`}</Button>})}</Card>)}</div>
<details className="economy-methodology"><summary>Source provenance & limitations</summary>{data.limitations.map(l=><p key={l}>{l}</p>)}<p>Freshness uses original upstream fetch/publication times, not the time you open this page.</p>{data.sources.map(s=><p key={s.id}><a href={s.url} target="_blank" rel="noreferrer">{s.id}</a> · fetched {time(s.fetchedAt)} · published {time(s.sourceUpdatedAt)} · {s.servedFromSourceCache?'upstream response cache reused':'collected from source'}</p>)}</details></>}
{!data&&!loading&&!job&&!error&&<Card>No economy evidence yet. Refresh to collect public market observations.</Card>}
</>}</div></section>
}