clio/clio-ui/components/DayView.jsx
Your Name 528813d4a0
All checks were successful
Deploy UI / deploy (push) Successful in 1s
fixing bugs
2026-05-19 20:34:00 -04:00

337 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// DayView: calendar month grid + day timeline with audio density visualization
const { useState: useStateDV, useEffect: useEffectDV, useRef: useRefDV } = React;
function DayView({ data, selectedDay, setSelectedDay, setSelectedRecording, setView }) {
const now = new Date();
const [viewMonth, setViewMonth] = useStateDV(() => {
if (selectedDay) {
const d = new Date(selectedDay);
return { year: d.getFullYear(), month: d.getMonth() };
}
return { year: now.getFullYear(), month: now.getMonth() };
});
// Build day map
const dayMap = {};
for (const r of data.recordings) {
const key = r.startTime.toISOString().slice(0, 10);
if (!dayMap[key]) dayMap[key] = [];
dayMap[key].push(r);
}
const maxCount = Math.max(...Object.values(dayMap).map(d => d.length), 1);
// Calendar grid
const { year, month } = viewMonth;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDow = firstDay.getDay();
const daysInMonth = lastDay.getDate();
const prevMonth = () => {
setViewMonth(m => {
const d = new Date(m.year, m.month - 1, 1);
return { year: d.getFullYear(), month: d.getMonth() };
});
};
const nextMonth = () => {
setViewMonth(m => {
const d = new Date(m.year, m.month + 1, 1);
return { year: d.getFullYear(), month: d.getMonth() };
});
};
const monthLabel = firstDay.toLocaleString('default', { month: 'long', year: 'numeric' });
const dayRecs = selectedDay ? (dayMap[selectedDay] || []).sort((a, b) => a.startTime - b.startTime) : [];
function fmtDay(dateStr) {
const d = new Date(dateStr + 'T12:00:00');
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
}
return (
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left: Month calendar */}
<div style={{
width: 300, flexShrink: 0, borderRight: '1px solid var(--border)',
background: 'var(--bg2)', display: 'flex', flexDirection: 'column',
padding: '20px 16px', gap: 16, overflow: 'auto',
}}>
{/* Month nav */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button onClick={prevMonth} style={{ color: 'var(--text2)', fontSize: 16, padding: '4px 8px', borderRadius: 4 }}></button>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text)', fontWeight: 500 }}>{monthLabel}</div>
<button onClick={nextMonth} style={{ color: 'var(--text2)', fontSize: 16, padding: '4px 8px', borderRadius: 4 }}></button>
</div>
{/* Weekday headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{['S','M','T','W','T','F','S'].map((d, i) => (
<div key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textAlign: 'center', paddingBottom: 4 }}>{d}</div>
))}
{/* Empty cells */}
{Array.from({ length: startDow }).map((_, i) => <div key={`e${i}`} />)}
{/* Day cells */}
{Array.from({ length: daysInMonth }).map((_, i) => {
const d = i + 1;
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const recs = dayMap[dateStr] || [];
const isSelected = selectedDay === dateStr;
const isToday = dateStr === now.toISOString().slice(0, 10);
const hasRecs = recs.length > 0;
const intensity = hasRecs ? recs.length / maxCount : 0;
return (
<div
key={d}
onClick={() => hasRecs && setSelectedDay(dateStr)}
style={{
position: 'relative',
aspectRatio: '1',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
borderRadius: 4,
cursor: hasRecs ? 'pointer' : 'default',
background: isSelected ? 'var(--amber)' : isToday ? 'var(--bg4)' : 'transparent',
outline: isToday && !isSelected ? '1px solid var(--border)' : 'none',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (hasRecs && !isSelected) e.currentTarget.style.background = 'var(--bg4)'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isToday ? 'var(--bg4)' : 'transparent'; }}
>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
color: isSelected ? 'var(--bg)' : isToday ? 'var(--amber)' : hasRecs ? 'var(--text)' : 'var(--text3)',
fontWeight: isSelected || isToday ? 600 : 400,
lineHeight: 1,
}}>{d}</span>
{hasRecs && (
<div style={{
position: 'absolute', bottom: 3,
display: 'flex', gap: 1, justifyContent: 'center',
}}>
{recs.slice(0, 3).map((_, ri) => (
<div key={ri} style={{
width: 3, height: 3, borderRadius: '50%',
background: isSelected ? 'var(--bg)' : 'var(--amber)',
opacity: 0.8,
}} />
))}
</div>
)}
</div>
);
})}
</div>
{/* Day summary */}
{selectedDay && dayRecs.length > 0 && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
{dayRecs.length} recordings · {Math.round(dayRecs.reduce((a, r) => a + r.durationMinutes, 0) / 60)}h total
</div>
{['portable','stationary','phone'].map(src => {
const count = dayRecs.filter(r => r.source === src).length;
if (!count) return null;
return (
<div key={src} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<span className={`source-pill source-${src}`}>{src}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{count}</span>
</div>
);
})}
</div>
)}
</div>
{/* Right: Day timeline */}
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
{!selectedDay ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 13 }}>
Select a day to view recordings
</div>
) : (
<>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)', marginBottom: 6 }}>
{fmtDay(selectedDay)}
</div>
{dayRecs.length === 0 ? (
<div style={{ color: 'var(--text3)', fontFamily: 'var(--font-mono)', fontSize: 12, marginTop: 20 }}>No recordings this day.</div>
) : (
<DayTimeline recordings={dayRecs} onSelect={r => setSelectedRecording(r)} />
)}
</>
)}
</div>
</div>
);
}
function DayTimeline({ recordings, onSelect }) {
// 24-hour timeline with recording bars
const HOUR_H = 52;
const LABEL_W = 44;
const BAR_W = 260;
const hours = Array.from({ length: 24 }, (_, i) => i);
function recTop(r) {
const h = r.startTime.getHours() + r.startTime.getMinutes() / 60;
return h * HOUR_H;
}
function recHeight(r) {
return Math.max((r.durationMinutes / 60) * HOUR_H, 8);
}
function qualityColor(q) {
const map = { excellent: 'var(--green)', good: 'var(--teal)', fair: 'var(--amber)', poor: 'var(--red)' };
return map[q] || 'var(--text3)';
}
const totalH = 24 * HOUR_H;
// Density mini-bars per hour (minutes of audio per hour)
const densityPerHour = Array(24).fill(0);
for (const r of recordings) {
const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
const endH = startH + r.durationMinutes / 60;
for (let h = 0; h < 24; h++) {
const overlapStart = Math.max(h, startH);
const overlapEnd = Math.min(h + 1, endH);
if (overlapEnd > overlapStart) {
densityPerHour[h] += (overlapEnd - overlapStart) * 60;
}
}
}
return (
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', marginTop: 12 }}>
{/* Timeline */}
<div style={{ position: 'relative', height: totalH, width: LABEL_W + BAR_W + 32, flexShrink: 0 }}>
{/* Hour lines + labels */}
{hours.map(h => (
<div key={h} style={{ position: 'absolute', top: h * HOUR_H, left: 0, right: 0, display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ width: LABEL_W, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textAlign: 'right', flexShrink: 0, paddingTop: 1 }}>
{String(h).padStart(2, '0')}:00
</div>
<div style={{ flex: 1, height: 1, background: 'var(--border)', marginTop: 6 }} />
</div>
))}
{/* Recording bars */}
{recordings.map(r => {
const top = recTop(r);
const height = recHeight(r);
const srcColors = { portable: 'var(--amber)', stationary: 'var(--teal)', phone: 'var(--green)' };
const srcColor = srcColors[r.source] || 'var(--text3)';
return (
<div
key={r.id}
onClick={() => onSelect(r)}
title={`${r.source} · ${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m · ${r.quality}`}
style={{
position: 'absolute',
top: top + 1,
left: LABEL_W + 12,
width: BAR_W,
height: height - 2,
background: `${srcColor}22`,
border: `1px solid ${srcColor}66`,
borderLeft: `3px solid ${srcColor}`,
borderRadius: '0 4px 4px 0',
cursor: 'pointer',
display: 'flex', alignItems: 'flex-start', gap: 8, padding: '4px 8px',
overflow: 'hidden',
transition: 'background 0.1s',
zIndex: 1,
}}
onMouseEnter={e => { e.currentTarget.style.background = `${srcColor}40`; }}
onMouseLeave={e => { e.currentTarget.style.background = `${srcColor}22`; }}
>
<span className={`source-pill source-${r.source}`} style={{ fontSize: 9, padding: '1px 5px', flexShrink: 0 }}>{r.source}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text2)', flexShrink: 0 }}>
{Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }} className={`q-${r.quality}`}>{r.quality}</span>
{r.highlights.length > 0 && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}> {r.highlights.length}</span>
)}
</div>
);
})}
</div>
{/* Density strip - clickable to go to recording at that hour */}
<div style={{ flexShrink: 0, width: 40 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4, writingMode: 'horizontal-tb' }}>density</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{densityPerHour.map((mins, h) => {
const pct = mins / 60;
// Find recording that covers this hour
const recAtHour = recordings.find(r => {
const startH = r.startTime.getHours() + r.startTime.getMinutes() / 60;
const endH = startH + r.durationMinutes / 60;
return h >= startH && h < endH;
});
return (
<div
key={h}
onClick={() => recAtHour && onSelect(recAtHour)}
style={{
height: HOUR_H,
display: 'flex',
alignItems: 'stretch',
cursor: recAtHour ? 'pointer' : 'default',
}}
>
<div style={{
width: Math.round(pct * 36) + 'px',
minWidth: pct > 0 ? 2 : 0,
background: pct > 0.8 ? 'var(--amber)' : pct > 0.3 ? 'var(--amber-dim)' : 'var(--bg4)',
transition: 'width 0.2s, opacity 0.1s',
opacity: recAtHour ? 1 : 0.5,
}} />
</div>
);
})}
</div>
</div>
{/* Recording list for quick jump */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 4 }}>Jump to</div>
{recordings.map(r => {
const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return (
<div
key={r.id}
onClick={() => onSelect(r)}
style={{
background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 4,
padding: '8px 12px', cursor: 'pointer', transition: 'border-color 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--amber)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>{timeStr}</span>
<span className={`source-pill source-${r.source}`}>{r.source}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)' }}>{Math.floor(r.durationMinutes / 60)}h {Math.round(r.durationMinutes % 60)}m</span>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<span className={`q-${r.quality}`} style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }}>{r.quality}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>{r.sampleRate / 1000}kHz · {r.bitDepth}bit · {r.fileSizeMB}MB</span>
{r.hasTranscript && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}> transcript</span>}
{r.highlights.length > 0 && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)' }}> {r.highlights.length}</span>}
</div>
</div>
);
})}
</div>
</div>
);
}
Object.assign(window, { DayView, DayTimeline });