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:
@@ -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]) => (
|
||||
|
||||
Reference in New Issue
Block a user