src/swarms/SwarmWorkspace.tsx

import {SwarmActivityChart,SwarmHistoryChart} from './SwarmCharts';
import {SwarmHistoryDetail,type HistorySelection} from './SwarmHistoryDetail';
import {SwarmConnections} from './SwarmConnections';
import type {MissionResearchSelection} from '../../shared/logistics';
import React,{useEffect,useRef,useState} from 'react';
import {Badge,Button,Card,Heading,Text,TextArea,TextField} from '@whop/react/components';
import {FilterSelect} from '../ui';
import {ACTIVITIES,ACTIVITY_LABELS,type Activity,type SwarmGroup,type SwarmSummary,type SwarmMemberState,type SwarmHistoryResult} from '../../shared/swarms';
import './swarms.css';
const BASE='/api/swarms/v1';
async function request(path:string,body?:unknown,method='GET'){const response=await fetch(BASE+path,{method,headers:body?{'Content-Type':'application/json'}:undefined,body:body?JSON.stringify(body):undefined});const envelope=await response.json();if(!response.ok)throw new Error(envelope.error?.message??'Swarm request failed');return envelope.data;}
const age=(at:number|null)=>at?`${Math.max(0,Math.floor((Date.now()-at)/1000))}s ago`:'not observed';
const fmt=(n:number|null|undefined)=>n===null||n===undefined?'Unknown':n.toLocaleString(undefined,{maximumFractionDigits:1});
export type SwarmWorkspaceProps={groupRequest?:{id:string;requestId:number}|null;active?:boolean;onInspectPlayer?:(name:string)=>void;onShowPlayer?:(name:string)=>void;onAsk?:(question:string)=>void;onContext?:(entity:{kind:'swarm';key:string;name:string})=>void;onPrepareMission?:(selection:MissionResearchSelection)=>void};
export default function SwarmWorkspace({active=true,groupRequest,onInspectPlayer,onShowPlayer,onAsk,onContext,onPrepareMission}:SwarmWorkspaceProps){
 const [groups,setGroups]=useState<SwarmGroup[]>([]),[selected,setSelected]=useState<string|null>(null),[summary,setSummary]=useState<SwarmSummary|null>(null),[members,setMembers]=useState<SwarmMemberState[]>([]),[history,setHistory]=useState<SwarmHistoryResult|null>(null),[status,setStatus]=useState<any>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[creating,setCreating]=useState(false),[editing,setEditing]=useState<string|null>(null),[addName,setAddName]=useState(''),[name,setName]=useState(''),[kind,setKind]=useState('explicit'),[definition,setDefinition]=useState(''),[query,setQuery]=useState(''),[groupQuery,setGroupQuery]=useState(''),[activity,setActivity]=useState('all'),[relationship,setRelationship]=useState('all'),[memberSort,setMemberSort]=useState('name:asc'),[pinned,setPinned]=useState<string[]>(()=>{try{const value=JSON.parse(localStorage.getItem('rs-watch-pinned-swarms')??'[]');return Array.isArray(value)?value.filter(v=>typeof v==='string').slice(0,100):[];}catch{return [];}}),[days,setDays]=useState('1'),[next,setNext]=useState<string|null>(null),[inspected,setInspected]=useState<SwarmMemberState|null>(null),[evidence,setEvidence]=useState<any>(null),[memberSnapshotAt,setMemberSnapshotAt]=useState<number|null>(null),[watched,setWatched]=useState<string|null>(null),[inspectedAt,setInspectedAt]=useState<number|null>(null);
 useEffect(()=>{if(groupRequest){setSelected(groupRequest.id);setGroupQuery('');}},[groupRequest]);
 const [historySelection,setHistorySelection]=useState<HistorySelection|null>(null);
 const memberRequest=useRef(0),lastMemberTrigger=useRef<HTMLButtonElement|null>(null);const selectedRef=useRef(selected);selectedRef.current=selected;const generation=useRef(0),groupList=useRef<HTMLDivElement>(null),memberList=useRef<HTMLDivElement>(null);const group=groups.find(g=>g.id===selected);const loadedGroup=useRef<string|null>(null);const stamp=useRef<{id:string;days:string;from:number;to:number}|null>(null);
 async function loadGroups(){const [g,s]=await Promise.all([request('/groups?limit=100'),request('/status')]);setGroups(g.groups);setStatus(s);}
 async function loadMembers(cursor?:string){if(!selected)return;const id=selected,requestId=++memberRequest.current;const params=new URLSearchParams({limit:'100',q:query,sort:memberSort.split(':')[0],direction:memberSort.split(':')[1],...(activity!=='all'?{activity}:{}),...(relationship!=='all'?{relationship}:{}),...(cursor?{cursor}:{})});const result=await request(`/groups/${id}/members?${params}`);if(selectedRef.current===id&&memberRequest.current===requestId){setMembers(result.members);setNext(result.nextCursor);setMemberSnapshotAt(result.asOf);}}
 async function loadHistory(){if(!selected)return;const id=selected;if(!stamp.current||stamp.current.id!==id||stamp.current.days!==days){const to=Date.now();stamp.current={id,days,to,from:to-Number(days)*86400000};}const s=stamp.current;const path=`/groups/${id}/history?from=${s.from}&to=${s.to}&resolution=auto&limit=1000`;const h=await request(path);let cursor=h.nextCursor;while(cursor&&selectedRef.current===id){const page=await request(path+'&cursor='+encodeURIComponent(cursor));h.buckets.push(...page.buckets);cursor=page.nextCursor;}h.nextCursor=cursor;if(selectedRef.current===id)setHistory(h);}
 useEffect(()=>{if(!active)return;let alive=true;const poll=()=>{void loadGroups().catch(e=>alive&&setError(e.message));};poll();const timer=setInterval(poll,5000);return()=>{alive=false;clearInterval(timer);};},[active]);
 useEffect(()=>{setSummary(null);setMembers([]);setInspected(null);setHistory(null);setHistorySelection(null);setError('');},[selected]);
 useEffect(()=>{const n=++generation.current;if(!active||!selected)return;const id=selected;let alive=true;const poll=async()=>{try{const s=await request(`/groups/${id}/summary`);if(alive&&generation.current===n)setSummary(s);}catch(e){if(alive)setError((e as Error).message);}};void poll();if(loadedGroup.current!==id||(!members.length&&!memberSnapshotAt))void loadMembers().catch(e=>alive&&setError(e.message));if(loadedGroup.current!==id||!history)void loadHistory().catch(e=>alive&&setError(e.message));loadedGroup.current=id;const timer=setInterval(poll,1000);return()=>{alive=false;clearInterval(timer);};},[selected,active]);
 useEffect(()=>{if(!active)return;void loadMembers().catch(e=>setError(e.message));},[query,activity,relationship,memberSort]);
 useEffect(()=>{try{localStorage.setItem('rs-watch-pinned-swarms',JSON.stringify(pinned));}catch{}},[pinned]);
 useEffect(()=>{if(!active)return;void loadHistory().catch(e=>setError(e.message));},[days]);
 async function action(fn:()=>Promise<unknown>){setBusy(true);setError('');try{await fn();await loadGroups();if(selectedRef.current){setSummary(await request(`/groups/${selectedRef.current}/summary`));await loadMembers();}}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
 const ask=()=>onAsk?.(`Analyze swarm ${group?.name} (id ${selected}, membership revision ${summary?.revision}) at ${new Date(summary?.asOf??Date.now()).toISOString()}. Explain observed activity and history with full denominators, Unknown members, freshness, and coverage limits. Research membership is not ownership or gameplay authority.`);
 const total=summary?.observedOnline??0;const historyBars=history?Array.from({length:Math.min(288,history.buckets.length)},(_,i)=>{const size=Math.ceil(history.buckets.length/288),part=history.buckets.slice(i*size,(i+1)*size);return part.length?{bucketStart:part[0].bucketStart,bucketEnd:part[part.length-1].bucketEnd,segments:part.flatMap(b=>b.segments)}:null;}).filter((b):b is NonNullable<typeof b>=>b!==null):[];
 return <section className={`swarm-workspace ${selected?'swarm-selected':''} ${inspected?'swarm-inspecting':''}`} aria-label="Swarm intelligence">
  <div className="swarm-header" role="banner"><div><Text size="1" color="gray">RS-PALANTIR · RESEARCH</Text><Heading size="5">Swarm intelligence</Heading><Text as="p" size="2" color="gray">Understand a group’s observed activity. Keep team ownership and command permissions separate.</Text></div><div className="swarm-actions"><Badge color={status?.source==='live'?'green':'gray'}>{status?.paused?'Collection paused':status?.source==='live'?'Shared feed connected':'Waiting for fresh roster'}</Badge><Button size="1" variant="soft" disabled={busy||!status} onClick={()=>void action(()=>request('/collector',{paused:!status?.paused},'PATCH'))}>{status?.paused?'Resume collection':'Pause collection'}</Button><Button size="1" onClick={()=>{setCreating(v=>!v);setEditing(null);}}>{creating?'Close new swarm':'New swarm'}</Button></div></div>
  {error&&<div className="swarm-error" role="alert">{error}<Button size="1" variant="ghost" onClick={()=>setError('')}>Dismiss</Button></div>}
  {creating&&<Card className="swarm-create"><form onSubmit={e=>{e.preventDefault();void action(async()=>{const d=kind==='explicit'?{kind,names:definition.split(/[\n,]/).map(v=>v.trim()).filter(Boolean)}:kind==='name-prefix'?{kind,prefix:definition.trim()}:{kind,teamId:definition.trim()};const current=groups.find(g=>g.id===editing);const g=editing?await request(`/groups/${editing}`,{name,definition:d,expectedRevision:current?.revision},'PATCH'):await request('/groups',{name,definition:d},'POST');setEditing(null);setSelected(g.id);setCreating(false);setName('');setDefinition('');});}}><Heading size="3">{editing?'Edit research group':'Create a research group'}</Heading><label>Group name<TextField.Root><TextField.Input value={name} onChange={e=>setName(e.target.value)} maxLength={80} placeholder="Mining group" required/></TextField.Root></label><FilterSelect label="Membership definition" value={kind} onValueChange={setKind} options={[{value:'explicit',label:'Exact player names'},{value:'name-prefix',label:'Name-prefix cohort'},{value:'team-binding',label:'Explicit team assignment'}]}/><label>{kind==='explicit'?'Player names, separated by commas or new lines':kind==='name-prefix'?'Literal name prefix (at least 2 characters)':'Exact team ID'}<TextArea value={definition} onChange={e=>setDefinition(e.target.value)} aria-label="Membership definition" placeholder={kind==='explicit'?'ExampleMiner1, ExampleMiner2':kind==='name-prefix'?'Example':'Team ID'}/></label><Text size="1" color="gray">Prefix matches are a research cohort. Shared operator, hostility and bot-control authority are unverified.</Text><Button type="submit" disabled={busy||!name.trim()||!definition.trim()}>Save swarm</Button></form></Card>}
  <div className="swarm-layout">
   <aside className="swarm-group-list" ref={groupList}><TextField.Root><TextField.Input aria-label="Search swarms" placeholder="Search swarms…" value={groupQuery} onChange={e=>setGroupQuery(e.target.value)}/></TextField.Root>{groups.filter(g=>g.name.toLowerCase().includes(groupQuery.toLowerCase())).sort((a,b)=>Number(pinned.includes(b.id))-Number(pinned.includes(a.id))||a.name.localeCompare(b.name)).map(g=><div key={g.id}><button className={`swarm-group ${g.id===selected?'selected':''}`} key={g.id} onClick={()=>{setSelected(g.id);setInspected(null);onContext?.({kind:'swarm',key:g.id,name:g.name});}}><strong>{pinned.includes(g.id)?'★ ':''}{g.name}</strong><span>{g.members.length} known · revision {g.revision}</span><small>{g.definition.kind==='name-prefix'?'Name-matched cohort':g.definition.kind==='team-binding'?'Assignment-bound research':'Explicit names'}</small></button><Button className="swarm-pin" size="1" variant="ghost" onClick={()=>setPinned(ids=>ids.includes(g.id)?ids.filter(id=>id!==g.id):[...ids,g.id])}>{pinned.includes(g.id)?'Unpin swarm':'Pin swarm'}</Button></div>)}{!groups.length&&<Text as="p" color="gray">No saved swarms yet. Add exact names or a prefix to begin collecting observations.</Text>}</aside>
   <div className="swarm-detail" role="region" aria-label="Selected swarm">
    {!selected?<Card><Heading size="4">What is a swarm doing?</Heading><Text as="p">Select a group to see movement, detail coverage and Unknown activity. Saving a group keeps the existing public feed running while this local service is on.</Text><Text as="p" color="gray">History starts when collected. Activity inference uses reviewed local game mappings. Unsupported signals remain Unknown; local source parity is unverified.</Text></Card>:<>
     <div className="swarm-actions"><Button className="swarm-back" size="1" variant="soft" onClick={()=>{setSelected(null);setInspected(null);}}>Back to swarms</Button><Heading size="4">{group?.name??'Loading swarm…'}</Heading><Badge>Revision {summary?.revision??group?.revision??'—'}</Badge><Button size="1" variant="soft" disabled={!onAsk||!summary} onClick={ask}>Ask assistant</Button>{group&&<><Button size="1" variant="ghost" onClick={()=>{setEditing(group.id);setName(group.name);setKind(group.definition.kind);setDefinition(group.definition.kind==='explicit'?group.definition.names.join(', '):group.definition.kind==='name-prefix'?group.definition.prefix:group.definition.teamId);setCreating(true);}}>Edit swarm</Button><Button size="1" variant="ghost" disabled={busy} onClick={()=>void action(async()=>{await request(`/groups/${group.id}?expectedRevision=${group.revision}`,undefined,'DELETE');setSelected(null);selectedRef.current=null;})}>Archive swarm</Button></>}</div>
     {group?.definition.kind==='name-prefix'&&<Text as="p" color="gray">Name-matched cohort; shared operator unverified.</Text>}
     <div className="swarm-metrics">{[['Known members',summary?.knownMembers],['Observed online',summary?.observedOnline],['Detailed coverage',summary?.detailedVisible],['Classified',summary?.classified],['Unknown activity',summary?.activityCounts?.unknown],['Not observed',summary?.notObserved]].map(([label,value])=><Card key={String(label)}><Text size="1" color="gray">{label}</Text><strong>{fmt(value as number|null)}</strong></Card>)}</div>
     <Text as="p" size="1" color="gray">Roster {age(summary?.coverage.rosterAt??null)} · Detail feed {age(summary?.coverage.detailAt??null)}. Not observed does not mean logged out.</Text>
     {summary?.coverage.gapReasons.length!==0&&summary&&<div className="swarm-notice">Current totals unavailable: {summary.coverage.gapReasons.join(', ')}. Last-known values are not live counts.</div>}
     <Card><Heading size="3">Observed activity mix</Heading><SwarmActivityChart counts={summary?.activityCounts??{}} total={total} selected={activity} onSelect={setActivity}/><Text as="p" size="1" color="gray">Activity is inferred from repeated reviewed signals; equipment, location or facing alone never establish PvP or a crafting task. Local source mapping parity is unverified.</Text></Card>
     {group&&<details className="swarm-research-connections" open><summary>Locations, comparisons, membership review & mission planning</summary><SwarmConnections active={active} key={group.id} group={group} groups={groups} history={history} onChanged={async()=>{await loadGroups();await loadMembers();}} onShowPlayer={onShowPlayer} onPrepareMission={onPrepareMission}/></details>}
     <Card><div className="swarm-actions"><Heading size="3">Activity history</Heading><FilterSelect label="History range" value={days} onValueChange={setDays} options={[{value:'1',label:'24 hours'},{value:'7',label:'7 days'},{value:'30',label:'30 days'},{value:'90',label:'90 days'},{value:'365',label:'1 year'}]}/><Button size="1" variant="ghost" onClick={()=>{stamp.current=null;void loadHistory().catch(e=>setError(e.message));}}>Refresh history</Button></div>{history&&<><Text as="p" size="1" color="gray">{history.resolution==='day'?'Daily':'Five-minute'} summaries · time-weighted member-seconds. Blank bars mean no observations. Collection began {new Date(history.collectionStartedAt).toLocaleString()}; earlier time is not collected. {history.gaps.filter(g=>g.reason!=='not_collected'&&g.to>history.collectionStartedAt).length} source/service gap intervals after collection began.</Text><SwarmHistoryChart key={`${selected}-${days}`} buckets={historyBars} onSelect={setHistorySelection}/>{historySelection&&selected&&<SwarmHistoryDetail groupId={selected} selection={historySelection} to={history.requestedRange.to} active={active} onClose={()=>setHistorySelection(null)}/>}<Text as="p" size="0" color="gray">Rounded coverage: {new Date(history.effectiveRange.from).toLocaleString()} – {new Date(history.effectiveRange.to).toLocaleString()}. {history.buckets.length>288?'Chart combines adjacent original buckets into at most 288 bars across the complete range. ':''}Raw evidence may expire while summaries remain.</Text></>}</Card>
     <Card className="swarm-members"><div className="swarm-actions"><Heading size="3">Members</Heading><TextField.Root><TextField.Input aria-label="Search swarm members" placeholder="Search exact names…" value={query} onChange={e=>setQuery(e.target.value)}/></TextField.Root><FilterSelect label="Activity filter" value={activity} onValueChange={setActivity} options={[{value:'all',label:'All activities'},...ACTIVITIES.map(a=>({value:a,label:ACTIVITY_LABELS[a]}))]}/><FilterSelect label="Relationship filter" value={relationship} onValueChange={setRelationship} options={[{value:'all',label:'All relationships'},{value:'owned',label:'My team'},{value:'ally',label:'Allies'},{value:'unclassified',label:'Unclassified'},{value:'other',label:'Other'}]}/><FilterSelect label="Sort swarm members" value={memberSort} onValueChange={setMemberSort} options={[{value:'name:asc',label:'Name A–Z'},{value:'name:desc',label:'Name Z–A'},{value:'activity:asc',label:'Activity'},{value:'lastObserved:desc',label:'Latest observation'},{value:'relationship:asc',label:'Relationship'}]}/><Button size="1" variant="ghost" onClick={()=>void loadMembers().catch(e=>setError(e.message))}>Refresh members</Button></div>{group&&<form className="swarm-actions" onSubmit={e=>{e.preventDefault();void action(async()=>{await request(`/groups/${group.id}/members/${encodeURIComponent(addName.trim())}`,{expectedRevision:group.revision,decision:'include',note:''},'PUT');setAddName('');});}}><TextField.Root><TextField.Input aria-label="Add exact player name" placeholder="Add exact player name" value={addName} onChange={e=>setAddName(e.target.value)} maxLength={32}/></TextField.Root><Button size="1" type="submit" disabled={busy||!addName.trim()}>Add member</Button></form>}<div className="swarm-member-scroll" ref={memberList}>{members.map(m=><button key={m.playerKey} className="swarm-member" onClick={e=>{lastMemberTrigger.current=e.currentTarget;setInspected(m);setInspectedAt(memberSnapshotAt);setEvidence(null);setWatched(null);}}><span><strong>{m.displayName}</strong><small>{m.membershipBasis} · {m.relationship==='owned'?'My team':m.relationship==='ally'?'Ally':'Unclassified / other'}</small></span><span>{m.presence==='observed-online'?ACTIVITY_LABELS[m.activity]:m.presence==='unknown'?'Presence unknown':'Not observed'}<small>{m.detailedVisible?'Detailed observation':'Roster only / unavailable'} · {m.certainty}</small></span><span>{m.position?`${m.position.x}, ${m.position.z} · floor ${m.position.plane}`:'Location unknown'}<small>{age(m.position?.observedAt??null)}</small></span></button>)}</div>{!members.length&&<Text as="p" color="gray">No members match this filter.</Text>}{next&&<Button size="1" onClick={()=>void loadMembers(next).catch(e=>setError(e.message))}>Next 100 members</Button>}<Text as="p" size="0" color="gray">Member snapshot {age(memberSnapshotAt)} · {memberSort.replace(':',' ')} · at most 100 rows. Refresh updates the snapshot and returns to the beginning.</Text></Card>
    </>}
   </div>
   {inspected&&<aside className="swarm-inspector"><Button size="1" variant="soft" onClick={()=>{setInspected(null);setTimeout(()=>lastMemberTrigger.current?.focus(),0);}}>Back to results</Button><Heading size="4">{inspected.displayName}</Heading><Text size="1" color="gray">Member snapshot {age(inspectedAt)}. Refresh members for a newer observation.</Text><Badge>{ACTIVITY_LABELS[inspected.activity]} · {inspected.certainty}</Badge><Text as="p">{inspected.reason.replaceAll('_',' ')}</Text><Text as="p" size="1" color="gray">Membership: {inspected.membershipBasis}. This does not grant control authority.</Text><div className="swarm-actions"><Button size="1" disabled={busy||watched===inspected.playerKey} onClick={()=>void action(async()=>{const r=await fetch('/api/watches',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:inspected.displayName,note:`Swarm research: ${group?.name??''}`})});if(!r.ok)throw new Error('Could not save player watch');setWatched(inspected.playerKey);})}>{watched===inspected.playerKey?'Watching player':'Watch player'}</Button><Button size="1" disabled={!onInspectPlayer} onClick={()=>onInspectPlayer?.(inspected.displayName)}>Player dossier</Button><Button size="1" disabled={!onShowPlayer} onClick={()=>onShowPlayer?.(inspected.displayName)}>Show on map</Button><Button size="1" disabled={!onPrepareMission} onClick={()=>onPrepareMission?.({id:crypto.randomUUID(),source:'player',asOf:inspectedAt??Date.now(),memberKeys:[inspected.playerKey],displayNames:[inspected.displayName]})}>Prepare mission selection</Button></div><Text as="p" size="1" color="gray">Show on map may move the single shared detail rectangle.</Text><Heading size="3">Evidence</Heading>{inspected.evidence.length?inspected.evidence.map(e=><Button key={e.id} size="1" variant="soft" onClick={()=>void request(`/evidence/${e.id}`).then(setEvidence).catch(err=>setEvidence({message:err.message}))}>{e.kind} · {age(e.observedAt)}</Button>):<Text as="p" color="gray">No retained detailed evidence. Movement uses successive roster positions.</Text>}{evidence&&<pre className="swarm-evidence">{JSON.stringify(evidence,null,2)}</pre>}{group&&<div className="swarm-actions"><Button size="1" variant="soft" disabled={busy} onClick={()=>void action(()=>request(`/groups/${group.id}/members/${encodeURIComponent(inspected.playerKey)}`,{expectedRevision:group.revision,decision:'include',note:''},'PUT'))}>Accept exact member</Button><Button size="1" variant="soft" disabled={busy} onClick={()=>void action(async()=>{await request(`/groups/${group.id}/members/${encodeURIComponent(inspected.playerKey)}`,{expectedRevision:group.revision,decision:'exclude',note:''},'PUT');setInspected(null);})}>Exclude from swarm</Button></div>}</aside>}
  </div>
 </section>;
}