213 lines
9.5 KiB
JavaScript
213 lines
9.5 KiB
JavaScript
// Dashboard view: stats, calendar heatmap, recent recordings
|
|
function Dashboard({ data, setView, setSelectedDay, setSelectedRecording }) {
|
|
const now = new Date();
|
|
|
|
// Aggregate stats
|
|
const totalHours = data.recordings.reduce((a, r) => a + r.durationMinutes / 60, 0);
|
|
const totalGB = data.recordings.reduce((a, r) => a + r.fileSizeMB, 0) / 1024;
|
|
const transcribedCount = data.recordings.filter(r => r.hasTranscript).length;
|
|
const highlightCount = data.highlights.length;
|
|
|
|
// By source
|
|
const bySrc = {};
|
|
for (const r of data.recordings) {
|
|
bySrc[r.source] = (bySrc[r.source] || 0) + 1;
|
|
}
|
|
|
|
// Calendar heatmap: last 8 months
|
|
const calStart = new Date(now);
|
|
calStart.setMonth(calStart.getMonth() - 7);
|
|
calStart.setDate(1);
|
|
|
|
// Build day map
|
|
const dayMap = {};
|
|
for (const r of data.recordings) {
|
|
const key = window.toLocalDateString(r.startTime);
|
|
if (!dayMap[key]) dayMap[key] = { count: 0, minutes: 0 };
|
|
dayMap[key].count++;
|
|
dayMap[key].minutes += r.durationMinutes;
|
|
}
|
|
|
|
const maxMinutes = Math.max(...Object.values(dayMap).map(d => d.minutes), 1);
|
|
|
|
// Recent recordings
|
|
const recent = [...data.recordings]
|
|
.sort((a, b) => b.startTime - a.startTime)
|
|
.slice(0, 8);
|
|
|
|
function handleDayClick(dateStr) {
|
|
setSelectedDay(dateStr);
|
|
setView('calendar');
|
|
}
|
|
|
|
function fmtDuration(min) {
|
|
const h = Math.floor(min / 60);
|
|
const m = min % 60;
|
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
}
|
|
|
|
function qualityClass(q) {
|
|
return `q-${q}`;
|
|
}
|
|
|
|
return (
|
|
<div style={{ flex: 1, overflow: 'auto', padding: '24px 28px', display: 'flex', flexDirection: 'column', gap: 24 }}>
|
|
|
|
{/* Stat cards */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
|
|
{[
|
|
{ label: 'Recordings', value: data.recordings.length.toLocaleString(), sub: 'total files' },
|
|
{ label: 'Total Duration', value: `${Math.round(totalHours)}h`, sub: `${Math.round(totalHours / 24)} days of audio` },
|
|
{ label: 'Storage Used', value: `${totalGB.toFixed(1)} GB`, sub: `avg ${(totalGB / data.recordings.length * 1024).toFixed(0)} MB/file` },
|
|
{ label: 'Highlights', value: highlightCount, sub: `${transcribedCount} transcribed` },
|
|
].map(card => (
|
|
<div key={card.label} style={{
|
|
background: 'var(--bg2)', border: '1px solid var(--border)',
|
|
borderRadius: 6, padding: '16px 18px',
|
|
}}>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-mono)', marginBottom: 6 }}>{card.label}</div>
|
|
<div style={{ fontSize: 28, fontFamily: 'var(--font-mono)', fontWeight: 500, color: 'var(--amber)', lineHeight: 1 }}>{card.value}</div>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 5, fontFamily: 'var(--font-mono)' }}>{card.sub}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Source breakdown */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
|
{Object.entries(bySrc).map(([src, count]) => {
|
|
const srcHours = data.recordings.filter(r => r.source === src).reduce((a, r) => a + r.durationMinutes / 60, 0);
|
|
return (
|
|
<div key={src} style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
<div>
|
|
<span className={`source-pill source-${src}`}>{src}</span>
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 18, color: 'var(--text)', fontWeight: 500 }}>{count} <span style={{ fontSize: 12, color: 'var(--text3)' }}>files</span></div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{Math.round(srcHours)}h recorded</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Calendar heatmap */}
|
|
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, padding: '18px 20px' }}>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-mono)', marginBottom: 14 }}>Activity — Last 8 Months</div>
|
|
<CalendarHeatmap dayMap={dayMap} maxMinutes={maxMinutes} onDayClick={handleDayClick} />
|
|
</div>
|
|
|
|
{/* Recent recordings */}
|
|
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 6, padding: '18px 20px' }}>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-mono)', marginBottom: 14 }}>Recent Recordings</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
{recent.map(r => (
|
|
<RecordingRow key={r.id} recording={r} onClick={() => setSelectedRecording(r)} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CalendarHeatmap({ dayMap, maxMinutes, onDayClick }) {
|
|
const now = new Date();
|
|
const months = [];
|
|
|
|
for (let m = 7; m >= 0; m--) {
|
|
const d = new Date(now.getFullYear(), now.getMonth() - m, 1);
|
|
months.push({ year: d.getFullYear(), month: d.getMonth(), label: d.toLocaleString('default', { month: 'short' }) + ' ' + d.getFullYear() });
|
|
}
|
|
|
|
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
|
|
function intensityColor(minutes) {
|
|
if (!minutes) return 'var(--bg4)';
|
|
const t = Math.min(minutes / maxMinutes, 1);
|
|
if (t < 0.2) return 'oklch(0.35 0.08 65)';
|
|
if (t < 0.5) return 'oklch(0.50 0.11 65)';
|
|
if (t < 0.8) return 'oklch(0.65 0.13 65)';
|
|
return 'oklch(0.75 0.15 65)';
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', gap: 16, overflowX: 'auto', paddingBottom: 4 }}>
|
|
{months.map(({ year, month, label }) => {
|
|
const firstDay = new Date(year, month, 1);
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const startDow = firstDay.getDay(); // 0=Sun
|
|
const daysInMonth = lastDay.getDate();
|
|
|
|
const cells = [];
|
|
// Empty cells for offset
|
|
for (let i = 0; i < startDow; i++) {
|
|
cells.push(<div key={`empty-${i}`} style={{ width: 14, height: 14 }} />);
|
|
}
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
const info = dayMap[dateStr];
|
|
const isToday = dateStr === window.toLocalDateString(new Date());
|
|
cells.push(
|
|
<div
|
|
key={d}
|
|
onClick={() => info && onDayClick(dateStr)}
|
|
title={info ? `${dateStr}: ${info.count} recordings, ${Math.round(info.minutes / 60)}h` : dateStr}
|
|
style={{
|
|
width: 14, height: 14, borderRadius: 2,
|
|
background: intensityColor(info?.minutes),
|
|
cursor: info ? 'pointer' : 'default',
|
|
outline: isToday ? '2px solid var(--amber)' : 'none',
|
|
outlineOffset: 1,
|
|
transition: 'transform 0.1s',
|
|
}}
|
|
onMouseEnter={e => { if (info) e.target.style.transform = 'scale(1.3)'; }}
|
|
onMouseLeave={e => { e.target.style.transform = 'scale(1)'; }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={label} style={{ flexShrink: 0 }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', marginBottom: 6, whiteSpace: 'nowrap' }}>{label}</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 14px)', gap: 3 }}>{cells}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RecordingRow({ recording, onClick }) {
|
|
const r = recording;
|
|
const timeStr = r.startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
const dateStr = r.startTime.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
const dur = `${Math.floor(r.durationMinutes / 60)}h ${Math.round(r.durationMinutes % 60)}m`;
|
|
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 12,
|
|
padding: '9px 12px', borderRadius: 4, cursor: 'pointer',
|
|
transition: 'background 0.1s',
|
|
}}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg3)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', width: 80, flexShrink: 0 }}>{dateStr} {timeStr}</div>
|
|
<span className={`source-pill source-${r.source}`}>{r.source}</span>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text2)', width: 60, flexShrink: 0 }}>{dur}</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, flexShrink: 0 }} className={`q-${r.quality}`}>{r.quality}</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)', flexShrink: 0 }}>{r.fileSizeMB} MB</div>
|
|
{r.highlights.length > 0 && (
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', padding: '1px 6px', borderRadius: 3 }}>
|
|
{r.highlights.length} ◈
|
|
</div>
|
|
)}
|
|
{r.hasTranscript && (
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text3)' }}>✦ transcript</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
Object.assign(window, { Dashboard, CalendarHeatmap, RecordingRow });
|