major update
Deploy / lint (push) Failing after 8s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-02-27 16:08:09 +01:00
parent 61ab24490d
commit 181cfe6588
71 changed files with 7647 additions and 281 deletions
@@ -0,0 +1,66 @@
import type { ScoreBreakdown } from '../../lib/types';
interface DimensionBreakdownPanelProps {
breakdown: ScoreBreakdown;
}
function formatWeight(weight: number): string {
return `${Math.round(weight * 100)}%`;
}
function formatRawValue(value: number | string | null): string {
if (value === null) return '—';
if (typeof value === 'string') return value;
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
}
export function DimensionBreakdownPanel({ breakdown }: DimensionBreakdownPanelProps) {
return (
<div className="space-y-3">
{/* Sub-score rows */}
{breakdown.sub_scores.length > 0 && (
<div className="space-y-1.5">
{breakdown.sub_scores.map((sub) => (
<div
key={sub.name}
data-testid="sub-score-row"
className="flex items-center justify-between gap-2 text-sm"
>
<span className="text-gray-400 min-w-0 truncate">{sub.name}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-gray-200 tabular-nums">{sub.score.toFixed(1)}</span>
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-medium text-gray-400">
{formatWeight(sub.weight)}
</span>
<span className="text-gray-500 text-xs tabular-nums w-16 text-right">
{formatRawValue(sub.raw_value)}
</span>
</div>
</div>
))}
</div>
)}
{/* Formula description */}
{breakdown.formula && (
<p className="text-xs text-gray-500 leading-relaxed">{breakdown.formula}</p>
)}
{/* Unavailable sub-scores */}
{breakdown.unavailable.length > 0 && (
<div className="space-y-1">
{breakdown.unavailable.map((item) => (
<div
key={item.name}
data-testid="unavailable-row"
className="flex items-center justify-between text-sm"
>
<span className="text-gray-600">{item.name}</span>
<span className="text-gray-600 text-xs italic">{item.reason}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { formatPercent, formatLargeNumber } from '../../lib/format';
import type { FundamentalResponse } from '../../lib/types';
@@ -5,30 +6,106 @@ interface FundamentalsPanelProps {
data: FundamentalResponse;
}
const FIELD_LABELS: Record<string, string> = {
pe_ratio: 'P/E Ratio',
revenue_growth: 'Revenue Growth',
earnings_surprise: 'Earnings Surprise',
market_cap: 'Market Cap',
};
export function FundamentalsPanel({ data }: FundamentalsPanelProps) {
const [expanded, setExpanded] = useState<boolean>(false);
const items = [
{ label: 'P/E Ratio', value: data.pe_ratio !== null ? data.pe_ratio.toFixed(2) : '—' },
{ label: 'Revenue Growth', value: data.revenue_growth !== null ? formatPercent(data.revenue_growth) : '—' },
{ label: 'Earnings Surprise', value: data.earnings_surprise !== null ? formatPercent(data.earnings_surprise) : '—' },
{ label: 'Market Cap', value: data.market_cap !== null ? formatLargeNumber(data.market_cap) : '—' },
{ key: 'pe_ratio', label: 'P/E Ratio', value: data.pe_ratio, format: (v: number) => v.toFixed(2) },
{ key: 'revenue_growth', label: 'Revenue Growth', value: data.revenue_growth, format: formatPercent },
{ key: 'earnings_surprise', label: 'Earnings Surprise', value: data.earnings_surprise, format: formatPercent },
{ key: 'market_cap', label: 'Market Cap', value: data.market_cap, format: formatLargeNumber },
];
const unavailableEntries = Object.entries(data.unavailable_fields ?? {});
return (
<div className="glass p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Fundamentals</h3>
<div className="space-y-2.5 text-sm">
{items.map((item) => (
<div key={item.label} className="flex justify-between">
<span className="text-gray-400">{item.label}</span>
<span className="text-gray-200">{item.value}</span>
</div>
))}
{data.fetched_at && (
<p className="mt-2 text-xs text-gray-500">
Updated {new Date(data.fetched_at).toLocaleDateString()}
</p>
)}
{items.map((item) => {
const reason = data.unavailable_fields?.[item.key];
let display: React.ReactNode;
let valueClass = 'text-gray-200';
if (item.value !== null) {
display = item.format(item.value);
} else if (reason) {
display = reason;
valueClass = 'text-amber-400';
} else {
display = '—';
}
return (
<div key={item.key} className="flex justify-between">
<span className="text-gray-400">{item.label}</span>
<span className={valueClass}>{display}</span>
</div>
);
})}
</div>
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors"
aria-expanded={expanded}
aria-label={expanded ? 'Collapse details' : 'Expand details'}
>
<svg
className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded && (
<div className="mt-3 space-y-3 border-t border-white/10 pt-3">
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Data Source</span>
<span className="text-gray-300">FMP</span>
</div>
{data.fetched_at && (
<div className="flex justify-between">
<span className="text-gray-500">Fetched</span>
<span className="text-gray-300">{new Date(data.fetched_at).toLocaleString()}</span>
</div>
)}
</div>
{unavailableEntries.length > 0 && (
<div>
<span className="text-xs font-medium uppercase tracking-widest text-gray-500">Unavailable Fields</span>
<ul className="mt-1 space-y-1">
{unavailableEntries.map(([field, reason]) => (
<li key={field} className="flex justify-between text-sm">
<span className="text-gray-400">{FIELD_LABELS[field] ?? field}</span>
<span className="text-amber-400">{reason}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{!expanded && data.fetched_at && (
<p className="mt-2 text-xs text-gray-500">
Updated {new Date(data.fetched_at).toLocaleDateString()}
</p>
)}
</div>
);
}
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { formatPercent } from '../../lib/format';
import type { SentimentResponse } from '../../lib/types';
@@ -12,32 +13,84 @@ const classificationColors: Record<string, string> = {
};
export function SentimentPanel({ data }: SentimentPanelProps) {
const [expanded, setExpanded] = useState<boolean>(false);
const latest = data.scores[0];
return (
<div className="glass p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Sentiment</h3>
{latest ? (
<div className="space-y-2.5 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Classification</span>
<span className={classificationColors[latest.classification] ?? 'text-gray-300'}>
{latest.classification}
</span>
<>
<div className="space-y-2.5 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Classification</span>
<span className={classificationColors[latest.classification] ?? 'text-gray-300'}>
{latest.classification}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Confidence</span>
<span className="text-gray-200">{formatPercent(latest.confidence)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Dimension Score</span>
<span className="text-gray-200">{data.dimension_score !== null ? Math.round(data.dimension_score) : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Sources</span>
<span className="text-gray-200">{data.count}</span>
</div>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Confidence</span>
<span className="text-gray-200">{formatPercent(latest.confidence)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Dimension Score</span>
<span className="text-gray-200">{data.dimension_score !== null ? Math.round(data.dimension_score) : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Sources</span>
<span className="text-gray-200">{data.count}</span>
</div>
</div>
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors"
aria-expanded={expanded}
aria-label={expanded ? 'Collapse details' : 'Expand details'}
>
<svg
className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded && (
<div className="mt-3 space-y-3 border-t border-white/10 pt-3">
<div>
<span className="text-xs font-medium uppercase tracking-widest text-gray-500">Reasoning</span>
<p className="mt-1 text-sm text-gray-300">
{latest.reasoning || 'No reasoning available'}
</p>
</div>
{latest.citations.length > 0 && (
<div>
<span className="text-xs font-medium uppercase tracking-widest text-gray-500">Citations</span>
<ul className="mt-1 space-y-1">
{latest.citations.map((citation, idx) => (
<li key={idx}>
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-400 hover:text-blue-300 underline break-all"
>
{citation.title || citation.url}
</a>
</li>
))}
</ul>
</div>
)}
</div>
)}
</>
) : (
<p className="text-sm text-gray-500">No sentiment data available</p>
)}