feat(ui): add /confluence page with picture-quality + slot evidence grid (M22 slice 11)

ConfluencePanel shows the current picture quality for a symbol+rack, a
color-coded evidence summary, a per-slot fired/not-fired evidence grid with
notes, and the reliability scorecard. Uses the existing LayoutShell and
CollapsibleSection patterns. ADR-0007: quality labels are evidence-based
descriptions, never buy/sell directives.
This commit is contained in:
Investor Flow Build
2026-08-10 22:06:00 -04:00
parent 44243618d5
commit 74ff851ed2
2 changed files with 215 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
'use client';
import { LayoutShell } from '@/components/LayoutShell';
import { ConfluencePanel } from '@/components/ConfluencePanel';
/**
* Confluence — entry/exit picture quality from the34-slot confluence rack.
* Evidence-based descriptions of a symbol's current setup (ADR-0007).
*/
export default function ConfluencePage() {
return (
<LayoutShell>
<ConfluencePanel />
</LayoutShell>
);
}
+200
View File
@@ -0,0 +1,200 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/trpc';
import { useActiveSymbol } from '@/stores/active-symbol-store';
import { CollapsibleSection } from '@/components/CollapsibleSection';
// ---------------------------------------------------------------------------
// Types (subset of server types for display)
// ---------------------------------------------------------------------------
type Quality = 'strong-bullish' | 'moderate-bullish' | 'weak-bullish' | 'mixed' | 'weak-bearish' | 'moderate-bearish' | 'strong-bearish' | 'sparse';
const QUALITY_COLOR: Record<Quality, string> = {
'strong-bullish': 'bg-green-600/20 text-green-300 border-green-600/40',
'moderate-bullish': 'bg-green-600/10 text-green-400 border-green-600/20',
'weak-bullish': 'bg-green-600/5 text-green-500 border-green-600/10',
'mixed': 'bg-zinc-700/30 text-zinc-300 border-zinc-600/30',
'weak-bearish': 'bg-red-600/5 text-red-500 border-red-600/10',
'moderate-bearish': 'bg-red-600/10 text-red-400 border-red-600/20',
'strong-bearish': 'bg-red-600/20 text-red-300 border-red-600/40',
'sparse': 'bg-zinc-800/50 text-zinc-500 border-zinc-700/30',
};
const QUALITY_LABEL: Record<Quality, string> = {
'strong-bullish': 'Strong Bullish',
'moderate-bullish': 'Moderate Bullish',
'weak-bullish': 'Weak Bullish',
'mixed': 'Mixed',
'weak-bearish': 'Weak Bearish',
'moderate-bearish': 'Moderate Bearish',
'strong-bearish': 'Strong Bearish',
'sparse': 'Sparse Data',
};
const BODY_BADGE: Record<string, string> = {
bull: 'bg-green-600/15 text-green-400',
bear: 'bg-red-600/15 text-red-400',
exit: 'bg-amber-600/15 text-amber-400',
};
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
export function ConfluencePanel() {
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
const [symbol, setSymbol] = useState(activeSymbol);
const [selectedRackId, setSelectedRackId] = useState<string | null>(null);
// Racks
const [racks, setRacks] = useState<{ system: Array<{ id: string; name: string }>; user: Array<{ id: string; name: string }> }>({ system: [], user: [] });
useEffect(() => {
api.confluence.racks().then(setRacks).catch(() => {});
}, []);
// Evaluation
const [evaluation, setEvaluation] = useState<Record<string, unknown> | null>(null);
const rackId = selectedRackId ?? racks.system[0]?.id ?? null;
useEffect(() => {
if (!symbol) return;
api.confluence.evaluation(symbol, { rackId: rackId ?? undefined, limit: 6 })
.then((r) => setEvaluation(r as Record<string, unknown>))
.catch(() => setEvaluation(null));
}, [symbol, rackId]);
// Scorecard
const [scorecard, setScorecard] = useState<{ slots: Array<Record<string, unknown>>; note: string } | null>(null);
useEffect(() => {
if (!symbol) return;
api.confluence.scorecard(symbol)
.then((r) => setScorecard(r as { slots: Array<Record<string, unknown>>; note: string }))
.catch(() => setScorecard(null));
}, [symbol]);
// Slots
const [slotMeta, setSlotMeta] = useState<Map<string, { name: string; body: string; explain: string }>>(new Map());
useEffect(() => {
api.confluence.slots()
.then((r) => setSlotMeta(new Map(r.slots.map((s) => [s.id, { name: s.name, body: s.body, explain: s.explain }]))))
.catch(() => {});
}, []);
const latest = (evaluation?.latest as Record<string, unknown>) ?? null;
const quality = (latest?.quality as Quality) ?? null;
const assessments = (latest?.assessments as Array<{ id: string; state: string; note?: string }>) ?? [];
const bullEvidence = Number(latest?.bullEvidence ?? 0);
const bearEvidence = Number(latest?.bearEvidence ?? 0);
const change = evaluation?.change as { changed: boolean; changeType: string; previous: string | null } | null;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="block text-xs text-fg-muted mb-1">Symbol</label>
<input
type="text"
value={symbol}
onChange={(e) => setSymbol(e.target.value.toUpperCase().trim())}
className="w-24 rounded bg-bg-secondary border border-fg-muted/20 px-2 py-1.5 text-sm text-fg"
maxLength={12}
/>
</div>
<div>
<label className="block text-xs text-fg-muted mb-1">Rack</label>
<select
value={rackId ?? ''}
onChange={(e) => setSelectedRackId(e.target.value || null)}
className="rounded bg-bg-secondary border border-fg-muted/20 px-2 py-1.5 text-sm text-fg min-w-[180px]"
>
{racks.system.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
{racks.user.map((r) => (
<option key={r.id} value={r.id}>{r.name} (yours)</option>
))}
</select>
</div>
</div>
{/* Picture quality + evidence summary */}
{quality ? (
<div className="flex flex-wrap items-center gap-4">
<div className={`inline-flex items-center gap-2 rounded border px-3 py-1.5 text-sm font-medium ${QUALITY_COLOR[quality]}`}>
{QUALITY_LABEL[quality]}
</div>
<div className="text-xs text-fg-muted">
Bull <span className="text-green-400 font-medium">{bullEvidence.toFixed(1)}</span>
{' '}&middot;{' '}
Bear <span className="text-red-400 font-medium">{bearEvidence.toFixed(1)}</span>
{' '}&middot;{' '}
Net <span className={bullEvidence - bearEvidence >= 0 ? 'text-green-400' : 'text-red-400'}>
{(bullEvidence - bearEvidence).toFixed(1)}
</span>
</div>
{change?.changed && (
<div className={`text-xs px-2 py-0.5 rounded ${change.changeType === 'improved' ? 'bg-green-600/10 text-green-400' : 'bg-red-600/10 text-red-400'}`}>
{change.changeType === 'improved' ? 'Improved' : 'Deteriorated'} from {change.previous ?? '?'}
</div>
)}
</div>
) : evaluation !== null ? (
<div className="text-sm text-fg-muted">No evaluations yet for {symbol}. Data will appear once the confluence evaluator runs.</div>
) : null}
{/* Slot evidence grid */}
{assessments.length > 0 && (
<CollapsibleSection title="Slot Evidence" defaultOpen>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{assessments.map((a) => {
const meta = slotMeta.get(a.id);
const fired = a.state === 'fired';
return (
<div key={a.id} className={`rounded border px-2.5 py-2 text-xs ${fired ? 'border-fg-muted/30 bg-bg-secondary' : 'border-fg-muted/10 bg-bg-secondary/50 opacity-60'}`}>
<div className="flex items-center gap-2 mb-1">
<span className={`inline-block w-1.5 h-1.5 rounded-full ${fired ? 'bg-green-400' : 'bg-zinc-600'}`} />
<span className="font-medium text-fg truncate">{meta?.name ?? a.id}</span>
<span className={`ml-auto text-[10px] px-1 rounded ${BODY_BADGE[meta?.body ?? 'bull']}`}>
{meta?.body ?? '?'}
</span>
</div>
{a.note && <div className="text-fg-muted leading-tight">{a.note}</div>}
</div>
);
})}
</div>
</CollapsibleSection>
)}
{/* Scorecard */}
{scorecard && scorecard.slots.length > 0 && (
<CollapsibleSection title="Reliability Scorecard">
<p className="text-xs text-fg-muted mb-2">{scorecard.note}</p>
<table className="w-full text-xs">
<thead>
<tr className="text-fg-muted border-b border-fg-muted/20">
<th className="text-left py-1">Slot</th>
<th className="text-right py-1">Fires</th>
<th className="text-right py-1">Hit Rate</th>
<th className="text-right py-1">Reliability</th>
</tr>
</thead>
<tbody>
{scorecard.slots
.sort((a, b) => ((b.hitRate as number) ?? 0) - ((a.hitRate as number) ?? 0))
.map((s) => (
<tr key={s.slotId as string} className="border-b border-fg-muted/10">
<td className="py-1">{slotMeta.get(s.slotId as string)?.name ?? (s.slotId as string)}</td>
<td className="text-right py-1">{s.fires as number}</td>
<td className="text-right py-1">{s.hitRate != null ? `${((s.hitRate as number) * 100).toFixed(0)}%` : '-'}</td>
<td className="text-right py-1">{(s.reliabilityWeight as number).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</CollapsibleSection>
)}
</div>
);
}