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 (
Run sequence ({runs.length} runs)
{/* Mode toggle */}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 && (