From 81120547aefa7f3e8c2b0759669b8a676080bc26 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 27 Apr 2026 16:37:48 +0200 Subject: [PATCH] wip: get timetable to work --- frontend/src/components/stats/BedtimePlot.js | 36 ----- frontend/src/components/stats/DailySummary.js | 48 ------ .../src/components/stats/IntervalPicker.js | 7 - frontend/src/components/stats/SessionList.js | 61 ------- .../src/components/stats/SettingsPopover.js | 37 ----- .../src/components/stats/SleepTimetable.js | 43 ----- .../src/components/stats/SleepTimetable.tsx | 153 +++++++++++------- frontend/src/components/stats/StatsPage.js | 22 --- frontend/src/components/stats/WakeupPlot.js | 36 ----- frontend/tests/e2e/timetable_mock.spec.ts | 80 +++++++++ 10 files changed, 176 insertions(+), 347 deletions(-) delete mode 100644 frontend/src/components/stats/BedtimePlot.js delete mode 100644 frontend/src/components/stats/DailySummary.js delete mode 100644 frontend/src/components/stats/IntervalPicker.js delete mode 100644 frontend/src/components/stats/SessionList.js delete mode 100644 frontend/src/components/stats/SettingsPopover.js delete mode 100644 frontend/src/components/stats/SleepTimetable.js delete mode 100644 frontend/src/components/stats/StatsPage.js delete mode 100644 frontend/src/components/stats/WakeupPlot.js create mode 100644 frontend/tests/e2e/timetable_mock.spec.ts diff --git a/frontend/src/components/stats/BedtimePlot.js b/frontend/src/components/stats/BedtimePlot.js deleted file mode 100644 index cb97652..0000000 --- a/frontend/src/components/stats/BedtimePlot.js +++ /dev/null @@ -1,36 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useEffect, useState } from 'react'; -import { useStats } from '../../hooks/useStats'; -import { formatMinutesAfter, getToday } from '../../utils/time'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; -const BedtimePlot = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => { - const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00', wakeup_ref, bedtime_ref); - const [chartData, setChartData] = useState([]); - useEffect(() => { - if (!wakeupBedtime?.days) { - setChartData([]); - return; - } - const data = wakeupBedtime.days.map((day) => { - const daysAgo = Math.floor((new Date(getToday()).getTime() - new Date(day.date).getTime()) / (1000 * 60 * 60 * 24)); - return { - date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`, - minutes: day.bedtime_minutes ?? 0, - time: day.bedtime_minutes !== null - ? formatMinutesAfter(day.bedtime_minutes, bedtime_ref) - : 'N/A', - }; - }).reverse(); - setChartData(data); - }, [wakeupBedtime, bedtime_ref]); - if (isLoading) - return _jsx("div", { className: "text-center p-4", children: "Loading plot..." }); - if (!chartData.length) { - return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No data available for this period." }); - } - return (_jsxs("div", { className: "bg-white p-4 rounded-xl shadow-sm border border-gray-200", children: [_jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "Bedtime" }), _jsx(ResponsiveContainer, { width: "100%", height: 200, children: _jsxs(LineChart, { data: chartData, margin: { top: 5, right: 20, left: 0, bottom: 5 }, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3", stroke: "#e5e7eb" }), _jsx(XAxis, { dataKey: "date", tick: { fontSize: 12 }, tickLine: false, axisLine: { stroke: '#e5e7eb' } }), _jsx(YAxis, { tick: { fontSize: 12 }, tickLine: false, axisLine: { stroke: '#e5e7eb' }, tickFormatter: (value) => formatMinutesAfter(value, bedtime_ref), domain: ['dataMin - 30', 'dataMax + 30'] }), _jsx(Tooltip, { formatter: (value) => [ - formatMinutesAfter(value, bedtime_ref), - 'Bedtime', - ], labelFormatter: (label) => label }), _jsx(Line, { type: "monotone", dataKey: "minutes", stroke: "#8b5cf6", strokeWidth: 2, dot: { fill: '#8b5cf6', r: 4 }, connectNulls: false })] }) })] })); -}; -export default BedtimePlot; diff --git a/frontend/src/components/stats/DailySummary.js b/frontend/src/components/stats/DailySummary.js deleted file mode 100644 index 9e5cc21..0000000 --- a/frontend/src/components/stats/DailySummary.js +++ /dev/null @@ -1,48 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useEffect, useState } from 'react'; -import { useStats } from '../../hooks/useStats'; -import { formatMinutes } from '../../utils/time'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; -const DailySummary = ({ from_date, to_date, day_start, day_end }) => { - const { dailySummary, isLoading } = useStats(from_date, to_date, day_start, day_end); - const [chartData, setChartData] = useState([]); - const [avgStats, setAvgStats] = useState({ avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 }); - useEffect(() => { - if (!dailySummary?.days) { - setChartData([]); - setAvgStats({ avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 }); - return; - } - const data = dailySummary.days.map((day) => ({ - date: day.date, - daySleep: day.day_minutes / 60, - nightSleep: day.night_minutes / 60, - total: day.total_minutes / 60, - })); - setChartData(data.reverse()); - if (data.length) { - const totals = data.reduce((acc, d) => ({ - total: acc.total + d.total, - day: acc.day + d.daySleep, - night: acc.night + d.nightSleep, - }), { total: 0, day: 0, night: 0 }); - const count = data.length; - setAvgStats({ - avgTotal: totals.total / count, - avgDay: totals.day / count, - avgNight: totals.night / count, - count, - }); - } - }, [dailySummary]); - if (isLoading) - return _jsx("div", { className: "text-center p-4", children: "Loading summary..." }); - if (!chartData.length) { - return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No data available for this period." }); - } - return (_jsxs("div", { className: "bg-white p-4 rounded-xl shadow-sm border border-gray-200", children: [_jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "Daily Summary" }), _jsx(ResponsiveContainer, { width: "100%", height: Math.max(200, chartData.length * 30), children: _jsxs(BarChart, { data: chartData, layout: "vertical", margin: { top: 5, right: 30, left: 60, bottom: 5 }, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3", stroke: "#e5e7eb" }), _jsx(XAxis, { type: "number", tickFormatter: (value) => `${Math.round(value)}h`, tick: { fontSize: 12 }, tickLine: false }), _jsx(YAxis, { type: "category", dataKey: "date", tick: { fontSize: 12 }, tickLine: false, width: 60 }), _jsx(Tooltip, { formatter: (value, name) => [ - formatMinutes(Math.round(value * 60)), - name === 'daySleep' ? 'Day' : 'Night', - ] }), _jsx(Bar, { dataKey: "nightSleep", stackId: "a", fill: "#1e3a5f" }), _jsx(Bar, { dataKey: "daySleep", stackId: "a", fill: "#fbbf24" })] }) }), _jsxs("div", { className: "mt-4 grid grid-cols-3 gap-4 text-center", children: [_jsxs("div", { className: "p-3 bg-gray-50 rounded-lg", children: [_jsx("div", { className: "text-xs text-gray-500", children: "Avg Total" }), _jsx("div", { className: "text-lg font-semibold text-gray-800", children: formatMinutes(Math.round(avgStats.avgTotal * 60)) })] }), _jsxs("div", { className: "p-3 bg-gray-50 rounded-lg", children: [_jsx("div", { className: "text-xs text-gray-500", children: "Avg Day" }), _jsx("div", { className: "text-lg font-semibold text-gray-800", children: formatMinutes(Math.round(avgStats.avgDay * 60)) })] }), _jsxs("div", { className: "p-3 bg-gray-50 rounded-lg", children: [_jsx("div", { className: "text-xs text-gray-500", children: "Avg Night" }), _jsx("div", { className: "text-lg font-semibold text-gray-800", children: formatMinutes(Math.round(avgStats.avgNight * 60)) })] })] })] })); -}; -export default DailySummary; diff --git a/frontend/src/components/stats/IntervalPicker.js b/frontend/src/components/stats/IntervalPicker.js deleted file mode 100644 index dbfdd07..0000000 --- a/frontend/src/components/stats/IntervalPicker.js +++ /dev/null @@ -1,7 +0,0 @@ -import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; -const INTERVALS = [7, 14, 30, 90]; -export function IntervalPicker({ value, onChange }) { - return (_jsx("div", { className: "flex justify-center space-x-1 bg-gray-100 p-1 rounded-lg", children: INTERVALS.map((days) => (_jsxs("button", { onClick: () => onChange(days), className: `px-4 py-2 rounded-md text-sm font-medium transition-all ${value === days - ? 'bg-white text-blue-600 shadow-sm' - : 'text-gray-600 hover:text-gray-900'}`, children: [days, "d"] }, days))) })); -} diff --git a/frontend/src/components/stats/SessionList.js b/frontend/src/components/stats/SessionList.js deleted file mode 100644 index e0b4c7c..0000000 --- a/frontend/src/components/stats/SessionList.js +++ /dev/null @@ -1,61 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useState } from 'react'; -import { useSessions } from '../../hooks/useSleepSession'; -import { Edit2, Check, X } from 'lucide-react'; -function formatDateTime(isoString) { - const iso = isoString.endsWith('Z') ? isoString : isoString + 'Z'; - const d = new Date(iso); - return d.toLocaleString('sv-SE', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }).replace(',', ' '); -} -function toDatetimeLocal(isoString) { - const iso = isoString.endsWith('Z') ? isoString : isoString + 'Z'; - const d = new Date(iso); - const pad = (n) => n.toString().padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; -} -export function SessionList() { - const { sessions, isLoading, updateSession } = useSessions(); - const [editingId, setEditingId] = useState(null); - const [editStart, setEditStart] = useState(''); - const [editEnd, setEditEnd] = useState(''); - if (isLoading) - return _jsx("div", { className: "text-center p-4", children: "Loading sessions..." }); - if (!sessions.length) - return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No sessions recorded." }); - const handleSave = async (id) => { - try { - await updateSession({ - sessionId: id, - payload: { - start_time: new Date(editStart).toISOString(), - end_time: editEnd ? new Date(editEnd).toISOString() : null, - } - }); - setEditingId(null); - } - catch (err) { - console.error(err); - alert('Failed to update session'); - } - }; - const handleCancel = () => { - setEditingId(null); - }; - const startEditing = (s) => { - setEditingId(s.id); - setEditStart(toDatetimeLocal(s.start_time)); - setEditEnd(s.end_time ? toDatetimeLocal(s.end_time) : ''); - }; - return (_jsxs("div", { className: "bg-white p-4 rounded-xl shadow-sm border border-gray-200", children: [_jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "Sleep Sessions" }), _jsx("div", { className: "space-y-2", children: sessions.map((s) => (_jsx("div", { className: "border-b border-gray-100 last:border-0 pb-2", children: editingId === s.id ? (_jsxs("div", { className: "flex flex-col space-y-2 py-1", children: [_jsxs("div", { className: "grid grid-cols-1 gap-2 text-xs text-gray-500", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { children: "Start Time" }), _jsx("input", { type: "datetime-local", className: "border rounded px-1 py-0.5 text-sm text-black w-full", value: editStart, onChange: (e) => setEditStart(e.target.value) })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { children: "End Time" }), _jsx("input", { type: "datetime-local", className: "border rounded px-1 py-0.5 text-sm text-black w-full", value: editEnd, onChange: (e) => setEditEnd(e.target.value) })] })] }), _jsxs("div", { className: "flex justify-end space-x-2", children: [_jsx("button", { onClick: handleCancel, className: "p-1 text-gray-500 hover:text-gray-700 transition-colors", title: "Cancel", children: _jsx(X, { size: 18 }) }), _jsx("button", { onClick: () => handleSave(s.id), className: "p-1 text-green-600 hover:text-green-700 transition-colors", title: "Save", children: _jsx(Check, { size: 18 }) })] })] })) : (_jsxs("div", { className: "flex justify-between items-center text-sm cursor-pointer hover:bg-gray-50 p-1 rounded transition-colors", onClick: () => startEditing(s), children: [_jsxs("div", { className: "flex flex-col", children: [_jsx("span", { className: "text-gray-900 font-medium", children: formatDateTime(s.start_time) }), _jsx("span", { className: "text-gray-500 text-xs", children: s.end_time - ? `${formatDateTime(s.start_time)} → ${formatDateTime(s.end_time)}` - : 'In progress' })] }), _jsxs("div", { className: "flex items-center space-x-2", children: [_jsx("span", { className: "text-blue-600 font-mono text-sm", children: s.end_time && s.start_time - ? `${Math.round((new Date(s.end_time + 'Z').getTime() - new Date(s.start_time + 'Z').getTime()) / 60000)} min` - : '—' }), _jsx(Edit2, { size: 14, className: "text-gray-400" })] })] })) }, s.id))) })] })); -} diff --git a/frontend/src/components/stats/SettingsPopover.js b/frontend/src/components/stats/SettingsPopover.js deleted file mode 100644 index 7826243..0000000 --- a/frontend/src/components/stats/SettingsPopover.js +++ /dev/null @@ -1,37 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useState, useRef, useEffect } from 'react'; -export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSave }) { - const [isOpen, setIsOpen] = useState(false); - const [localDayStart, setLocalDayStart] = useState(dayStart); - const [localDayEnd, setLocalDayEnd] = useState(dayEnd); - const [localWakeupRef, setLocalWakeupRef] = useState(wakeupRef); - const [localBedtimeRef, setLocalBedtimeRef] = useState(bedtimeRef); - const popoverRef = useRef(null); - useEffect(() => { - setLocalDayStart(dayStart); - setLocalDayEnd(dayEnd); - setLocalWakeupRef(wakeupRef); - setLocalBedtimeRef(bedtimeRef); - }, [dayStart, dayEnd, wakeupRef, bedtimeRef]); - useEffect(() => { - const handleClickOutside = (e) => { - if (popoverRef.current && !popoverRef.current.contains(e.target)) { - setIsOpen(false); - } - }; - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [isOpen]); - const handleSave = () => { - onSave({ - dayStart: localDayStart, - dayEnd: localDayEnd, - wakeupRef: localWakeupRef, - bedtimeRef: localBedtimeRef, - }); - setIsOpen(false); - }; - return (_jsxs("div", { className: "relative", ref: popoverRef, children: [_jsx("button", { onClick: () => setIsOpen(!isOpen), className: "p-2 text-gray-500 hover:text-gray-700", "aria-label": "Settings", children: _jsxs("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: [_jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" }), _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" })] }) }), isOpen && (_jsxs("div", { className: "absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-gray-200 p-4 z-50", children: [_jsx("h4", { className: "font-semibold text-gray-800 mb-4", children: "Settings" }), _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "day-start", className: "block text-xs text-gray-500 mb-1", children: "Day starts (HH:MM)" }), _jsx("input", { id: "day-start", type: "time", value: localDayStart, onChange: (e) => setLocalDayStart(e.target.value), className: "w-full p-2 border rounded-md text-sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "day-end", className: "block text-xs text-gray-500 mb-1", children: "Day ends (HH:MM)" }), _jsx("input", { id: "day-end", type: "time", value: localDayEnd, onChange: (e) => setLocalDayEnd(e.target.value), className: "w-full p-2 border rounded-md text-sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "wakeup-ref", className: "block text-xs text-gray-500 mb-1", children: "Wakeup ref (HH:MM)" }), _jsx("input", { id: "wakeup-ref", type: "time", value: localWakeupRef, onChange: (e) => setLocalWakeupRef(e.target.value), className: "w-full p-2 border rounded-md text-sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "bedtime-ref", className: "block text-xs text-gray-500 mb-1", children: "Bedtime ref (HH:MM)" }), _jsx("input", { id: "bedtime-ref", type: "time", value: localBedtimeRef, onChange: (e) => setLocalBedtimeRef(e.target.value), className: "w-full p-2 border rounded-md text-sm" })] })] }), _jsx("button", { onClick: handleSave, className: "w-full mt-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700", children: "Save" })] }))] })); -} diff --git a/frontend/src/components/stats/SleepTimetable.js b/frontend/src/components/stats/SleepTimetable.js deleted file mode 100644 index 7da5e20..0000000 --- a/frontend/src/components/stats/SleepTimetable.js +++ /dev/null @@ -1,43 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useStats } from '../../hooks/useStats'; -import { timeToMinutes, formatMinutesAfter } from '../../utils/time'; -; -const Timetable = ({ from_date, to_date, day_start, day_end }) => { - const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end); - if (isLoading) - return _jsx("div", { className: "text-center p-4", children: "Loading timetable..." }); - if (!timetable?.slots || timetable.slots.length === 0) { - return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No data available for this period." }); - } - const startMins = timeToMinutes(day_start); - let endMins = timeToMinutes(day_end); - if (endMins < startMins) - endMins += 1440; - const totalDayMinutes = endMins - startMins; - // Grouping slots by date to make it easier to render rows - const groupedSlots = timetable.slots.reduce((acc, slot) => { - if (!acc[slot.date]) - acc[slot.date] = []; - acc[slot.date].push(slot); - return acc; - }, {}); - return (_jsxs("div", { className: "bg-white p-4 rounded-xl shadow-sm border border-gray-200 overflow-x-auto", children: [_jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "Sleep Timetable" }), _jsx("div", { className: "min-w-[800px]", children: Object.keys(groupedSlots).sort((a, b) => b.localeCompare(a)).map((date) => (_jsxs("div", { className: "mb-8 last:mb-0", children: [_jsx("h4", { className: "text-sm font-medium text-gray-500 mb-2", children: date }), _jsxs("div", { className: "relative w-full", children: [_jsx("div", { className: "relative h-8 bg-gray-50 p-1 rounded-lg overflow-hidden", children: groupedSlots[date].map((slot) => { - let sStart = timeToMinutes(slot.slot_start); - if (sStart < startMins) - sStart += 1440; - let sEnd = timeToMinutes(slot.slot_end); - if (sEnd < sStart) - sEnd += 1440; - else if (sEnd < startMins) - sEnd += 1440; - const left = ((sStart - startMins) / totalDayMinutes) * 100; - const width = ((sEnd - sStart) / totalDayMinutes) * 100; - return (_jsx("div", { className: `absolute h-full rounded-sm transition-all duration-300 cursor-help ${slot.sleeping ? 'bg-blue-900' : 'bg-gray-200'}`, style: { left: `${left}%`, width: `${width}%` }, title: `${slot.slot_start} - ${slot.slot_end}`, children: _jsx("div", { className: "overflow-hidden whitespace-nowrap text-[10px] leading-none flex items-center justify-center h-full px-1 text-white/70", children: width > 15 && _jsx("span", { children: slot.slot_start }) }) }, `${date}-${slot.slot_start}`)); - }) }), _jsx("div", { className: "flex justify-between mt-1 px-1 text-[10px] text-gray-400", children: Array.from({ length: Math.ceil(totalDayMinutes / 120) + 1 }).map((_, i) => { - const minsOffset = i * 120; - if (minsOffset > totalDayMinutes) - return null; - return (_jsx("span", { className: "w-0 min-w-[30px] text-center", children: formatMinutesAfter(minsOffset, day_start) }, i)); - }) })] })] }, date))) })] })); -}; -export default Timetable; diff --git a/frontend/src/components/stats/SleepTimetable.tsx b/frontend/src/components/stats/SleepTimetable.tsx index 05bb7da..d07292f 100644 --- a/frontend/src/components/stats/SleepTimetable.tsx +++ b/frontend/src/components/stats/SleepTimetable.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useStats } from '../../hooks/useStats'; import { timeToMinutes, formatMinutesAfter } from '../../utils/time'; @@ -7,86 +7,125 @@ interface TimetableProps { to_date: string; day_start: string; day_end: string; -}; +} const Timetable: React.FC = ({ from_date, to_date, day_start, day_end }) => { const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end); + const days = useMemo(() => { + const start = new Date(from_date); + const end = new Date(to_date); + const result: string[] = []; + let current = new Date(start); + while (current <= end) { + result.push(current.toISOString().split('T')[0]); + current.setDate(current.getDate() + 1); + } + return result; + }, [from_date, to_date]); + + const timeSteps = useMemo(() => { + return Array.from({ length: 12 }, (_, i) => i * 120); + }, []); + if (isLoading) return
Loading timetable...
; if (!timetable?.slots || timetable.slots.length === 0) { return
No data available for this period.
; } - const startMins = timeToMinutes(day_start); - let endMins = timeToMinutes(day_end); - if (endMins < startMins) endMins += 1440; - const totalDayMinutes = endMins - startMins; + const getDayName = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short' }); + }; - // Grouping slots by date to make it easier to render rows - const groupedSlots = timetable.slots.reduce((acc: any, slot) => { - if (!acc[slot.date]) acc[slot.date] = []; - acc[slot.date].push(slot); - return acc; - }, {}); + const formatTimeLabel = (mins: number) => { + const hours = Math.floor(mins / 60); + const minutes = mins % 60; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; + }; + + const isSleepingInInterval = (date: string, intervalStart: number, intervalEnd: number) => { + return timetable.slots.some((slot) => { + if (slot.date !== date || !slot.sleeping) return false; + + let sStart = timeToMinutes(slot.slot_start); + let sEnd = timeToMinutes(slot.slot_end); + + if (sEnd <= sStart) { + const overlap1 = Math.max(sStart, intervalStart) < Math.min(1440, intervalEnd); + const overlap2 = Math.max(0, intervalStart) < Math.min(sEnd, intervalEnd); + return overlap1 || overlap2; + } + + return Math.max(sStart, intervalStart) < Math.min(sEnd, intervalEnd); + }); + }; + + const currentMins = new Date().getHours() * 60 + new Date().getMinutes(); + + // Total rows: 1 (header) + 12 (time steps) = 13 + const totalRows = 13; return (

Sleep Timetable

-
- {Object.keys(groupedSlots).sort((a, b) => b.localeCompare(a)).map((date) => ( -
-

{date}

-
- {/* The progress bar container */} -
- {groupedSlots[date].map((slot: any) => { - let sStart = timeToMinutes(slot.slot_start); - if (sStart < startMins) sStart += 1440; - - let sEnd = timeToMinutes(slot.slot_end); - if (sEnd < sStart) sEnd += 1440; - else if (sEnd < startMins) sEnd += 1440; - - const left = ((sStart - startMins) / totalDayMinutes) * 100; - const width = ((sEnd - sStart) / totalDayMinutes) * 100; +
+ {/* Header Row */} +
+
{/* Empty corner cell */} + {days.map((day) => ( +
+ {getDayName(day)} +
+ ))} +
+ {/* Time Grid Container */} +
+
+ {/* Time Rows */} + {timeSteps.map((step) => ( + + {/* Time Label */} +
+ {formatTimeLabel(step)} +
+ + {/* Grid Cells */} + {days.map((day) => { + const sleeping = isSleepingInInterval(day, step, step + 120); return (
- {/* Only show text if there's enough space */} -
- {width > 15 && {slot.slot_start}} -
-
+ key={`${day}-${step}`} + className={`h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`} + /> ); })} -
+ + ))} - {/* Time hints below the bar */} -
- {Array.from({ length: Math.ceil(totalDayMinutes / 120) + 1 }).map((_, i) => { - const minsOffset = i * 120; - if (minsOffset > totalDayMinutes) return null; - return ( - - {formatMinutesAfter(minsOffset, day_start)} - - ); - })} -
-
+ {/* Red Marker Line */} +
- ))} +
); }; export default Timetable; - diff --git a/frontend/src/components/stats/StatsPage.js b/frontend/src/components/stats/StatsPage.js deleted file mode 100644 index 8bf21b7..0000000 --- a/frontend/src/components/stats/StatsPage.js +++ /dev/null @@ -1,22 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { IntervalPicker } from './IntervalPicker'; -import { SettingsPopover } from './SettingsPopover'; -import DailySummary from './DailySummary'; -import Timetable from './SleepTimetable'; -import WakeupPlot from './WakeupPlot'; -import BedtimePlot from './BedtimePlot'; -import { SessionList } from './SessionList'; -import { ErrorBoundary } from '../../components/ErrorBoundary'; -import { useSettings } from '../../hooks/useSettings'; -const StatsPage = () => { - const { settings, update } = useSettings(); - const from_date = new Date(new Date().setDate(new Date().getDate() - settings.statsInterval)).toISOString().split('T')[0]; - const to_date = new Date().toISOString().split('T')[0]; - return (_jsxs("div", { className: "max-w-4xl mx-auto space-y-8 pb-12", children: [_jsxs("div", { className: "flex justify-between items-center pt-4", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-3xl font-extrabold text-slate-900 tracking-tight", children: "Statistics" }), _jsx("p", { className: "text-slate-500 text-sm mt-1", children: "Overview of sleep patterns and trends" })] }), _jsx(SettingsPopover, { dayStart: settings.dayStart, dayEnd: settings.dayEnd, wakeupRef: settings.wakeupRef, bedtimeRef: settings.bedtimeRef, onSave: (s) => { - update('dayStart', s.dayStart); - update('dayEnd', s.dayEnd); - update('wakeupRef', s.wakeupRef); - update('bedtimeRef', s.bedtimeRef); - } })] }), _jsx("div", { className: "bg-white p-6 rounded-[2rem] shadow-md border border-gray-100", children: _jsx(IntervalPicker, { value: settings.statsInterval, onChange: (days) => update('statsInterval', days) }) }), _jsx(ErrorBoundary, { children: _jsx(DailySummary, { from_date: from_date, to_date: to_date, day_start: settings.dayStart, day_end: settings.dayEnd }) }), _jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-6", children: [_jsx(ErrorBoundary, { children: _jsx(WakeupPlot, { from_date: from_date, to_date: to_date, wakeup_ref: settings.wakeupRef, bedtime_ref: settings.bedtimeRef }) }), _jsx(ErrorBoundary, { children: _jsx(BedtimePlot, { from_date: from_date, to_date: to_date, wakeup_ref: settings.wakeupRef, bedtime_ref: settings.bedtimeRef }) })] }), _jsx(ErrorBoundary, { children: _jsx(Timetable, { from_date: from_date, to_date: to_date, day_start: settings.dayStart, day_end: settings.dayEnd }) }), _jsx(ErrorBoundary, { children: _jsx(SessionList, {}) })] })); -}; -export default StatsPage; diff --git a/frontend/src/components/stats/WakeupPlot.js b/frontend/src/components/stats/WakeupPlot.js deleted file mode 100644 index 6268dc9..0000000 --- a/frontend/src/components/stats/WakeupPlot.js +++ /dev/null @@ -1,36 +0,0 @@ -import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useEffect, useState } from 'react'; -import { useStats } from '../../hooks/useStats'; -import { formatMinutesAfter, getToday } from '../../utils/time'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; -const WakeupPlot = ({ from_date, to_date, wakeup_ref, bedtime_ref }) => { - const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00', wakeup_ref, bedtime_ref); - const [chartData, setChartData] = useState([]); - useEffect(() => { - if (!wakeupBedtime?.days) { - setChartData([]); - return; - } - const data = wakeupBedtime.days.map((day) => { - const daysAgo = Math.floor((new Date(getToday()).getTime() - new Date(day.date).getTime()) / (1000 * 60 * 60 * 24)); - return { - date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`, - minutes: day.wakeup_minutes ?? 0, - time: day.wakeup_minutes !== null - ? formatMinutesAfter(day.wakeup_minutes, wakeup_ref) - : 'N/A', - }; - }).reverse(); - setChartData(data); - }, [wakeupBedtime, wakeup_ref]); - if (isLoading) - return _jsx("div", { className: "text-center p-4", children: "Loading plot..." }); - if (!chartData.length) { - return _jsx("div", { className: "text-center p-4 text-gray-500", children: "No data available for this period." }); - } - return (_jsxs("div", { className: "bg-white p-4 rounded-xl shadow-sm border border-gray-200", children: [_jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "Wakeup Times" }), _jsx(ResponsiveContainer, { width: "100%", height: 200, children: _jsxs(LineChart, { data: chartData, margin: { top: 5, right: 20, left: 0, bottom: 5 }, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3", stroke: "#e5e7eb" }), _jsx(XAxis, { dataKey: "date", tick: { fontSize: 12 }, tickLine: false, axisLine: { stroke: '#e5e7eb' } }), _jsx(YAxis, { tick: { fontSize: 12 }, tickLine: false, axisLine: { stroke: '#e5e7eb' }, tickFormatter: (value) => formatMinutesAfter(value, wakeup_ref), domain: ['dataMin - 30', 'dataMax + 30'] }), _jsx(Tooltip, { formatter: (value) => [ - formatMinutesAfter(value, wakeup_ref), - 'Wakeup', - ], labelFormatter: (label) => label }), _jsx(Line, { type: "monotone", dataKey: "minutes", stroke: "#3b82f6", strokeWidth: 2, dot: { fill: '#3b82f6', r: 4 }, connectNulls: false })] }) })] })); -}; -export default WakeupPlot; diff --git a/frontend/tests/e2e/timetable_mock.spec.ts b/frontend/tests/e2e/timetable_mock.spec.ts new file mode 100644 index 0000000..61562dd --- /dev/null +++ b/frontend/tests/e2e/timetable_mock.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Timetable Mocked Data', () => { + test.beforeEach(async ({ page }) => { + // Mock current time to 12:00 PM for consistent red line position (50% through the day) + await page.clock.setFixedTime(new Date('2026-04-25T12:00:00')); + + // Mock API responses + await page.route('**/api/stats/daily-summary*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + total_sleep_minutes: 480, + }), + }); + }); + + await page.route('**/api/stats/timetable*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + slots: [ + { date: '2026-04-25', slot_start: '22:00', slot_end: '06:00', sleeping: true }, + { date: '2026-04-24', slot_start: '23:00', slot_end: '07:00', sleeping: true }, + ], + }), + }); + }); + + await page.route('**/api/stats/wake-sleep-times*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + wakeup_times: [], + bedtime_times: [], + }), + }); + }); + + await page.goto('http://localhost:5173/stats'); + }); + + test('Timetable should render with correct sleep blocks and red line position', async ({ page }) => { + // Find the container that holds the timetable. + const componentContainer = page.locator('div').filter({ + has: page.locator('h3', { hasText: 'Sleep Timetable' }) + }).first(); + + await expect(componentContainer).toBeVisible(); + + // Check if some sleep blocks are green + const sleepingBlock = page.locator('.bg-green-500').first(); + await expect(sleepingBlock).toBeVisible(); + + // The red line should be at roughly 50% of the grid height if it's 12:00 PM + const redLine = page.locator('.bg-red-500').first(); + await expect(redLine).toBeVisible(); + + const redLineBox = await redLine.boundingBox(); + // We need to find the grid container specifically for precise alignment check + const gridContainer = componentContainer.locator('.relative').first(); + const gridBox = await gridContainer.boundingBox(); + + if (gridBox && redLineBox) { + // The red line's Y position relative to the grid container should be ~50% + const relativeY = redLineBox.y - gridBox.y; + const percentage = (relativeY / gridBox.height) * 100; + + console.log(`Red line relative Y: ${relativeY}, Grid height: ${gridBox.height}, Percentage: ${percentage}%`); + + // At 12:00 PM, it should be exactly at 50% + expect(percentage).toBeCloseTo(50, 0); + } else { + throw new Error('Could not get bounding boxes for red line or grid container'); + } + }); +});