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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
{' '}·{' '}
|
||||
Bear <span className="text-red-400 font-medium">{bearEvidence.toFixed(1)}</span>
|
||||
{' '}·{' '}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user