wip: get timetable to work

This commit is contained in:
2026-04-27 16:37:48 +02:00
parent 54e382cbb3
commit 81120547ae
10 changed files with 176 additions and 347 deletions
@@ -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;
@@ -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;
@@ -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))) }));
}
@@ -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))) })] }));
}
@@ -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" })] }))] }));
}
@@ -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;
@@ -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<TimetableProps> = ({ 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 <div className="text-center p-4">Loading timetable...</div>;
if (!timetable?.slots || timetable.slots.length === 0) {
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
}
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 (
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200 overflow-x-auto">
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Timetable</h3>
<div className="min-w-[800px]">
{Object.keys(groupedSlots).sort((a, b) => b.localeCompare(a)).map((date) => (
<div key={date} className="mb-8 last:mb-0">
<h4 className="text-sm font-medium text-gray-500 mb-2">{date}</h4>
<div className="relative w-full">
{/* The progress bar container */}
<div className="relative h-8 bg-gray-50 p-1 rounded-lg overflow-hidden">
{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;
<div className="min-w-[600px] flex flex-col gap-px">
{/* Header Row */}
<div
className="grid"
style={{
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}}
>
<div className="h-8" /> {/* Empty corner cell */}
{days.map((day) => (
<div key={day} className="text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-8">
{getDayName(day)}
</div>
))}
</div>
{/* Time Grid Container */}
<div className="relative">
<div
className="grid gap-px"
style={{
gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))`
}}
>
{/* Time Rows */}
{timeSteps.map((step) => (
<React.Fragment key={step}>
{/* Time Label */}
<div className="text-[10px] text-gray-400 flex items-center justify-end pr-2 h-8">
{formatTimeLabel(step)}
</div>
{/* Grid Cells */}
{days.map((day) => {
const sleeping = isSleepingInInterval(day, step, step + 120);
return (
<div
key={`${date}-${slot.slot_start}`}
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}`}
>
{/* Only show text if there's enough space */}
<div className="overflow-hidden whitespace-nowrap text-[10px] leading-none flex items-center justify-center h-full px-1 text-white/70">
{width > 15 && <span>{slot.slot_start}</span>}
</div>
</div>
key={`${day}-${step}`}
className={`h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}`}
/>
);
})}
</div>
</React.Fragment>
))}
{/* Time hints below the bar */}
<div className="flex justify-between mt-1 px-1 text-[10px] text-gray-400">
{Array.from({ length: Math.ceil(totalDayMinutes / 120) + 1 }).map((_, i) => {
const minsOffset = i * 120;
if (minsOffset > totalDayMinutes) return null;
return (
<span key={i} className="w-0 min-w-[30px] text-center">
{formatMinutesAfter(minsOffset, day_start)}
</span>
);
})}
</div>
</div>
{/* Red Marker Line */}
<div
className="absolute left-[4rem] right-0 h-0.5 bg-red-500 pointer-events-none z-10"
style={{
top: `${(currentMins / 1440) * 100}%`,
transform: 'translateY(-50%)'
}}
/>
</div>
))}
</div>
</div>
</div>
);
};
export default Timetable;
@@ -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;
@@ -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;
+80
View File
@@ -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');
}
});
});