diff --git a/backend/migrate_sleep_data.py b/backend/scripts/migrate_sleep_data.py similarity index 100% rename from backend/migrate_sleep_data.py rename to backend/scripts/migrate_sleep_data.py diff --git a/docker-compose.yml b/docker-compose.yml index 39fc147..55e5001 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: context: ./backend platforms: [linux/arm64] volumes: - - sleep_data:/app/data + - ./backend/data:/app/data environment: - DATABASE_URL=sqlite+aiosqlite:///./data/sleep.db - CORS_ORIGINS=http://localhost @@ -19,6 +19,3 @@ services: depends_on: - backend restart: unless-stopped - -volumes: - sleep_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 00ad83b..3324148 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app COPY package*.json ./ # Install dependencies (including devDependencies for build) -RUN npm ci +RUN npm install # Copy the rest of the application source code COPY . . diff --git a/frontend/nginx.conf b/frontend/nginx.conf index e37b2cd..9264f1a 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -11,8 +11,7 @@ server { } # Proxy API requests to backend container - location /api/ { - rewrite ^/api/(.*)$ /$1 break; + location /api { proxy_pass http://backend:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 47632ed..5c03f35 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -284,31 +284,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", diff --git a/frontend/src/components/stats/BedtimePlot.js b/frontend/src/components/stats/BedtimePlot.js new file mode 100644 index 0000000..e163007 --- /dev/null +++ b/frontend/src/components/stats/BedtimePlot.js @@ -0,0 +1,36 @@ +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) => [ + value !== undefined ? 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/BedtimePlot.tsx b/frontend/src/components/stats/BedtimePlot.tsx index 9b2c2f8..413430a 100644 --- a/frontend/src/components/stats/BedtimePlot.tsx +++ b/frontend/src/components/stats/BedtimePlot.tsx @@ -70,10 +70,10 @@ const BedtimePlot: React.FC = ({ from_date, to_date, wakeup_ domain={['dataMin - 30', 'dataMax + 30']} /> [ - formatMinutesAfter(value, bedtime_ref), + formatter={(value: any) => [ + value !== undefined ? formatMinutesAfter(value, bedtime_ref) : '', 'Bedtime', - ]} + ] as any} labelFormatter={(label) => label} /> { + 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 ?? 0) * 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/DailySummary.tsx b/frontend/src/components/stats/DailySummary.tsx index 0afdd18..40ef95c 100644 --- a/frontend/src/components/stats/DailySummary.tsx +++ b/frontend/src/components/stats/DailySummary.tsx @@ -95,10 +95,10 @@ const DailySummary: React.FC = ({ from_date, to_date, day_sta width={60} /> [ - formatMinutes(Math.round(value * 60)), - name === 'daySleep' ? 'Day' : 'Night', - ]} + formatter={(value: any, name: any) => [ + formatMinutes(Math.round((value ?? 0) * 60)), + name === 'daySleep' ? 'Day' : 'Night', + ] as any} /> diff --git a/frontend/src/components/stats/IntervalPicker.js b/frontend/src/components/stats/IntervalPicker.js new file mode 100644 index 0000000..dbfdd07 --- /dev/null +++ b/frontend/src/components/stats/IntervalPicker.js @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..e0b4c7c --- /dev/null +++ b/frontend/src/components/stats/SessionList.js @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..7826243 --- /dev/null +++ b/frontend/src/components/stats/SettingsPopover.js @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..f944c74 --- /dev/null +++ b/frontend/src/components/stats/SleepTimetable.js @@ -0,0 +1,61 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import React, { useMemo } from 'react'; +import { useStats } from '../../hooks/useStats'; +import { timeToMinutes } 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); + const days = useMemo(() => { + const start = new Date(from_date); + const end = new Date(to_date); + const result = []; + 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: 24 }, (_, i) => i * 60); + }, []); + 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 getDayName = (dateStr) => { + return new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short' }); + }; + const formatTimeLabel = (mins) => { + const hours = Math.floor(mins / 60); + const minutes = mins % 60; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; + }; + const isSleepingInInterval = (date, intervalStart, intervalEnd) => { + 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(); + 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" }), _jsxs("div", { className: "min-w-[600px] flex flex-col gap-px", children: [_jsxs("div", { className: "grid", style: { + gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))` + }, children: [_jsx("div", { className: "h-8" }), " ", days.map((day) => (_jsx("div", { className: "text-center text-[10px] font-medium text-gray-500 uppercase flex items-center justify-center h-8", children: getDayName(day) }, day)))] }), _jsx("div", { className: "relative", children: _jsxs("div", { className: "grid", style: { + gridTemplateColumns: `4rem repeat(${days.length}, minmax(40px, 1fr))` + }, children: [timeSteps.map((step) => (_jsxs(React.Fragment, { children: [_jsx("div", { className: "text-[10px] text-gray-400 flex items-center justify-end pr-2 h-8", children: step % 120 === 0 ? formatTimeLabel(step) : '' }), days.map((day) => { + const sleeping = isSleepingInInterval(day, step, step + 60); + return (_jsx("div", { className: `h-8 w-full ${sleeping ? 'bg-green-500' : 'bg-gray-50'}` }, `${day}-${step}`)); + })] }, step))), _jsx("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%)' + } })] }) })] })] })); +}; +export default Timetable; diff --git a/frontend/src/components/stats/SleepTimetable.test.js b/frontend/src/components/stats/SleepTimetable.test.js new file mode 100644 index 0000000..0801e94 --- /dev/null +++ b/frontend/src/components/stats/SleepTimetable.test.js @@ -0,0 +1,89 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { vi, describe, it, expect } from 'vitest'; +import Timetable from './SleepTimetable'; +import { useStats } from '../../hooks/useStats'; +vi.mock('../../hooks/useStats', () => ({ + useStats: vi.fn(), +})); +describe('SleepTimetable', () => { + const defaultProps = { + from_date: '2025-04-20', + to_date: '2025-04-21', + day_start: '22:00', + day_end: '08:00', + }; + it('renders loading state', () => { + useStats.mockReturnValue({ timetable: null, isLoading: true }); + render(_jsx(Timetable, { ...defaultProps })); + expect(screen.getByText(/Loading timetable.../i)).toBeInTheDocument(); + }); + it('renders empty state when no data is available', () => { + useStats.mockReturnValue({ timetable: { slots: [] }, isLoading: false }); + render(_jsx(Timetable, { ...defaultProps })); + expect(screen.getByText(/No data available/i)).toBeInTheDocument(); + }); + it('renders the grid correctly with hourly rows and biennial labels', () => { + useStats.mockReturnValue({ + timetable: { + slots: [ + { + date: '2025-04-20', + sleeping: true, + slot_start: '01:00', + slot_end: '05:00', + }, + ], + }, + isLoading: false, + }); + render(_jsx(Timetable, { ...defaultProps })); + // Check for day headers + expect(screen.getByText(/SUN/i)).toBeInTheDocument(); // 2025-04-20 is Sunday + expect(screen.getByText(/MON/i)).toBeInTheDocument(); // 2025-04-21 is Monday + // Check for time labels (every 2 hours: 00, 02, 04...) + expect(screen.getByText('00:00')).toBeInTheDocument(); + expect(screen.getByText('02:00')).toBeInTheDocument(); + expect(screen.getByText('04:00')).toBeInTheDocument(); + expect(screen.getByText('22:00')).toBeInTheDocument(); + // Verify non-labeled hours (every hour that isn't %120 == 0) + // We can't easily check for empty text nodes in Vitest/RTL without more precision + // but we can check that 01:00 is NOT present as a text label + expect(screen.queryByText('01:00')).not.toBeInTheDocument(); + }); + it('colors sleeping intervals correctly', () => { + useStats.mockReturnValue({ + timetable: { + slots: [ + { + date: '2025-04-20', + sleeping: true, + slot_start: '01:00', + slot_end: '03:00', // Covers 01:00 and 02:00 + }, + ], + }, + isLoading: false, + }); + const { container } = render(_jsx(Timetable, { ...defaultProps })); + // We are looking for the grid cells. + // Since they don't have text, we look at their classes/backgrounds. + const cells = container.querySelectorAll('.h-8'); + // Total cells = 24 rows * (1 label col + 2 days) = 72? No, the structure is: + // Header row has cells. Time grid rows have (1 label + 2 days) = 3 cells each. + // Total elements with .h-8: 1 (header corner) + days.length * 1 (day headers) + 24 * 3 = 75? + // Actually, the code has: + // Header row: header corner + days.map -> 1 + 2 = 3 cells + // Time Rows: (label cell) + days.map -> 24 * 3 = 72 cells + // Total = 75. + // The sleeping cell for 01:00 on 2025-04-20 should be bg-green-500 + // The cell for 02:00 on 2025-04-20 should be bg-green-500 + // The cell for 03:00 on 2025-04-20 should be bg-gray-50 + // Let's just check if ANY cell has bg-green-500 and ALL non-sleeping have bg-gray-50 + const greenCells = container.querySelectorAll('.bg-green-500'); + expect(greenCells.length).toBeGreaterThan(0); + const grayCells = container.querySelectorAll('.bg-gray-50'); + expect(grayCells.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/components/stats/SleepTimetable.test.tsx b/frontend/src/components/stats/SleepTimetable.test.tsx index 1a95f16..c18bbf0 100644 --- a/frontend/src/components/stats/SleepTimetable.test.tsx +++ b/frontend/src/components/stats/SleepTimetable.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; import { vi, describe, it, expect } from 'vitest'; import Timetable from './SleepTimetable'; import { useStats } from '../../hooks/useStats'; diff --git a/frontend/src/components/stats/StatsPage.js b/frontend/src/components/stats/StatsPage.js new file mode 100644 index 0000000..8bf21b7 --- /dev/null +++ b/frontend/src/components/stats/StatsPage.js @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..63b96c3 --- /dev/null +++ b/frontend/src/components/stats/WakeupPlot.js @@ -0,0 +1,36 @@ +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 ?? 0, 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/src/components/stats/WakeupPlot.tsx b/frontend/src/components/stats/WakeupPlot.tsx index 923ac83..46a98e6 100644 --- a/frontend/src/components/stats/WakeupPlot.tsx +++ b/frontend/src/components/stats/WakeupPlot.tsx @@ -70,11 +70,11 @@ const WakeupPlot: React.FC = ({ from_date, to_date, wakeup_r domain={['dataMin - 30', 'dataMax + 30']} /> [ - formatMinutesAfter(value, wakeup_ref), - 'Wakeup', - ]} - labelFormatter={(label) => label} + formatter={(value: any) => [ + formatMinutesAfter(value ?? 0, wakeup_ref), + 'Wakeup', + ] as any} + labelFormatter={(label) => label} />