← back to Norma
components/email-analyzer/ScorePanel.tsx
493 lines
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Gauge,
RefreshCw,
Loader2,
ArrowUp,
ArrowDown,
Sparkles,
CheckCircle2,
Target,
Trophy,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import Thermometer from './Thermometer';
export interface ScoreItem {
key: string;
label: string;
score: number;
rationale: string;
}
interface Improvement {
key: string;
label: string;
delta: number;
note: string;
}
interface NextStep {
key: string;
label: string;
suggestion: string;
expectedGain: number;
}
interface ScoreSnapshot {
scores: ScoreItem[];
overall: number;
whatChanged: string;
improvements: Improvement[];
nextSteps: NextStep[];
}
interface ScorePanelProps {
email: string;
/** Changing this triggers an automatic re-score. Pass the activeVersionId. */
autoRunKey?: string;
/** Called when user clicks a suggestion — usually wired to the rewrite chat. */
onApplySuggestion?: (suggestion: string, label: string) => void;
}
function scoreColor(score: number): string {
if (score >= 75) return 'var(--color-success)';
if (score >= 50) return 'var(--color-secondary)';
return 'var(--color-error)';
}
/** Per-versionId cache of prior score snapshots so we can diff forward. */
const SNAPSHOT_CACHE_KEY = 'norma-analyzer-score-snapshots';
function loadCache(): Record<string, ScoreSnapshot> {
try {
const raw = localStorage.getItem(SNAPSHOT_CACHE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function saveCache(cache: Record<string, ScoreSnapshot>) {
try {
localStorage.setItem(SNAPSHOT_CACHE_KEY, JSON.stringify(cache));
} catch { /* noop */ }
}
export default function ScorePanel({ email, autoRunKey, onApplySuggestion }: ScorePanelProps) {
const [snapshot, setSnapshot] = useState<ScoreSnapshot | null>(null);
const [prevSnapshot, setPrevSnapshot] = useState<ScoreSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(true);
const lastAutoKey = useRef<string | null>(null);
const runScore = useCallback(async () => {
if (!email || email.trim().length < 20) {
setError('Email too short to score');
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch('/api/email-analyzer/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email,
previousEmail: prevSnapshot ? (prevSnapshot as unknown as { email: string }).email : undefined,
previousScores: prevSnapshot?.scores.map((s) => ({ key: s.key, score: s.score })),
}),
});
if (!res.ok) throw new Error((await res.json()).error || 'Score failed');
const data = await res.json();
const newSnap: ScoreSnapshot = {
scores: data.scores || [],
overall: data.overall ?? 0,
whatChanged: data.whatChanged || '',
improvements: data.improvements || [],
nextSteps: data.nextSteps || [],
};
// Persist snapshot so the NEXT run can diff against this one
if (autoRunKey) {
const cache = loadCache();
cache[autoRunKey] = { ...newSnap };
// Save email alongside for diffing next version
(cache[autoRunKey] as unknown as { email: string }).email = email;
saveCache(cache);
}
setSnapshot(newSnap);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, [email, prevSnapshot, autoRunKey]);
// When the active version changes, load that version's snapshot from cache
// and find the most recent *prior* snapshot to diff against.
useEffect(() => {
if (!autoRunKey) return;
const cache = loadCache();
const keys = Object.keys(cache);
const idx = keys.indexOf(autoRunKey);
// Pull cached snapshot for this version (if we've scored it before)
const existing = cache[autoRunKey] || null;
if (existing) {
setSnapshot(existing);
}
// Pick the most recently inserted other key as "previous"
const prevKey = idx > 0 ? keys[idx - 1] : keys.filter((k) => k !== autoRunKey).pop() || null;
if (prevKey && cache[prevKey]) {
setPrevSnapshot(cache[prevKey]);
} else {
setPrevSnapshot(null);
}
// Auto-run only once per autoRunKey change, and only if not already cached
if (lastAutoKey.current !== autoRunKey) {
lastAutoKey.current = autoRunKey;
if (!existing && email) {
void runScore();
}
}
}, [autoRunKey, email, runScore]);
const overall = snapshot?.overall ?? null;
const prevOverall = prevSnapshot?.overall ?? null;
const overallDelta = overall !== null && prevOverall !== null ? overall - prevOverall : null;
return (
<div className="card" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: collapsed ? 0 : 10 }}>
{/* Header — clickable collapse toggle */}
<div
role="button"
tabIndex={0}
onClick={() => setCollapsed((p) => !p)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setCollapsed((p) => !p); } }}
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
>
{collapsed ? <ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />}
<Gauge size={14} style={{ color: 'var(--color-primary)' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Version Score
</span>
{overall !== null && (
<span
style={{
fontSize: 11,
fontWeight: 700,
color: scoreColor(overall),
fontVariantNumeric: 'tabular-nums',
padding: '1px 6px',
borderRadius: 10,
background: 'var(--color-surface-el)',
}}
>
{overall}/100
</span>
)}
{overall !== null && overall >= 95 && (
<Trophy size={13} style={{ color: 'var(--color-secondary)' }} />
)}
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={(e) => { e.stopPropagation(); runScore(); }}
disabled={loading || !email}
style={{
minHeight: 24,
padding: '2px 8px',
fontSize: 11,
gap: 4,
marginLeft: 'auto',
}}
title="Re-score this email"
>
{loading ? <Loader2 size={11} className="spinner" /> : <RefreshCw size={11} />}
{snapshot ? 'Re-score' : 'Run'}
</button>
</div>
{collapsed ? null : (
<>
{/* Thermometer — completion visualization */}
{overall !== null && (
<Thermometer
score={overall}
prevScore={prevOverall}
label="Completion toward 100"
/>
)}
{/* Error */}
{error && (
<div style={{ fontSize: 11, color: 'var(--color-error)' }}>{error}</div>
)}
{/* Empty state */}
{!snapshot && !loading && !error && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
Click Run to score this version on 5 dimensions and get concrete suggestions to push toward 100.
</div>
)}
{/* Category scores with deltas */}
{snapshot && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{snapshot.scores.map((s) => {
const prev = prevSnapshot?.scores.find((p) => p.key === s.key)?.score ?? null;
const delta = prev !== null ? s.score - prev : null;
return (
<div key={s.key}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', flex: 1 }}>
{s.label}
</span>
{delta !== null && delta !== 0 && <DeltaBadge delta={delta} small />}
<span
style={{
fontSize: 11,
fontWeight: 700,
color: scoreColor(s.score),
fontVariantNumeric: 'tabular-nums',
minWidth: 22,
textAlign: 'right',
}}
>
{s.score}
</span>
</div>
<div
style={{
height: 4,
borderRadius: 2,
background: 'var(--color-surface-el)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${s.score}%`,
height: '100%',
background: scoreColor(s.score),
transition: 'width 300ms ease',
}}
/>
</div>
{s.rationale && (
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', marginTop: 2, lineHeight: 1.4 }}>
{s.rationale}
</div>
)}
</div>
);
})}
</div>
)}
{/* What changed from last version */}
{snapshot?.whatChanged && (
<div
style={{
padding: 8,
borderRadius: 'var(--radius-sm)',
background: 'rgba(79,70,229,0.08)',
border: '1px solid rgba(79,70,229,0.20)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
<Sparkles size={11} style={{ color: 'var(--color-lane-b)' }} />
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)' }}>
What's new this version
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.4 }}>
{snapshot.whatChanged}
</div>
</div>
)}
{/* What was solved */}
{snapshot && snapshot.improvements.length > 0 && (
<div
style={{
padding: 8,
borderRadius: 'var(--radius-sm)',
background: 'rgba(16,185,129,0.08)',
border: '1px solid rgba(16,185,129,0.20)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<CheckCircle2 size={11} style={{ color: 'var(--color-success)' }} />
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)' }}>
Solved vs last version
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{snapshot.improvements.map((imp) => (
<div key={imp.key} style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
<DeltaBadge delta={imp.delta} small />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text)' }}>
{imp.label}
</div>
{imp.note && (
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
{imp.note}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Next steps to push toward 100 */}
{snapshot && snapshot.nextSteps.length > 0 && overall !== null && overall < 100 && (
<div
style={{
padding: 8,
borderRadius: 'var(--radius-sm)',
background: 'rgba(245,158,11,0.08)',
border: '1px solid rgba(245,158,11,0.20)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<Target size={11} style={{ color: 'var(--color-secondary)' }} />
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)' }}>
To push toward 100 ({100 - overall} pts to go)
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{onApplySuggestion && (
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
Click any suggestion to send it to the chat and generate v{(snapshot.scores.length ? 1 : 0) + 1} next →
</div>
)}
{snapshot.nextSteps.map((step, i) => {
const clickable = !!onApplySuggestion;
return (
<button
key={i}
type="button"
onClick={() => onApplySuggestion?.(step.suggestion, step.label)}
disabled={!clickable}
style={{
padding: 8,
borderRadius: 4,
background: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'flex-start',
gap: 6,
textAlign: 'left',
cursor: clickable ? 'pointer' : 'default',
transition: 'var(--transition)',
width: '100%',
}}
onMouseEnter={(e) => {
if (clickable) {
(e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)';
(e.currentTarget as HTMLElement).style.background = 'rgba(16,185,129,0.05)';
}
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border)';
(e.currentTarget as HTMLElement).style.background = 'var(--color-surface-el)';
}}
title={clickable ? 'Click to apply this suggestion' : undefined}
>
<span
style={{
fontSize: 10,
fontWeight: 700,
color: 'var(--color-success)',
background: 'rgba(16,185,129,0.12)',
padding: '1px 6px',
borderRadius: 10,
flexShrink: 0,
whiteSpace: 'nowrap',
}}
>
+{step.expectedGain}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 600 }}>
{step.label}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text)', lineHeight: 1.4 }}>
{step.suggestion}
</div>
</div>
{clickable && (
<Sparkles size={11} style={{ color: 'var(--color-primary)', flexShrink: 0, marginTop: 2 }} />
)}
</button>
);
})}
</div>
</div>
)}
{/* 100% celebration */}
{overall === 100 && (
<div
style={{
padding: 10,
borderRadius: 'var(--radius-sm)',
background: 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(245,158,11,0.15))',
border: '1px solid var(--color-success)',
textAlign: 'center',
fontSize: 12,
color: 'var(--color-text)',
fontWeight: 600,
}}
>
<Trophy size={14} style={{ color: 'var(--color-secondary)', verticalAlign: 'middle', marginRight: 4 }} />
Maxed out! This version scored 100 across every dimension.
</div>
)}
</>
)}
</div>
);
}
/* ─── Subcomponent: delta badge ──────────────────────────────────────────── */
function DeltaBadge({ delta, small }: { delta: number; small?: boolean }) {
if (delta === 0) return null;
const up = delta > 0;
const color = up ? 'var(--color-success)' : 'var(--color-error)';
const Icon = up ? ArrowUp : ArrowDown;
const size = small ? 10 : 11;
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 2,
fontSize: size,
fontWeight: 700,
color,
background: up ? 'rgba(16,185,129,0.12)' : 'rgba(244,63,94,0.12)',
padding: '1px 5px',
borderRadius: 10,
fontVariantNumeric: 'tabular-nums',
}}
>
<Icon size={size - 1} />
{Math.abs(delta)}
</span>
);
}