← 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>
  );
}