Kexin-251202's picture
update src/ tutorial & data management
ad1b410 verified
import React, { useState, useEffect, useRef } from 'react';
function Records() {
const [filter, setFilter] = useState('all');
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(false);
const [detailState, setDetailState] = useState({
open: false,
loading: false,
error: '',
session: null
});
const chartRef = useRef(null);
const fileInputRef = useRef(null);
const formatDuration = (seconds) => {
const safeSeconds = Math.max(0, Number(seconds) || 0);
const mins = Math.floor(safeSeconds / 60);
const secs = safeSeconds % 60;
return `${mins}m ${secs}s`;
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatDateTime = (dateString) => {
if (!dateString) return 'Not available';
const date = new Date(dateString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const parseMetadata = (detectionData) => {
if (!detectionData) return {};
if (typeof detectionData === 'object') return detectionData;
try {
return JSON.parse(detectionData);
} catch (_) {
return {};
}
};
const averageOf = (values) => {
const valid = values.filter((value) => Number.isFinite(value));
if (valid.length === 0) return null;
return valid.reduce((sum, value) => sum + value, 0) / valid.length;
};
const buildTimelineSegments = (events, maxSegments = 48) => {
if (!events.length) return [];
const segmentSize = Math.ceil(events.length / maxSegments);
const segments = [];
for (let i = 0; i < events.length; i += segmentSize) {
const slice = events.slice(i, i + segmentSize);
const focusedCount = slice.filter((event) => event.isFocused).length;
const focusRatio = focusedCount / slice.length;
const confidence = averageOf(slice.map((event) => event.confidence));
let tone = 'distracted';
if (focusRatio >= 0.75) tone = 'focused';
else if (focusRatio >= 0.35) tone = 'mixed';
segments.push({
tone,
focusRatio,
confidence,
count: slice.length
});
}
return segments;
};
const buildDetailView = (session) => {
if (!session) return null;
const parsedEvents = (session.events || []).map((event) => {
const metadata = parseMetadata(event.detection_data);
return {
...event,
metadata,
isFocused: Boolean(event.is_focused),
confidence: Number(event.confidence) || 0
};
});
const focusRatio = session.total_frames
? session.focused_frames / session.total_frames
: parsedEvents.length
? parsedEvents.filter((event) => event.isFocused).length / parsedEvents.length
: 0;
const modelCounts = parsedEvents.reduce((counts, event) => {
const model = event.metadata?.model;
if (model) counts[model] = (counts[model] || 0) + 1;
return counts;
}, {});
const dominantModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'Unavailable';
const avgConfidence = averageOf(parsedEvents.map((event) => event.confidence));
const avgFaceScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_face)));
const avgEyeScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_eye)));
const avgMar = averageOf(parsedEvents.map((event) => Number(event.metadata?.mar)));
const startTime = session.start_time ? new Date(session.start_time) : null;
const timeline = buildTimelineSegments(parsedEvents);
const recentEvents = parsedEvents.slice(-10).reverse();
return {
parsedEvents,
focusRatio,
dominantModel,
avgConfidence,
avgFaceScore,
avgEyeScore,
avgMar,
timeline,
recentEvents,
formatOffset(timestamp) {
if (!startTime || !timestamp) return '--';
const offsetSeconds = Math.max(0, Math.round((new Date(timestamp) - startTime) / 1000));
const mins = Math.floor(offsetSeconds / 60);
const secs = offsetSeconds % 60;
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
}
};
};
const getScoreTone = (score) => {
if (score >= 0.8) return 'excellent';
if (score >= 0.6) return 'good';
if (score >= 0.4) return 'fair';
return 'low';
};
const closeDetails = () => {
setDetailState({
open: false,
loading: false,
error: '',
session: null
});
};
const loadSessions = async (filterType) => {
setLoading(true);
try {
const response = await fetch(`/api/sessions?filter=${filterType}&limit=50`);
const data = await response.json();
setSessions(data);
drawChart(data);
} catch (error) {
console.error('Failed to load sessions:', error);
} finally {
setLoading(false);
}
};
const drawChart = (data) => {
const canvas = chartRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.width = canvas.offsetWidth;
const height = canvas.height = 300;
ctx.clearRect(0, 0, width, height);
if (data.length === 0) {
ctx.fillStyle = '#999';
ctx.font = '16px Nunito';
ctx.textAlign = 'center';
ctx.fillText('No data available', width / 2, height / 2);
return;
}
const displayData = data.slice(0, 20).reverse();
const padding = 50;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const barWidth = chartWidth / displayData.length;
const maxScore = 1.0;
ctx.strokeStyle = '#E0E0E0';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(padding, padding);
ctx.lineTo(padding, height - padding);
ctx.lineTo(width - padding, height - padding);
ctx.stroke();
ctx.fillStyle = '#666';
ctx.font = '12px Nunito';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const y = height - padding - (chartHeight * i / 4);
const value = (maxScore * i / 4 * 100).toFixed(0);
ctx.fillText(value + '%', padding - 10, y + 4);
ctx.strokeStyle = '#F0F0F0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(width - padding, y);
ctx.stroke();
}
displayData.forEach((session, index) => {
const barHeight = (session.focus_score / maxScore) * chartHeight;
const x = padding + index * barWidth + barWidth * 0.1;
const y = height - padding - barHeight;
const barActualWidth = barWidth * 0.8;
const score = session.focus_score;
let color;
if (score >= 0.8) color = '#4A90E2';
else if (score >= 0.6) color = '#5DADE2';
else if (score >= 0.4) color = '#85C1E9';
else color = '#AED6F1';
ctx.fillStyle = color;
ctx.fillRect(x, y, barActualWidth, barHeight);
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, barActualWidth, barHeight);
});
ctx.textAlign = 'left';
ctx.font = 'bold 14px Nunito';
ctx.fillStyle = '#4A90E2';
ctx.fillText('Focus Score by Session', padding, 30);
};
useEffect(() => {
loadSessions(filter);
}, [filter]);
useEffect(() => {
if (!detailState.open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
closeDetails();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [detailState.open]);
const handleFilterClick = (filterType) => {
setFilter(filterType);
};
const handleViewDetails = async (sessionId) => {
setDetailState({
open: true,
loading: true,
error: '',
session: null
});
try {
const response = await fetch(`/api/sessions/${sessionId}`);
if (!response.ok) {
throw new Error('Failed to load session details.');
}
const data = await response.json();
setDetailState({
open: true,
loading: false,
error: '',
session: data
});
} catch (error) {
setDetailState({
open: true,
loading: false,
error: error.message || 'Failed to load session details.',
session: null
});
}
};
// Data Management core function
const handleExport = async () => {
try {
const response = await fetch('/api/sessions?filter=all');
if (!response.ok) throw new Error("Failed to fetch data");
const data = await response.json();
const jsonString = JSON.stringify(data, null, 2);
localStorage.setItem('focus_magic_backup', jsonString);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `focus-guard-backup-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error(error);
alert("Export failed: " + error.message);
}
};
const triggerImport = () => {
fileInputRef.current.click();
};
const handleFileChange = async (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target.result;
const sessions = JSON.parse(content);
if (!Array.isArray(sessions)) {
throw new Error("Invalid file format: Expected a list of sessions.");
}
const response = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sessions)
});
if (response.ok) {
const result = await response.json();
alert(`Success! Imported ${result.count} sessions.`);
loadSessions(filter);
} else {
alert("Import failed on server side.");
}
} catch (err) {
alert("Error parsing file: " + err.message);
}
event.target.value = '';
};
reader.readAsText(file);
};
const handleClearHistory = async () => {
if (!window.confirm("Are you sure? This will delete ALL your session history permanently.")) {
return;
}
try {
const response = await fetch('/api/history', { method: 'DELETE' });
if (response.ok) {
alert("All history has been cleared.");
loadSessions(filter);
} else {
alert("Failed to clear history.");
}
} catch (err) {
alert("Error: " + err.message);
}
};
const detailView = buildDetailView(detailState.session);
return (
<main id="page-d" className="page">
<h1 className="page-title" style={{ marginBottom: '10px' }}>My Records</h1>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '15px', marginBottom: '25px' }}>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".json"
onChange={handleFileChange}
/>
<button
onClick={handleExport}
style={{ background: '#eef3f8', border: '1px solid #d9eaff', color: '#4b5a6b', padding: '6px 16px', borderRadius: '20px', fontSize: '12px', fontWeight: '700', cursor: 'pointer', transition: 'all 0.2s' }}
onMouseOver={(e) => { e.target.style.background = '#e2eaf3'; }}
onMouseOut={(e) => { e.target.style.background = '#eef3f8'; }}
>
⬇️ Export
</button>
<button
onClick={triggerImport}
style={{ background: '#eef3f8', border: '1px solid #d9eaff', color: '#4b5a6b', padding: '6px 16px', borderRadius: '20px', fontSize: '12px', fontWeight: '700', cursor: 'pointer', transition: 'all 0.2s' }}
onMouseOver={(e) => { e.target.style.background = '#e2eaf3'; }}
onMouseOut={(e) => { e.target.style.background = '#eef3f8'; }}
>
⬆️ Import
</button>
<button
onClick={handleClearHistory}
style={{ background: '#fff1ee', border: '1px solid #f3c7c7', color: '#b54028', padding: '6px 16px', borderRadius: '20px', fontSize: '12px', fontWeight: '700', cursor: 'pointer', transition: 'all 0.2s' }}
onMouseOver={(e) => { e.target.style.background = '#fbe5e1'; }}
onMouseOut={(e) => { e.target.style.background = '#fff1ee'; }}
>
🗑️ Clear
</button>
</div>
<div className="records-controls" style={{ display: 'flex', justifyContent: 'center', gap: '10px', marginBottom: '30px' }}>
<button id="filter-today" onClick={() => handleFilterClick('today')} style={{ padding: '10px 30px', borderRadius: '8px', border: filter === 'today' ? 'none' : '2px solid #4A90E2', background: filter === 'today' ? '#4A90E2' : 'transparent', color: filter === 'today' ? 'white' : '#4A90E2', fontSize: '14px', fontWeight: '500', cursor: 'pointer', transition: 'all 0.3s' }}>Today</button>
<button id="filter-week" onClick={() => handleFilterClick('week')} style={{ padding: '10px 30px', borderRadius: '8px', border: filter === 'week' ? 'none' : '2px solid #4A90E2', background: filter === 'week' ? '#4A90E2' : 'transparent', color: filter === 'week' ? 'white' : '#4A90E2', fontSize: '14px', fontWeight: '500', cursor: 'pointer', transition: 'all 0.3s' }}>This Week</button>
<button id="filter-month" onClick={() => handleFilterClick('month')} style={{ padding: '10px 30px', borderRadius: '8px', border: filter === 'month' ? 'none' : '2px solid #4A90E2', background: filter === 'month' ? '#4A90E2' : 'transparent', color: filter === 'month' ? 'white' : '#4A90E2', fontSize: '14px', fontWeight: '500', cursor: 'pointer', transition: 'all 0.3s' }}>This Month</button>
<button id="filter-all" onClick={() => handleFilterClick('all')} style={{ padding: '10px 30px', borderRadius: '8px', border: filter === 'all' ? 'none' : '2px solid #4A90E2', background: filter === 'all' ? '#4A90E2' : 'transparent', color: filter === 'all' ? 'white' : '#4A90E2', fontSize: '14px', fontWeight: '500', cursor: 'pointer', transition: 'all 0.3s' }}>All Time</button>
</div>
<div className="chart-container" style={{ background: 'white', padding: '20px', borderRadius: '10px', marginBottom: '30px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<canvas ref={chartRef} id="focus-chart" style={{ width: '100%', height: '300px' }}></canvas>
</div>
<div className="sessions-list" style={{ background: 'white', padding: '20px', borderRadius: '10px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', marginBottom: '30px' }}>
<h2 style={{ color: '#333', marginBottom: '20px', fontSize: '18px', fontWeight: '600' }}>Recent Sessions</h2>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>Loading sessions...</div>
) : sessions.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>No sessions found for this period.</div>
) : (
<table id="sessions-table" style={{ width: '100%', borderCollapse: 'collapse', borderRadius: '10px', overflow: 'hidden' }}>
<thead>
<tr style={{ background: '#4A90E2' }}>
<th style={{ padding: '15px', textAlign: 'left', color: 'white', fontWeight: '600', fontSize: '14px' }}>Date</th>
<th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Duration</th>
<th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Focus Score</th>
<th style={{ padding: '15px', textAlign: 'center', color: 'white', fontWeight: '600', fontSize: '14px' }}>Action</th>
</tr>
</thead>
<tbody id="sessions-tbody">
{sessions.map((session, index) => (
<tr key={session.id} style={{ background: index % 2 === 0 ? '#f8f9fa' : 'white', borderBottom: '1px solid #e9ecef' }}>
<td style={{ padding: '15px', color: '#333', fontSize: '13px' }}>{formatDate(session.start_time)}</td>
<td style={{ padding: '15px', textAlign: 'center', color: '#333', fontSize: '13px' }}>{formatDuration(session.duration_seconds)}</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<span style={{ color: session.focus_score >= 0.8 ? '#28a745' : session.focus_score >= 0.6 ? '#ffc107' : session.focus_score >= 0.4 ? '#fd7e14' : '#dc3545', fontWeight: '600', fontSize: '13px' }}>
{(session.focus_score * 100).toFixed(1)}%
</span>
</td>
<td style={{ padding: '15px', textAlign: 'center' }}>
<button onClick={() => handleViewDetails(session.id)} className="btn-view">View</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{detailState.open ? (
<div className="modal-overlay" onClick={closeDetails}>
<div className="modal-content records-detail-modal" onClick={(event) => event.stopPropagation()}>
<div className="records-detail-header">
<div>
<div className="records-detail-kicker">Session Detail</div>
<h2>
{detailState.session ? formatDateTime(detailState.session.start_time) : 'Loading session'}
</h2>
<p className="records-detail-subtitle">
Review score, capture quality, and a condensed event timeline for this session.
</p>
</div>
<button type="button" className="records-detail-close" onClick={closeDetails}>
Close
</button>
</div>
{detailState.loading ? (
<div className="records-detail-feedback">Loading session details...</div>
) : detailState.error ? (
<div className="records-detail-feedback records-detail-feedback-error">{detailState.error}</div>
) : detailState.session && detailView ? (
<>
<section className="records-detail-summary">
<article className={`records-detail-stat ${getScoreTone(detailState.session.focus_score)}`}>
<span className="records-detail-stat-label">Focus Score</span>
<strong className="records-detail-stat-value">
{(detailState.session.focus_score * 100).toFixed(1)}%
</strong>
</article>
<article className="records-detail-stat">
<span className="records-detail-stat-label">Duration</span>
<strong className="records-detail-stat-value">
{formatDuration(detailState.session.duration_seconds)}
</strong>
</article>
<article className="records-detail-stat">
<span className="records-detail-stat-label">Frames Analysed</span>
<strong className="records-detail-stat-value">{detailState.session.total_frames}</strong>
</article>
<article className="records-detail-stat">
<span className="records-detail-stat-label">Focused Frames</span>
<strong className="records-detail-stat-value">
{(detailView.focusRatio * 100).toFixed(1)}%
</strong>
</article>
</section>
<section className="records-detail-grid">
<article className="records-detail-card">
<h3>Session Info</h3>
<div className="records-detail-list">
<div className="records-detail-item">
<span className="records-detail-item-label">Started</span>
<span className="records-detail-item-value">{formatDateTime(detailState.session.start_time)}</span>
</div>
<div className="records-detail-item">
<span className="records-detail-item-label">Ended</span>
<span className="records-detail-item-value">{formatDateTime(detailState.session.end_time)}</span>
</div>
<div className="records-detail-item">
<span className="records-detail-item-label">Dominant Model</span>
<span className="records-detail-item-value">{detailView.dominantModel}</span>
</div>
<div className="records-detail-item">
<span className="records-detail-item-label">Event Samples</span>
<span className="records-detail-item-value">{detailView.parsedEvents.length}</span>
</div>
</div>
</article>
<article className="records-detail-card">
<h3>Signal Quality</h3>
<div className="records-detail-list">
<div className="records-detail-item">
<span className="records-detail-item-label">Avg Confidence</span>
<span className="records-detail-item-value">
{detailView.avgConfidence !== null ? `${(detailView.avgConfidence * 100).toFixed(1)}%` : '--'}
</span>
</div>
<div className="records-detail-item">
<span className="records-detail-item-label">Avg Face Score</span>
<span className="records-detail-item-value">
{detailView.avgFaceScore !== null ? detailView.avgFaceScore.toFixed(3) : '--'}
</span>
</div>
<div className="records-detail-item">
<span className="records-detail-item-label">Avg Eye Score</span>
<span className="records-detail-item-value">
{detailView.avgEyeScore !== null ? detailView.avgEyeScore.toFixed(3) : '--'}
</span>
</div>
<div className="records-detail-item">
<span className="records-detail-item-label">Avg MAR</span>
<span className="records-detail-item-value">
{detailView.avgMar !== null ? detailView.avgMar.toFixed(3) : '--'}
</span>
</div>
</div>
</article>
</section>
<section className="records-detail-card">
<div className="records-detail-section-head">
<h3>Focus Timeline</h3>
<span>{detailView.parsedEvents.length} events condensed</span>
</div>
{detailView.timeline.length > 0 ? (
<>
<div className="records-detail-timeline">
{detailView.timeline.map((segment, index) => (
<div
key={`${segment.tone}-${index}`}
className={`records-detail-segment ${segment.tone}`}
title={`${(segment.focusRatio * 100).toFixed(0)}% focused, ${segment.count} events`}
/>
))}
</div>
<div className="records-detail-legend">
<span><i className="records-detail-dot focused" />Focused</span>
<span><i className="records-detail-dot mixed" />Mixed</span>
<span><i className="records-detail-dot distracted" />Distracted</span>
</div>
</>
) : (
<div className="records-detail-empty">No event timeline was recorded for this session.</div>
)}
</section>
<section className="records-detail-card">
<div className="records-detail-section-head">
<h3>Recent Events</h3>
<span>Last {detailView.recentEvents.length} samples</span>
</div>
{detailView.recentEvents.length > 0 ? (
<div className="records-detail-events">
{detailView.recentEvents.map((event) => (
<article key={event.id} className="records-detail-event">
<div className="records-detail-event-time">{detailView.formatOffset(event.timestamp)}</div>
<div className="records-detail-event-copy">
<div className="records-detail-event-status">
{event.isFocused ? 'Focused' : 'Distracted'}
</div>
<div className="records-detail-event-meta">
{event.metadata?.model || 'model n/a'} · confidence {(event.confidence * 100).toFixed(1)}%
</div>
</div>
<div className={`records-detail-event-badge ${event.isFocused ? 'focused' : 'distracted'}`}>
{event.isFocused ? 'OK' : 'Alert'}
</div>
</article>
))}
</div>
) : (
<div className="records-detail-empty">No individual event samples are available.</div>
)}
</section>
</>
) : null}
</div>
</div>
) : null}
</main>
);
}
export default Records;