← back to Norma
components/email-analyzer/VersionTimeline.tsx
666 lines
'use client';
import { useState } from 'react';
import {
Clock,
Copy,
Send,
Download,
Check,
FileText,
ChevronDown,
ChevronUp,
GitCompare,
Star,
BookmarkPlus,
ArrowDownUp,
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
/* ─── Types ──────────────────────────────────────────────────────────────── */
export interface VersionEntry {
id: string;
versionNumber: number;
content: string;
label: string;
tone: string;
wordCount: number;
paragraphCount: number;
timestamp: Date;
isFavorite?: boolean;
score?: number; // 0-100 quality score when available
instruction?: string; // rewrite instruction that produced this version
readingTimeSec?: number; // estimated reading time in seconds
}
interface VersionTimelineProps {
versions: VersionEntry[];
activeVersionId: string | null;
onSelectVersion: (version: VersionEntry) => void;
onCompare: (versionA: VersionEntry, versionB: VersionEntry) => void;
onExportCompose: (content: string) => void;
onToggleFavorite?: (versionId: string) => void;
onAddToReferences?: (version: VersionEntry, scope: 'project' | 'universal') => void;
}
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function timeAgo(date: Date): string {
const diff = Date.now() - date.getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return 'just now';
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
const TONE_COLORS: Record<string, string> = {
professional: '#4f46e5',
friendly: '#0a7c59',
urgent: '#dc2626',
empathetic: '#d97706',
formal: '#6366f1',
original: '#585e6b',
};
/* ─── Component ──────────────────────────────────────────────────────────── */
export default function VersionTimeline({
versions,
activeVersionId,
onSelectVersion,
onCompare,
onExportCompose,
onToggleFavorite,
onAddToReferences,
}: VersionTimelineProps) {
const { addToast } = useToast();
const [compareMode, setCompareMode] = useState(false);
const [compareSelections, setCompareSelections] = useState<string[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [sortByFavorites, setSortByFavorites] = useState(false);
const favCount = versions.filter((v) => v.isFavorite).length;
function handleCopy(content: string) {
const plainText = content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
navigator.clipboard.writeText(plainText).then(
() => addToast('Copied to clipboard', 'success'),
() => addToast('Failed to copy', 'error'),
);
}
function handleDownloadHtml(content: string, label: string) {
const blob = new Blob([content], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${label.replace(/[^a-z0-9]/gi, '_').slice(0, 40)}.html`;
a.click();
URL.revokeObjectURL(url);
addToast('Downloaded HTML', 'success');
}
function toggleCompareSelect(id: string) {
setCompareSelections((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= 2) return [prev[1], id];
return [...prev, id];
});
}
function runCompare() {
if (compareSelections.length !== 2) return;
const a = versions.find((v) => v.id === compareSelections[0]);
const b = versions.find((v) => v.id === compareSelections[1]);
if (a && b) {
onCompare(a, b);
setCompareMode(false);
setCompareSelections([]);
}
}
// Newest-first by default; when sort-by-favorites is on, stars float to the top
// while each group internally remains newest-first (stable sort via index).
const reversedVersions = (() => {
const base = versions.map((v, i) => ({ v, i })).reverse();
if (!sortByFavorites) return base.map((x) => x.v);
return [...base]
.sort((a, b) => {
const fa = a.v.isFavorite ? 1 : 0;
const fb = b.v.isFavorite ? 1 : 0;
if (fa !== fb) return fb - fa;
return 0; // stable within-group (reverse already applied above)
})
.map((x) => x.v);
})();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* ── Header ──────────────────────────────────────────────────────── */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
Versions ({versions.length}{favCount > 0 ? ` · ★ ${favCount}` : ''})
</div>
{onToggleFavorite && favCount > 0 && (
<button
type="button"
className="btn btn-ghost btn-sm"
style={{
fontSize: 11,
padding: '2px 8px',
minHeight: 26,
gap: 4,
color: sortByFavorites ? '#f59e0b' : 'var(--color-text-muted)',
borderColor: sortByFavorites ? '#f59e0b' : 'var(--color-border)',
background: sortByFavorites ? 'rgba(245,158,11,0.08)' : 'transparent',
}}
onClick={() => setSortByFavorites((p) => !p)}
title={sortByFavorites ? 'Clear favorites sort' : 'Sort favorites first'}
>
<ArrowDownUp size={11} />
{sortByFavorites ? 'Favs first' : 'Sort ★'}
</button>
)}
{versions.length >= 2 && (
<button
type="button"
className="btn btn-ghost btn-sm"
style={{
fontSize: 11,
padding: '2px 8px',
minHeight: 26,
gap: 4,
}}
onClick={() => {
setCompareMode((p) => !p);
setCompareSelections([]);
}}
>
<GitCompare size={12} />
{compareMode ? 'Cancel' : 'Compare'}
</button>
)}
</div>
{compareMode && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 12,
color: 'var(--color-text)',
padding: '8px 10px',
borderRadius: 8,
backgroundColor: compareSelections.length === 2 ? 'rgba(16,185,129,0.08)' : 'var(--color-surface-el)',
border: `1px solid ${compareSelections.length === 2 ? 'var(--color-primary)' : 'var(--color-border)'}`,
transition: 'var(--transition)',
}}
>
<span style={{ flex: 1, fontWeight: compareSelections.length === 2 ? 600 : 400 }}>
{compareSelections.length === 0 && 'Select 2 versions to compare'}
{compareSelections.length === 1 && 'Select 1 more version…'}
{compareSelections.length === 2 && 'Ready — click Compare →'}
</span>
<button
type="button"
className="btn btn-primary btn-sm"
style={{
fontSize: 12,
padding: '4px 14px',
minHeight: 28,
gap: 4,
opacity: compareSelections.length === 2 ? 1 : 0.4,
pointerEvents: compareSelections.length === 2 ? 'auto' : 'none',
}}
onClick={runCompare}
disabled={compareSelections.length !== 2}
>
<GitCompare size={12} />
Compare
</button>
</div>
)}
{/* ── Timeline ────────────────────────────────────────────────────── */}
{versions.length === 0 && (
<div
style={{
padding: '24px 12px',
textAlign: 'center',
color: 'var(--color-text-muted)',
fontSize: 13,
}}
>
<FileText
size={24}
style={{ color: 'var(--color-text-muted)', marginBottom: 6, opacity: 0.5 }}
/>
<p>No versions saved yet</p>
<p style={{ fontSize: 12, marginTop: 4 }}>
Paste an email and start rewriting to build your version history
</p>
</div>
)}
<div style={{ position: 'relative' }}>
{/* Connecting line */}
{reversedVersions.length > 1 && (
<div
aria-hidden="true"
style={{
position: 'absolute',
left: 11,
top: 16,
bottom: 16,
width: 2,
backgroundColor: 'var(--color-border)',
borderRadius: 1,
}}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{reversedVersions.map((version) => {
const isActive = version.id === activeVersionId;
const isCompareSelected = compareSelections.includes(version.id);
const isExpanded = expandedId === version.id;
const toneColor = TONE_COLORS[version.tone] || 'var(--color-text-muted)';
return (
<div
key={version.id}
style={{
display: 'flex',
gap: 10,
position: 'relative',
}}
>
{/* Timeline dot (or compare-selection badge) */}
<div
style={{
width: compareMode ? 28 : 22,
height: compareMode ? 28 : 22,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
marginTop: 10,
backgroundColor: isCompareSelected
? '#4f46e5'
: isActive
? 'var(--color-primary)'
: compareMode
? 'var(--color-surface-el)'
: 'var(--color-surface)',
border: `2px solid ${isCompareSelected ? '#4f46e5' : isActive ? 'var(--color-primary)' : compareMode ? 'var(--color-lane-b)' : 'var(--color-border)'}`,
borderStyle: compareMode && !isCompareSelected ? 'dashed' : 'solid',
zIndex: 1,
transition: 'all 0.15s',
}}
title={compareMode ? (isCompareSelected ? 'Selected — click to deselect' : 'Click to select for compare') : ''}
>
{compareMode && isCompareSelected ? (
<Check size={14} style={{ color: '#fff' }} />
) : isActive && !compareMode ? (
<Check size={10} style={{ color: '#fff' }} />
) : (
<span
style={{
fontSize: compareMode ? 11 : 9,
fontWeight: 700,
color: compareMode ? 'var(--color-lane-b)' : 'var(--color-text-muted)',
}}
>
{version.versionNumber}
</span>
)}
</div>
{/* Card */}
<div
role="button"
tabIndex={0}
onClick={() => {
if (compareMode) {
toggleCompareSelect(version.id);
} else {
onSelectVersion(version);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (compareMode) {
toggleCompareSelect(version.id);
} else {
onSelectVersion(version);
}
}
}}
style={{
flex: 1,
padding: '8px 10px',
borderRadius: 8,
cursor: 'pointer',
backgroundColor: isCompareSelected
? 'rgba(79,70,229,0.12)'
: isActive && !compareMode
? 'var(--color-primary-dim)'
: compareMode
? 'var(--color-surface-el)'
: 'var(--color-surface)',
border: `${isCompareSelected ? 2 : 1}px solid ${isCompareSelected ? '#4f46e5' : isActive && !compareMode ? 'rgba(10,124,89,0.3)' : compareMode ? 'var(--color-lane-b)' : 'var(--color-border)'}`,
borderStyle: compareMode && !isCompareSelected ? 'dashed' : 'solid',
transition: 'all 0.15s',
position: 'relative',
}}
>
{/* Top row: label + time */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 6,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
v{version.versionNumber}: {version.label}
</span>
{compareMode && isCompareSelected && (
<span
style={{
fontSize: 10,
fontWeight: 700,
padding: '2px 6px',
borderRadius: 999,
background: '#4f46e5',
color: '#fff',
whiteSpace: 'nowrap',
}}
>
#{compareSelections.indexOf(version.id) + 1}
</span>
)}
<span
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 3,
}}
>
<Clock size={10} />
{timeAgo(version.timestamp)}
</span>
</div>
{/* Bottom row: tone badge + stats */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginTop: 4,
}}
>
<span
className="badge"
style={{
fontSize: 10,
padding: '1px 6px',
backgroundColor: `${toneColor}12`,
color: toneColor,
border: `1px solid ${toneColor}30`,
textTransform: 'capitalize',
}}
>
{version.tone}
</span>
<span
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
}}
>
{version.wordCount}w
</span>
<span
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
}}
>
{version.paragraphCount}p
</span>
{typeof version.readingTimeSec === 'number' && version.readingTimeSec > 0 && (
<span
style={{ fontSize: 11, color: 'var(--color-text-muted)' }}
title={`~${version.readingTimeSec}s read`}
>
{version.readingTimeSec < 60
? `${version.readingTimeSec}s`
: `${Math.round(version.readingTimeSec / 60)}m`}
</span>
)}
{typeof version.score === 'number' && (
<span
className="badge"
style={{
fontSize: 10,
padding: '1px 6px',
backgroundColor:
version.score >= 80
? 'rgba(16,185,129,0.12)'
: version.score >= 60
? 'rgba(245,158,11,0.12)'
: 'rgba(244,63,94,0.12)',
color:
version.score >= 80
? '#10b981'
: version.score >= 60
? '#f59e0b'
: '#f43f5e',
border: '1px solid transparent',
}}
title="Quality score"
>
{version.score}
</span>
)}
{/* Favorite toggle (only when handler provided — orig-* rows skip it) */}
{onToggleFavorite && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(version.id);
}}
style={{
marginLeft: 'auto',
background: 'none',
border: 'none',
cursor: 'pointer',
color: version.isFavorite ? '#f59e0b' : 'var(--color-text-muted)',
padding: 2,
display: 'flex',
}}
title={version.isFavorite ? 'Remove from favorites' : 'Mark as favorite'}
aria-pressed={!!version.isFavorite}
>
<Star
size={13}
fill={version.isFavorite ? '#f59e0b' : 'none'}
strokeWidth={version.isFavorite ? 2 : 1.8}
/>
</button>
)}
{/* Expand/collapse */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpandedId(isExpanded ? null : version.id);
}}
style={{
marginLeft: onToggleFavorite ? 0 : 'auto',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text-muted)',
padding: 2,
display: 'flex',
}}
title={isExpanded ? 'Collapse' : 'Expand actions'}
>
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
</div>
{/* Expanded actions */}
{isExpanded && (
<div
style={{
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
{version.instruction && (
<div
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
fontStyle: 'italic',
lineHeight: 1.3,
}}
title="Rewrite instruction"
>
“{version.instruction}”
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 11, padding: '2px 8px', minHeight: 26, gap: 3 }}
onClick={(e) => {
e.stopPropagation();
handleCopy(version.content);
}}
>
<Copy size={11} /> Copy
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 11, padding: '2px 8px', minHeight: 26, gap: 3 }}
onClick={(e) => {
e.stopPropagation();
onExportCompose(version.content);
}}
>
<Send size={11} /> Compose
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 11, padding: '2px 8px', minHeight: 26, gap: 3 }}
onClick={(e) => {
e.stopPropagation();
handleDownloadHtml(version.content, version.label);
}}
>
<Download size={11} /> HTML
</button>
{onAddToReferences && (
<>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{
fontSize: 11,
padding: '2px 8px',
minHeight: 26,
gap: 3,
color: 'var(--color-primary)',
}}
onClick={(e) => {
e.stopPropagation();
onAddToReferences(version, 'project');
}}
title="Save as reference for THIS analysis only"
>
<BookmarkPlus size={11} /> Ref: project
</button>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{
fontSize: 11,
padding: '2px 8px',
minHeight: 26,
gap: 3,
color: 'var(--color-lane-b)',
}}
onClick={(e) => {
e.stopPropagation();
onAddToReferences(version, 'universal');
}}
title="Save to Universal References (available on every analysis)"
>
<BookmarkPlus size={11} /> Ref: universal
</button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}