Files
signal-platform/frontend/src/components/ui/ScoreCard.tsx
T
dennisthiessen 8c36cfcef1
Deploy / lint (push) Successful in 6s
Deploy / test (push) Failing after 48s
Deploy / deploy (push) Has been skipped
Make live signal reads non-mutating
2026-07-03 10:09:46 +02:00

178 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { DimensionBreakdownPanel } from '../ticker/DimensionBreakdownPanel';
import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
interface ScoreCardProps {
compositeScore: number | null;
dimensions: DimensionScoreDetail[];
compositeBreakdown?: CompositeBreakdown | null;
/** Hide the composite ring/header when the composite is shown elsewhere
* (e.g. the Standing matrix) and this card only carries the dimension detail. */
showComposite?: boolean;
}
function scoreColor(score: number): string {
if (score > 70) return 'text-emerald-400';
if (score >= 40) return 'text-amber-400';
return 'text-red-400';
}
function ringGradient(score: number): string {
if (score > 70) return '#10b981';
if (score >= 40) return '#f59e0b';
return '#ef4444';
}
function barGradient(score: number): string {
if (score > 70) return 'from-emerald-500 to-emerald-400';
if (score >= 40) return 'from-amber-500 to-amber-400';
return 'from-red-500 to-red-400';
}
function ScoreRing({ score }: { score: number }) {
const radius = 36;
const circumference = 2 * Math.PI * radius;
const clamped = Math.max(0, Math.min(100, score));
const offset = circumference - (clamped / 100) * circumference;
const color = ringGradient(score);
return (
<div className="relative inline-flex items-center justify-center">
<svg width="88" height="88" className="-rotate-90">
<circle cx="44" cy="44" r={radius} fill="none" strokeWidth="6" className="stroke-white/[0.06]" />
<circle
cx="44" cy="44" r={radius} fill="none" strokeWidth="6" strokeLinecap="round"
strokeDasharray={circumference} strokeDashoffset={offset}
stroke={color}
style={{ filter: `drop-shadow(0 0 8px ${color}40)`, transition: 'all 0.6s ease' }}
/>
</svg>
<span className={`absolute text-lg font-bold ${scoreColor(score)}`}>
{Math.round(score)}
</span>
</div>
);
}
export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, showComposite = true }: ScoreCardProps) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = (dimension: string) => {
setExpanded((prev) => ({ ...prev, [dimension]: !prev[dimension] }));
};
return (
<div className="glass p-5">
{showComposite && (
<div className="flex items-center gap-4">
{compositeScore !== null ? (
<ScoreRing score={compositeScore} />
) : (
<div className="flex h-[88px] w-[88px] items-center justify-center text-sm text-gray-500">N/A</div>
)}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Composite Score</p>
<p className={`text-2xl font-bold ${compositeScore !== null ? scoreColor(compositeScore) : 'text-gray-500'}`}>
{compositeScore !== null ? Math.round(compositeScore) : '—'}
</p>
{compositeBreakdown && (
<p className="mt-1 text-[10px] text-gray-500 leading-snug max-w-[220px]" data-testid="renorm-explanation">
{compositeBreakdown.sentiment_adjustment != null &&
compositeBreakdown.base_score != null &&
Math.abs(compositeBreakdown.sentiment_adjustment) >= 0.05
? `Base ${Math.round(compositeBreakdown.base_score)} · sentiment ${
compositeBreakdown.sentiment_adjustment >= 0 ? '+' : ''
}${Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)}`
: 'Weighted base of the other dimensions; sentiment adjusts it up or down.'}
</p>
)}
</div>
</div>
)}
{dimensions.length > 0 && (
<div className={`${showComposite ? 'mt-5' : ''} space-y-1`}>
<p className="text-[10px] font-medium uppercase tracking-widest text-gray-500">Dimensions</p>
{dimensions.map((d) => {
const isExpanded = expanded[d.dimension] ?? false;
const weight = compositeBreakdown?.renormalized_weights?.[d.dimension]
?? compositeBreakdown?.weights?.[d.dimension];
return (
<div key={d.dimension}>
<button
type="button"
className="flex w-full items-center justify-between text-sm py-1 hover:bg-white/[0.03] rounded transition-colors"
onClick={() => d.breakdown && toggleExpand(d.dimension)}
data-testid={`dimension-row-${d.dimension}`}
>
<span className="text-gray-300 capitalize flex items-center gap-1.5">
{d.breakdown && (
<span className="text-gray-500 text-[10px]">{isExpanded ? '▾' : '▸'}</span>
)}
{d.dimension}
</span>
<div className="flex items-center gap-2">
{d.dimension === 'sentiment' && compositeBreakdown?.sentiment_adjustment != null ? (
<span
className={`text-[10px] tabular-nums ${
compositeBreakdown.sentiment_adjustment > 0.05
? 'text-emerald-400/80'
: compositeBreakdown.sentiment_adjustment < -0.05
? 'text-red-400/80'
: 'text-gray-500'
}`}
data-testid="weight-sentiment"
title="Points sentiment adds to or subtracts from the base composite"
>
{compositeBreakdown.sentiment_adjustment >= 0 ? '+' : ''}
{Math.abs(compositeBreakdown.sentiment_adjustment).toFixed(1)}
</span>
) : weight != null ? (
<span className="text-[10px] text-gray-500 tabular-nums" data-testid={`weight-${d.dimension}`}>
{Math.round(weight * 100)}%
</span>
) : null}
<div className="h-1.5 w-20 rounded-full bg-white/[0.06] overflow-hidden">
<div
className={`h-1.5 rounded-full bg-gradient-to-r ${barGradient(d.score)} transition-all duration-500`}
style={{ width: `${Math.max(0, Math.min(100, d.score))}%` }}
/>
</div>
<span className={`w-8 text-right font-medium text-xs ${scoreColor(d.score)}`}>
{Math.round(d.score)}
</span>
</div>
</button>
{isExpanded && d.breakdown && (
<div className="ml-4 mt-1 mb-2 pl-3 border-l border-white/[0.06]">
<DimensionBreakdownPanel breakdown={d.breakdown} />
</div>
)}
</div>
);
})}
{/* Missing dimensions */}
{compositeBreakdown && compositeBreakdown.missing_dimensions.length > 0 && (
<div className="mt-2 space-y-1">
{compositeBreakdown.missing_dimensions
.filter((dim) => !dimensions.some((d) => d.dimension === dim))
.map((dim) => (
<div
key={dim}
className="flex items-center justify-between text-sm py-1 opacity-40"
data-testid={`missing-dimension-${dim}`}
>
<span className="text-gray-500 capitalize">{dim}</span>
<span className="text-[10px] text-gray-600 italic">redistributed</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}