Yingtao-Zheng's picture
MERGE EVERYTHING from feature/integration-cleaned :((((((((((((((((((((((((((((((((((((((((((((((( so tired
7053a3a
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);
// Format a session duration.
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`;
};
// Format a session timestamp for table display.
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
});
};
// Load session rows for the selected filter.
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);
}
};
// Draw the session score chart.
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;
// Clear the canvas before each redraw.
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;
}
// Use at most the latest 20 sessions in the chart.
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;
// Use a normalized max score for chart scaling.
const maxScore = 1.0;
// Draw the chart axes.
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();
// Draw Y-axis labels.
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);
// Draw horizontal grid lines.
ctx.strokeStyle = '#F0F0F0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(width - padding, y);
ctx.stroke();
}
// Draw the bar chart.
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;
// Map each score to a blue-toned color band.
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);
// Draw a matching outline around each bar.
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, barActualWidth, barHeight);
});
// Draw the chart title.
ctx.textAlign = 'left';
ctx.font = 'bold 14px Nunito';
ctx.fillStyle = '#4A90E2';
ctx.fillText('Focus Score by Session', padding, 30);
};
// Initial load.
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]);
// Filter button handler.
const handleFilterClick = (filterType) => {
setFilter(filterType);
};
// Open the detail modal for one session.
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
});
}
};
const detailView = buildDetailView(detailState.session);
return (
<main id="page-d" className="page">
<h1 className="page-title">My Records</h1>
<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)'
}}>
<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;