import React, { useState, useEffect, useMemo } from 'react'; // ── Colors ───────────────────────────────────────────────────────────────────── const BASE_COLORS = { Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500', badge: 'bg-green-500' }, Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500', badge: 'bg-red-500' }, Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400', badge: 'bg-gray-400' }, }; const EXTRA_COLORS = [ { bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500', badge: 'bg-indigo-500' }, { bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500', badge: 'bg-yellow-500' }, { bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500', badge: 'bg-purple-500' }, { bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500', badge: 'bg-pink-500' }, ]; function getColors(cat, customCats = []) { if (BASE_COLORS[cat]) return BASE_COLORS[cat]; return EXTRA_COLORS[Math.max(0, customCats.indexOf(cat)) % EXTRA_COLORS.length]; } const ORDINALS = ['1st', '2nd', '3rd', '4th', '5th']; // ── Helpers ──────────────────────────────────────────────────────────────────── /** Build display items from a sequence slice, respecting mode + collapsibility. */ function buildItems(seqSlice, mode, collapsible) { const categorised = seqSlice.filter(s => s.category); if (mode === 'discrete') { return categorised.map((s, i) => ({ id: i, category: s.category, count: 1 })); } // Rebuild runs within this slice, then apply collapsibility const runs = []; for (const s of categorised) { if (runs.length === 0 || runs[runs.length - 1].category !== s.category) { runs.push({ category: s.category, length: 1 }); } else { runs[runs.length - 1].length++; } } const items = []; for (const run of runs) { if (collapsible[run.category] === false) { for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 }); } else { items.push({ id: items.length, category: run.category, count: run.length }); } } return items; } /** Compute per-category counts and total for a sequence slice. */ function segStats(seqSlice) { const byCat = {}; for (const s of seqSlice) { if (!s.category) continue; byCat[s.category] = (byCat[s.category] || 0) + 1; } const total = Object.values(byCat).reduce((a, b) => a + b, 0); return { byCat, total }; } // ── Component ────────────────────────────────────────────────────────────────── /** * Props: * runs {category, length}[] — global run-length-encoded sequence * sequence {note, category, timestamp}[] * customCategories string[] */ export default function RunSequenceView({ runs = [], sequence = [], customCategories = [], sessionEndTs = null }) { const [mode, setMode] = useState('grouped'); const [collapsible, setCollapsible] = useState({}); const [splitN, setSplitN] = useState(1); const [durationMins, setDurationMins] = useState(20); const [selectedSegment, setSelectedSegment] = useState(null); // Initialise collapsibility for any new categories const allCats = useMemo(() => [...new Set(runs.map(r => r.category))], [runs]); useEffect(() => { setCollapsible(prev => { const next = { ...prev }; for (const cat of allCats) { if (!(cat in next)) next[cat] = cat !== 'Success'; } return next; }); }, [allCats]); // Reset selected segment when split changes useEffect(() => { setSelectedSegment(null); }, [splitN]); // ── Timestamp bounds ───────────────────────────────────────────────────────── const { startTs, segSize, windowUnderrun } = useMemo(() => { let endT = sessionEndTs != null ? sessionEndTs : null; if (endT === null) { const ts = sequence.map(s => parseFloat(s.timestamp)).filter(t => !isNaN(t)); endT = ts.length ? Math.max(...ts) : durationMins * 60; } const rawStart = endT - durationMins * 60; return { startTs: rawStart, segSize: (durationMins * 60) / Math.max(splitN, 1), windowUnderrun: rawStart < 0 ? Math.abs(rawStart) : 0, }; }, [sequence, sessionEndTs, durationMins, splitN]); function segIndexOf(timestamp) { const t = parseFloat(timestamp); if (isNaN(t)) return 0; return Math.min(Math.max(Math.floor((t - startTs) / segSize), 0), splitN - 1); } // ── Segment data (only when split > 1) ─────────────────────────────────────── const segmentData = useMemo(() => { if (splitN === 1) return null; const groups = Array.from({ length: splitN }, () => []); for (const s of sequence) { groups[segIndexOf(s.timestamp)].push(s); } return groups.map((grp, i) => ({ index: i, items: buildItems(grp, mode, collapsible), stats: segStats(grp), })); }, [splitN, sequence, mode, collapsible, startTs, segSize]); // ── Single-segment display items (split = 1) ───────────────────────────────── const singleItems = useMemo(() => { if (splitN !== 1) return []; if (mode === 'discrete') { const src = sequence.length > 0 ? sequence.filter(s => s.category) : runs.flatMap(r => Array.from({ length: r.length }, () => ({ category: r.category }))); return src.map((s, i) => ({ id: i, category: s.category, count: 1 })); } const items = []; for (const run of runs) { if (collapsible[run.category] === false) { for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 }); } else { items.push({ id: items.length, category: run.category, count: run.length }); } } return items; }, [splitN, mode, runs, sequence, collapsible]); // ── Global totals ───────────────────────────────────────────────────────────── const globalStats = useMemo(() => segStats(sequence), [sequence]); if (runs.length === 0) return null; const hasTimestamps = sequence.some(s => !isNaN(parseFloat(s.timestamp))); const activeSegStats = selectedSegment !== null && segmentData ? segmentData[selectedSegment]?.stats ?? null : null; // ── Render ──────────────────────────────────────────────────────────────────── return (
{/* ── Controls row ── */}
{/* Title */}

Run sequence ({runs.length} runs)

{/* Mode toggle */}
{/* Time split */} {hasTimestamps && (
Split: setDurationMins(Math.max(1, Number(e.target.value)))} className="border border-gray-200 rounded px-1.5 py-0.5 w-14 text-xs text-right focus:outline-none focus:ring-1 focus:ring-indigo-400" title="Experiment duration in minutes" /> min
)}
{/* ── Per-category collapse toggles (grouped mode) ── */} {mode === 'grouped' && allCats.length > 0 && (
{allCats.map(cat => { const c = getColors(cat, customCategories); const isGrouped = collapsible[cat] !== false; return ( ); })}
)} {/* ── Underrun warning ── */} {splitN > 1 && windowUnderrun > 0 && (() => { const u = Math.round(windowUnderrun); const m = Math.floor(u / 60), s = u % 60; return (

The {durationMins}-min window starts {m}:{String(s).padStart(2,'0')} before the recording — the 1st segment includes unrecorded time.

); })()} {/* ── Segment buttons (when split > 1) ── */} {splitN > 1 && segmentData && (
{segmentData.map((seg, si) => { const isActive = selectedSegment === si; const segStart = startTs + si * segSize; const segEnd = startTs + (si + 1) * segSize; const fmt = s => { const t = Math.round(s); const clamped = Math.max(0, t); const m = Math.floor(clamped / 60); const sec = clamped % 60; const prefix = t < 0 ? '0:00*' : `${m}:${String(sec).padStart(2, '0')}`; return prefix; }; return ( ); })} {selectedSegment !== null && ( )}
)} {/* ── Selected segment stats ── */} {activeSegStats && (
{ORDINALS[selectedSegment]} segment {activeSegStats.total} attempt{activeSegStats.total !== 1 ? 's' : ''} {Object.entries(activeSegStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => { const c = getColors(cat, customCategories); return ( {cat}: {n} ); })}
)} {/* ── Chip area ── */}
{splitN === 1 ? singleItems.map(item => ) : segmentData && segmentData.map((seg, si) => ( {si > 0 && (
)} {seg.items.length === 0 ? empty : seg.items.map(item => ( ))} )) }
{/* ── Global totals ── */}
Total: {globalStats.total} attempt{globalStats.total !== 1 ? 's' : ''} {Object.entries(globalStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => { const c = getColors(cat, customCategories); return ( {cat}: {n} ); })}
); } function Chip({ item, customCategories, dimmed }) { const c = getColors(item.category, customCategories); return ( 1 ? `${item.category} × ${item.count}` : item.category} className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border transition-opacity ${c.bg} ${c.text} ${c.border} ${dimmed ? 'opacity-25' : 'opacity-100'}`}> {item.count > 1 ? `${item.count}×` : ''}{item.category.slice(0, 3)} ); }