Spaces:
Running
Running
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; | |