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