feat: score justification on trade cards

- Add ScorePillars: compact 4-pillar mini-bars always visible under score
  bar (📰/📅/📈/⚖️ with color-coded fill — instant visual of pillar distribution)
- Add ScoreJustification: replaces old per-click BucketBreakdown with full
  text reasoning shown at once — key_catalyst highlighted in yellow callout,
  each pillar shows score + GPT-4o comment + all sub-pillars with comments
- Rename expand button to "↳ Justification du score" for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-16 22:05:51 +02:00
parent 929283045f
commit 4bbcd7a3a6

View File

@@ -53,46 +53,74 @@ function BucketBar({ score, max }: { score: number; max: number }) {
)
}
function BucketBreakdown({ buckets }: { buckets: any[] }) {
const [openBucket, setOpenBucket] = useState<string | null>(null)
// Compact 4-pillar mini-bars always visible on card
function ScorePillars({ buckets }: { buckets: any[] }) {
if (!buckets?.length) return null
return (
<div className="space-y-1 text-xs">
<div className="grid grid-cols-4 gap-1 mb-2">
{buckets.map((b: any) => {
const pct = b.max > 0 ? Math.round((b.score / b.max) * 100) : 0
const isOpen = openBucket === b.id
const color = pct >= 70 ? 'bg-emerald-500' : pct >= 45 ? 'bg-yellow-500' : 'bg-red-500/70'
return (
<div key={b.id} className="bg-dark-700/60 rounded overflow-hidden">
<button
className="w-full flex items-center gap-1.5 px-2 py-1.5 hover:bg-dark-600/60 transition-colors text-left"
onClick={() => setOpenBucket(isOpen ? null : b.id)}
>
<span>{BUCKET_ICONS[b.id] ?? '•'}</span>
<span className="text-slate-400 flex-1 truncate">{b.label}</span>
<div key={b.id} className="flex flex-col items-center gap-0.5">
<div className="w-full bg-slate-700/60 rounded-full h-1">
<div className={clsx('h-1 rounded-full', color)} style={{ width: `${pct}%` }} />
</div>
<span className="text-[9px] text-slate-600 truncate w-full text-center leading-none">
{BUCKET_ICONS[b.id]} {b.score}/{b.max}
</span>
</div>
)
})}
</div>
)
}
// Full justification panel — all comments at once, no per-bucket clicking
function ScoreJustification({ buckets, keyCatalyst }: { buckets: any[]; keyCatalyst?: string }) {
return (
<div className="space-y-2 text-xs">
{keyCatalyst && (
<div className="flex gap-1.5 bg-yellow-400/5 border border-yellow-400/20 rounded px-2 py-1.5">
<span className="text-yellow-400 shrink-0">🔑</span>
<p className="text-yellow-200/80 leading-snug">{keyCatalyst}</p>
</div>
)}
{buckets.map((b: any) => {
const pct = b.max > 0 ? Math.round((b.score / b.max) * 100) : 0
const scoreColor = pct >= 70 ? 'text-emerald-400' : pct >= 45 ? 'text-yellow-400' : 'text-red-400'
return (
<div key={b.id} className="bg-dark-700/60 rounded p-2 space-y-1.5">
{/* Pillar header */}
<div className="flex items-center gap-1.5">
<span className="text-base leading-none">{BUCKET_ICONS[b.id] ?? '•'}</span>
<span className="text-slate-300 font-medium flex-1">{b.label}</span>
<BucketBar score={b.score} max={b.max} />
<span className={clsx('font-mono w-9 text-right shrink-0', pct >= 75 ? 'text-emerald-400' : pct >= 50 ? 'text-yellow-400' : 'text-red-400')}>
<span className={clsx('font-mono text-right shrink-0 font-bold', scoreColor)}>
{b.score}/{b.max}
</span>
{isOpen ? <ChevronUp className="w-2.5 h-2.5 text-slate-600 shrink-0" /> : <ChevronDown className="w-2.5 h-2.5 text-slate-600 shrink-0" />}
</button>
{isOpen && (
<div className="px-2 pb-2 border-t border-slate-700/30 space-y-2 pt-1.5">
{b.comment && (
<p className="text-slate-500 italic">{b.comment}</p>
)}
{b.subs?.map((sub: any) => {
</div>
{/* Pillar comment */}
{b.comment && (
<p className="text-slate-400 leading-snug pl-1 border-l-2 border-slate-600">{b.comment}</p>
)}
{/* Sub-pillars */}
{b.subs?.length > 0 && (
<div className="pl-2 space-y-1.5 border-l border-slate-700/50 ml-1">
{b.subs.map((sub: any) => {
const subPct = sub.max > 0 ? Math.round((sub.score / sub.max) * 100) : 0
const subColor = subPct >= 70 ? 'text-emerald-400' : subPct >= 45 ? 'text-yellow-400' : 'text-slate-500'
return (
<div key={sub.id} className="pl-1 space-y-0.5">
<div className="flex items-center gap-1.5">
<span className="text-xs">{BUCKET_ICONS[sub.id] ?? ''}</span>
<div key={sub.id} className="space-y-0.5">
<div className="flex items-center gap-1">
<span className="text-[11px]">{BUCKET_ICONS[sub.id] ?? ''}</span>
<span className="text-slate-500 flex-1 truncate">{sub.label}</span>
<BucketBar score={sub.score} max={sub.max} />
<span className={clsx('font-mono w-7 text-right shrink-0', subPct >= 75 ? 'text-emerald-400' : subPct >= 50 ? 'text-yellow-400' : 'text-slate-600')}>
<span className={clsx('font-mono text-[10px] shrink-0', subColor)}>
{sub.score}/{sub.max}
</span>
</div>
{sub.comment && (
<p className="text-slate-600 italic ml-4">{sub.comment}</p>
<p className="text-slate-600 leading-snug text-[10px] pl-3">{sub.comment}</p>
)}
</div>
)
@@ -221,11 +249,16 @@ function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
)}
</div>
{/* Score bar */}
{/* Score bar + 4-pillar mini breakdown */}
{effectiveScore !== null && (
<div className="bg-dark-600 rounded-full h-1.5 mb-2">
<div className={clsx('h-1.5 rounded-full', scoreBg(effectiveScore))} style={{ width: `${effectiveScore}%` }} />
</div>
<>
<div className="bg-dark-600 rounded-full h-1.5 mb-1.5">
<div className={clsx('h-1.5 rounded-full', scoreBg(effectiveScore))} style={{ width: `${effectiveScore}%` }} />
</div>
{scoreInfo?.buckets?.length > 0 && (
<ScorePillars buckets={scoreInfo.buckets} />
)}
</>
)}
{/* EV / Profile match row — always shown when scored, helps understand why trade is/isn't logged */}
@@ -300,18 +333,21 @@ function TradeCard({ item, onAdd, macroInfo, addedInfo, profiles }: {
</div>
)}
{/* Expandable score breakdown */}
{/* Expandable score justification */}
{effectiveScore !== null && (scoreInfo?.buckets?.length > 0 || Object.keys(breakdown).length > 0) && (
<>
<button onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-1.5">
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 mb-1.5 transition-colors">
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
Détail du score par pilier
{expanded ? 'Masquer la justification' : '↳ Justification du score'}
</button>
{expanded && (
<div className="mb-2">
{scoreInfo?.buckets?.length > 0 ? (
<BucketBreakdown buckets={scoreInfo.buckets} />
<ScoreJustification
buckets={scoreInfo.buckets}
keyCatalyst={scoreInfo.key_catalyst}
/>
) : (
<div className="space-y-1 bg-dark-700/50 rounded p-2">
{Object.entries(breakdown).map(([k, v]: [string, any]) => (