From 74ff851ed2e8ab0c9de1ccaf5b9d1c982910802c Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Mon, 10 Aug 2026 22:06:00 -0400 Subject: [PATCH] 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. --- app/src/app/confluence/page.tsx | 15 ++ app/src/components/ConfluencePanel.tsx | 200 +++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 app/src/app/confluence/page.tsx create mode 100644 app/src/components/ConfluencePanel.tsx diff --git a/app/src/app/confluence/page.tsx b/app/src/app/confluence/page.tsx new file mode 100644 index 0000000..107d782 --- /dev/null +++ b/app/src/app/confluence/page.tsx @@ -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 ( + + + + ); +} \ No newline at end of file diff --git a/app/src/components/ConfluencePanel.tsx b/app/src/components/ConfluencePanel.tsx new file mode 100644 index 0000000..b1af320 --- /dev/null +++ b/app/src/components/ConfluencePanel.tsx @@ -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 = { + '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 = { + '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 = { + 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(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 | 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)) + .catch(() => setEvaluation(null)); + }, [symbol, rackId]); + + // Scorecard + const [scorecard, setScorecard] = useState<{ slots: Array>; note: string } | null>(null); + useEffect(() => { + if (!symbol) return; + api.confluence.scorecard(symbol) + .then((r) => setScorecard(r as { slots: Array>; note: string })) + .catch(() => setScorecard(null)); + }, [symbol]); + + // Slots + const [slotMeta, setSlotMeta] = useState>(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) ?? 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 ( +
+ {/* Header */} +
+
+ + 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} + /> +
+
+ + +
+
+ + {/* Picture quality + evidence summary */} + {quality ? ( +
+
+ {QUALITY_LABEL[quality]} +
+
+ Bull {bullEvidence.toFixed(1)} + {' '}·{' '} + Bear {bearEvidence.toFixed(1)} + {' '}·{' '} + Net = 0 ? 'text-green-400' : 'text-red-400'}> + {(bullEvidence - bearEvidence).toFixed(1)} + +
+ {change?.changed && ( +
+ {change.changeType === 'improved' ? 'Improved' : 'Deteriorated'} from {change.previous ?? '?'} +
+ )} +
+ ) : evaluation !== null ? ( +
No evaluations yet for {symbol}. Data will appear once the confluence evaluator runs.
+ ) : null} + + {/* Slot evidence grid */} + {assessments.length > 0 && ( + +
+ {assessments.map((a) => { + const meta = slotMeta.get(a.id); + const fired = a.state === 'fired'; + return ( +
+
+ + {meta?.name ?? a.id} + + {meta?.body ?? '?'} + +
+ {a.note &&
{a.note}
} +
+ ); + })} +
+
+ )} + + {/* Scorecard */} + {scorecard && scorecard.slots.length > 0 && ( + +

{scorecard.note}

+ + + + + + + + + + + {scorecard.slots + .sort((a, b) => ((b.hitRate as number) ?? 0) - ((a.hitRate as number) ?? 0)) + .map((s) => ( + + + + + + + ))} + +
SlotFiresHit RateReliability
{slotMeta.get(s.slotId as string)?.name ?? (s.slotId as string)}{s.fires as number}{s.hitRate != null ? `${((s.hitRate as number) * 100).toFixed(0)}%` : '-'}{(s.reliabilityWeight as number).toFixed(2)}
+
+ )} +
+ ); +} \ No newline at end of file