bugfixes and some redesign

This commit is contained in:
2026-04-26 17:57:05 +02:00
parent 8ba22fc4a0
commit 2fd00013f1
36 changed files with 1133 additions and 89 deletions
+8
View File
@@ -21,6 +21,7 @@
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.0",
"@types/lodash": "^4.17.24",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
@@ -1939,6 +1940,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+1
View File
@@ -25,6 +25,7 @@
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.0",
"@types/lodash": "^4.17.24",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
+9
View File
@@ -0,0 +1,9 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import TrackerPage from './components/tracker/TrackerPage';
import StatsPage from './components/stats/StatsPage';
import Navbar from './components/Navbar';
function App() {
return (_jsx(Router, { children: _jsxs("div", { className: "min-h-screen bg-[#f8fafc] text-slate-900 font-sans antialiased", children: [_jsx(Navbar, {}), _jsx("main", { className: "container mx-auto px-4 sm:px-6 lg:px-8 mt-8", children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(TrackerPage, {}) }), _jsx(Route, { path: "/stats", element: _jsx(StatsPage, {}) })] }) })] }) }));
}
export default App;
+2 -2
View File
@@ -7,9 +7,9 @@ import Navbar from './components/Navbar';
function App() {
return (
<Router>
<div className="min-h-screen bg-gray-100 text-gray-900">
<div className="min-h-screen bg-[#f8fafc] text-slate-900 font-sans antialiased">
<Navbar />
<main className="container mx-auto mt-8">
<main className="container mx-auto px-4 sm:px-6 lg:px-8 mt-8">
<Routes>
<Route path="/" element={<TrackerPage />} />
<Route path="/stats" element={<StatsPage />} />
+96
View File
@@ -0,0 +1,96 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
export function localToUTC(localValue) {
return localValue;
}
export async function fetchActiveSession() {
const response = await fetch(`${BASE_URL}/api/sessions/active`);
if (!response.ok)
throw new Error('Failed to fetch active session');
const data = await response.json();
return data.session;
}
export async function fetchSessions(from_date, to_date, limit = 200) {
const params = new URLSearchParams();
if (from_date)
params.append('from_date', from_date);
if (to_date)
params.append('to_date', to_date);
if (limit)
params.append('limit', limit.toString());
const response = await fetch(`${BASE_URL}/api/sessions?${params.toString()}`);
if (!response.ok)
throw new Error('Failed to fetch sessions');
return response.json();
}
export async function createSession(payload) {
const utcTime = localToUTC(payload.start_time);
const response = await fetch(`${BASE_URL}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_time: utcTime }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to create session');
}
return response.json();
}
export async function endSession(sessionId, payload) {
const utcTime = payload.end_time ? localToUTC(payload.end_time) : undefined;
const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}/end`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ end_time: utcTime }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to end session');
}
return response.json();
}
export async function updateSession(sessionId, payload) {
const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to update session');
}
return response.json();
}
export async function deleteSession(sessionId) {
const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, {
method: 'DELETE',
});
if (!response.ok)
throw new Error('Failed to delete session');
}
export async function fetchDailySummary(from_date, to_date, day_start, day_end) {
const fromDT = from_date + 'T00:00:00';
const toDT = to_date + 'T23:59:59';
const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, day_start, day_end });
const response = await fetch(`${BASE_URL}/api/stats/daily-summary?${params.toString()}`);
if (!response.ok)
throw new Error('Failed to fetch daily summary');
return response.json();
}
export async function fetchTimetable(from_date, to_date, day_start, day_end) {
const fromDT = from_date + 'T00:00:00';
const toDT = to_date + 'T23:59:59';
const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, day_start, day_end });
const response = await fetch(`${BASE_URL}/api/stats/timetable?${params.toString()}`);
if (!response.ok)
throw new Error('Failed to fetch timetable');
return response.json();
}
export async function fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref) {
const fromDT = from_date + 'T00:00:00';
const toDT = to_date + 'T23:59:59';
const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, wakeup_ref, bedtime_ref });
const response = await fetch(`${BASE_URL}/api/stats/wake-sleep-times?${params.toString()}`);
if (!response.ok)
throw new Error('Failed to fetch wake/sleep times');
return response.json();
}
+20
View File
@@ -0,0 +1,20 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Component } from 'react';
export class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error("ErrorBoundary caught an error", error, errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return (this.props.fallback || (_jsxs("div", { className: "bg-red-50 border border-red-200 rounded-lg p-4 text-center", children: [_jsx("p", { className: "text-red-600 font-medium", children: "Failed to load this component" }), _jsx("button", { onClick: () => this.setState({ hasError: false }), className: "mt-2 text-sm text-red-500 hover:underline", children: "Try again" })] })));
}
return this.props.children;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Link, useLocation } from 'react-router-dom';
const Navbar = () => {
const location = useLocation();
// Helper to check if a path is active
const isActive = (path) => location.pathname === path;
return (_jsx("nav", { className: "bg-white/80 backdrop-blur-md sticky top-0 z-50 border-b border-gray-100", children: _jsx("div", { className: "max-w-md mx-auto px-4 flex justify-center items-center h-16", children: _jsxs("div", { className: "flex space-x-2 bg-gray-100 p-1 rounded-xl", children: [_jsx(Link, { to: "/", className: `px-6 py-2 rounded-lg text-sm font-bold transition-all duration-200 ${isActive('/') ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`, children: "Tracker" }), _jsx(Link, { to: "/stats", className: `px-6 py-2 rounded-lg text-sm font-bold transition-all duration-200 ${isActive('/stats') ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`, children: "Stats" })] }) }) }));
};
export default Navbar;
+21 -17
View File
@@ -8,23 +8,27 @@ const Navbar: React.FC = () => {
const isActive = (path: string) => location.pathname === path;
return (
<nav className="bg-white shadow-md p-4 flex justify-center space-x-8">
<Link
to="/"
className={`font-bold transition-colors duration-200 ${
isActive('/') ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-600 hover:text-blue-500'
}`}
>
Tracker
</Link>
<Link
to="/stats"
className={`font-bold transition-colors duration-200 ${
isActive('/stats') ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-600 hover:text-blue-500'
}`}
>
Stats
</Link>
<nav className="bg-white/80 backdrop-blur-md sticky top-0 z-50 border-b border-gray-100">
<div className="max-w-md mx-auto px-4 flex justify-center items-center h-16">
<div className="flex space-x-2 bg-gray-100 p-1 rounded-xl">
<Link
to="/"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all duration-200 ${
isActive('/') ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
Tracker
</Link>
<Link
to="/stats"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all duration-200 ${
isActive('/stats') ? 'bg-white text-indigo-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
Stats
</Link>
</div>
</div>
</nav>
);
};
@@ -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) => [
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;
@@ -0,0 +1,48 @@
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;
@@ -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))) }));
}
@@ -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))) })] }));
}
+106 -19
View File
@@ -1,5 +1,7 @@
import React from 'react';
import React, { useState } from 'react';
import { useSessions } from '../../hooks/useSleepSession';
import { Edit2, Check, X } from 'lucide-react';
import type { SleepSessionRead } from '../../types';
function formatDateTime(isoString: string): string {
const iso = isoString.endsWith('Z') ? isoString : isoString + 'Z';
@@ -14,36 +16,121 @@ function formatDateTime(isoString: string): string {
}).replace(',', ' ');
}
function toDatetimeLocal(isoString: string): string {
const iso = isoString.endsWith('Z') ? isoString : isoString + 'Z';
const d = new Date(iso);
const pad = (n: number) => 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 } = useSessions();
const { sessions, isLoading, updateSession } = useSessions();
const [editingId, setEditingId] = useState<number | null>(null);
const [editStart, setEditStart] = useState('');
const [editEnd, setEditEnd] = useState('');
if (isLoading) return <div className="text-center p-4">Loading sessions...</div>;
if (!sessions.length) return <div className="text-center p-4 text-gray-500">No sessions recorded.</div>;
const handleSave = async (id: number) => {
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: SleepSessionRead) => {
setEditingId(s.id);
setEditStart(toDatetimeLocal(s.start_time));
setEditEnd(s.end_time ? toDatetimeLocal(s.end_time) : '');
};
return (
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
<div className="space-y-2">
{sessions.map((s) => (
<div key={s.id} className="flex justify-between items-center text-sm border-b border-gray-100 pb-2">
<div className="flex flex-col">
<span className="text-gray-900 font-medium">
{formatDateTime(s.start_time)}
</span>
<span className="text-gray-500 text-xs">
{s.end_time
? `${formatDateTime(s.start_time)} → ${formatDateTime(s.end_time)}`
: 'In progress'}
</span>
</div>
<span className="text-blue-600 font-mono text-sm">
{s.end_time && s.start_time
? `${Math.round((new Date(s.end_time + 'Z').getTime() - new Date(s.start_time + 'Z').getTime()) / 60000)} min`
: '—'}
</span>
<div key={s.id} className="border-b border-gray-100 last:border-0 pb-2">
{editingId === s.id ? (
<div className="flex flex-col space-y-2 py-1">
<div className="grid grid-cols-1 gap-2 text-xs text-gray-500">
<div className="flex flex-col gap-1">
<span>Start Time</span>
<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)}
/>
</div>
<div className="flex flex-col gap-1">
<span>End Time</span>
<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)}
/>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
onClick={handleCancel}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Cancel"
>
<X size={18} />
</button>
<button
onClick={() => handleSave(s.id)}
className="p-1 text-green-600 hover:text-green-700 transition-colors"
title="Save"
>
<Check size={18} />
</button>
</div>
</div>
) : (
<div
className="flex justify-between items-center text-sm cursor-pointer hover:bg-gray-50 p-1 rounded transition-colors"
onClick={() => startEditing(s)}
>
<div className="flex flex-col">
<span className="text-gray-900 font-medium">
{formatDateTime(s.start_time)}
</span>
<span className="text-gray-500 text-xs">
{s.end_time
? `${formatDateTime(s.start_time)} → ${formatDateTime(s.end_time)}`
: 'In progress'}
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-blue-600 font-mono text-sm">
{s.end_time && s.start_time
? `${Math.round((new Date(s.end_time + 'Z').getTime() - new Date(s.start_time + 'Z').getTime()) / 60000)} min`
: '—'}
</span>
<Edit2 size={14} className="text-gray-400" />
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}
}
@@ -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" })] }))] }));
}
@@ -0,0 +1,43 @@
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,6 +1,6 @@
import React from 'react';
import { useStats } from '../../hooks/useStats';
import { timeToMinutes } from '../../utils/time';
import { timeToMinutes, formatMinutesAfter } from '../../utils/time';
interface TimetableProps {
from_date: string;
@@ -17,6 +17,11 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
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;
// 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] = [];
@@ -28,29 +33,53 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
<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).map((date) => (
<div key={date} className="mb-6 last:mb-0">
{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="flex w-full h-8 gap-1 bg-gray-50 p-1 rounded-lg overflow-hidden">
{groupedSlots[date].map((slot: any) => {
const duration = Math.max(1, timeToMinutes(slot.slot_end) - timeToMinutes(slot.slot_start));
<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;
return (
<div
key={`${date}-${slot.slot_start}`}
className={`h-full rounded-sm transition-all duration-300 cursor-help ${
slot.sleeping ? 'bg-blue-900' : 'bg-gray-200'
}`}
style={{ flex: duration }}
title={`${slot.slot_start} - ${slot.slot_end} (${duration}m)`}
>
{/* 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">
{duration > 40 && <span className="text-white/70">{slot.slot_start}</span>}
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 (
<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>
</div>
);
})}
);
})}
</div>
{/* 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>
</div>
))}
@@ -60,3 +89,4 @@ const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, da
};
export default Timetable;
@@ -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;
+13 -8
View File
@@ -16,9 +16,12 @@ const StatsPage: React.FC = () => {
const to_date = new Date().toISOString().split('T')[0];
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-800">Sleep Statistics</h2>
<div className="max-w-4xl mx-auto space-y-8 pb-12">
<div className="flex justify-between items-center pt-4">
<div>
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Statistics</h2>
<p className="text-slate-500 text-sm mt-1">Overview of sleep patterns and trends</p>
</div>
<SettingsPopover
dayStart={settings.dayStart}
dayEnd={settings.dayEnd}
@@ -33,10 +36,12 @@ const StatsPage: React.FC = () => {
/>
</div >
<IntervalPicker
value={settings.statsInterval}
onChange={(days) => update('statsInterval', days)}
/>
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
<IntervalPicker
value={settings.statsInterval}
onChange={(days) => update('statsInterval', days)}
/>
</div>
<ErrorBoundary>
<DailySummary
@@ -47,7 +52,7 @@ const StatsPage: React.FC = () => {
/>
</ErrorBoundary>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ErrorBoundary>
<WakeupPlot
from_date={from_date}
@@ -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, 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;
@@ -0,0 +1,23 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useSleepSession } from '../../hooks/useSleepSession';
const SleepButton = ({ time }) => {
const { activeSession, createSession, endSession, isCreating, isEnding } = useSleepSession();
const isSleeping = !!activeSession;
const handleClick = async () => {
try {
if (isSleeping && activeSession?.id) {
await endSession({ sessionId: activeSession.id, endTime: time });
}
else {
await createSession({ start_time: time });
}
}
catch (error) {
console.error('Failed to update sleep session:', error?.message || error);
}
};
return (_jsx("button", { onClick: handleClick, disabled: isCreating || isEnding, className: `w-full py-8 px-8 rounded-3xl text-2xl font-extrabold shadow-xl transition-all active:scale-95 flex items-center justify-center ${isSleeping
? 'bg-rose-500 text-white hover:bg-rose-600 shadow-rose-200'
: 'bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200'} ${isCreating || isEnding ? 'opacity-50 cursor-not-allowed' : ''}`, children: isCreating || isEnding ? (_jsxs("div", { className: "flex items-center space-x-2", children: [_jsx("span", { className: "animate-spin rounded-full h-6 w-6 border-b-2 border-white" }), _jsx("span", { children: "Processing..." })] })) : isSleeping ? ('Stop Sleep') : ('Start Sleep') }));
};
export default SleepButton;
@@ -25,14 +25,17 @@ const SleepButton: React.FC<SleepButtonProps> = ({ time }) => {
<button
onClick={handleClick}
disabled={isCreating || isEnding}
className={`w-full py-6 px-8 rounded-2xl text-2xl font-bold shadow-lg transition-all active:scale-95 ${
className={`w-full py-8 px-8 rounded-3xl text-2xl font-extrabold shadow-xl transition-all active:scale-95 flex items-center justify-center ${
isSleeping
? 'bg-amber-500 text-white hover:bg-amber-600'
: 'bg-blue-700 text-white hover:bg-blue-800'
? 'bg-rose-500 text-white hover:bg-rose-600 shadow-rose-200'
: 'bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200'
} ${isCreating || isEnding ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isCreating || isEnding ? (
<span className="animate-pulse">Processing...</span>
<div className="flex items-center space-x-2">
<span className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></span>
<span>Processing...</span>
</div>
) : isSleeping ? (
'Stop Sleep'
) : (
@@ -0,0 +1,27 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect } from 'react';
import { useSleepSession } from '../../hooks/useSleepSession';
import { formatElapsedTime } from '../../utils/time';
const StatusBanner = () => {
const { activeSession, isLoading } = useSleepSession();
const [elapsed, setElapsed] = useState('');
useEffect(() => {
if (!activeSession) {
setElapsed('');
return;
}
const updateElapsed = () => {
setElapsed(formatElapsedTime(activeSession.start_time));
};
updateElapsed();
const interval = setInterval(updateElapsed, 1000);
return () => clearInterval(interval);
}, [activeSession?.start_time]);
if (isLoading)
return _jsx("div", { className: "p-4 text-center", children: "Loading..." });
if (activeSession) {
return (_jsxs("div", { className: "bg-amber-50 border border-amber-200 text-amber-900 p-4 rounded-2xl shadow-sm mb-4 flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center space-x-3", children: [_jsx("span", { className: "text-2xl", children: "\uD83D\uDE34" }), _jsxs("div", { children: [_jsx("p", { className: "font-semibold leading-tight", children: "Baby is sleeping" }), elapsed && _jsxs("p", { className: "text-sm text-amber-700 font-mono", children: ["(", elapsed, ")"] })] })] }), _jsx("div", { className: "h-2 w-2 bg-amber-500 rounded-full animate-pulse" })] }));
}
return (_jsx("div", { className: "bg-blue-50 border border-blue-200 text-blue-900 p-4 rounded-2xl shadow-sm mb-4 flex items-center justify-between", children: _jsxs("div", { className: "flex items-center space-x-3", children: [_jsx("span", { className: "text-2xl", children: "\uD83D\uDC76" }), _jsx("div", { children: _jsx("p", { className: "font-semibold leading-tight", children: "Baby is awake" }) })] }) }));
};
export default StatusBanner;
@@ -25,16 +25,27 @@ const StatusBanner = () => {
if (activeSession) {
return (
<div className="bg-amber-100 text-amber-800 p-4 rounded-lg shadow-sm mb-4 flex items-center justify-center space-x-2">
<span>Baby is sleeping 😴</span>
{elapsed && <span className="font-mono">({elapsed})</span>}
<div className="bg-amber-50 border border-amber-200 text-amber-900 p-4 rounded-2xl shadow-sm mb-4 flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-2xl">😴</span>
<div>
<p className="font-semibold leading-tight">Baby is sleeping</p>
{elapsed && <p className="text-sm text-amber-700 font-mono">({elapsed})</p>}
</div>
</div>
<div className="h-2 w-2 bg-amber-500 rounded-full animate-pulse" />
</div>
);
}
return (
<div className="bg-blue-100 text-blue-800 p-4 rounded-lg shadow-sm mb-4 flex items-center justify-center space-x-2">
<span>Baby is awake 👶</span>
<div className="bg-blue-50 border border-blue-200 text-blue-900 p-4 rounded-2xl shadow-sm mb-4 flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="text-2xl">👶</span>
<div>
<p className="font-semibold leading-tight">Baby is awake</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,32 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect } from 'react';
function toLocalDatetimeInputValue(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function toUTCFromLocalInput(inputValue) {
const [datePart, timePart] = inputValue.split('T');
const [year, month, day] = datePart.split('-').map(Number);
const [hours, minutes] = timePart.split(':').map(Number);
const localDate = new Date(year, month - 1, day, hours, minutes);
return localDate.toISOString();
}
const TimeAdjuster = ({ onTimeChange, defaultValue }) => {
const [value, setValue] = useState(() => {
if (defaultValue) {
const d = new Date(defaultValue);
return toLocalDatetimeInputValue(d);
}
return toLocalDatetimeInputValue(new Date());
});
useEffect(() => {
onTimeChange(toUTCFromLocalInput(value));
}, [value, onTimeChange]);
const resetToNow = () => {
const now = toLocalDatetimeInputValue(new Date());
setValue(now);
onTimeChange(toUTCFromLocalInput(now));
};
return (_jsxs("div", { className: "flex flex-col items-center space-y-3 w-full", children: [_jsx("div", { className: "relative w-full max-w-[280px]", children: _jsx("input", { type: "datetime-local", value: value, onChange: (e) => setValue(e.target.value), className: "w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-lg font-medium transition-all outline-none" }) }), _jsx("button", { onClick: resetToNow, className: "text-sm font-medium text-indigo-600 hover:text-indigo-500 transition-colors", children: "Reset to now" })] }));
};
export default TimeAdjuster;
@@ -38,16 +38,18 @@ const TimeAdjuster: React.FC<TimeAdjusterProps> = ({ onTimeChange, defaultValue
};
return (
<div className="flex flex-col items-center space-y-2">
<input
type="datetime-local"
value={value}
onChange={(e) => setValue(e.target.value)}
className="p-3 border rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-lg"
/>
<div className="flex flex-col items-center space-y-3 w-full">
<div className="relative w-full max-w-[280px]">
<input
type="datetime-local"
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-lg font-medium transition-all outline-none"
/>
</div>
<button
onClick={resetToNow}
className="text-sm text-blue-600 hover:underline"
className="text-sm font-medium text-indigo-600 hover:text-indigo-500 transition-colors"
>
Reset to now
</button>
@@ -0,0 +1,15 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import StatusBanner from './StatusBanner';
import SleepButton from './SleepButton';
import TimeAdjuster from './TimeAdjuster';
const TrackerPage = () => {
const getLocalTime = () => {
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
};
const [time, setTime] = useState(getLocalTime());
return (_jsxs("div", { className: "max-w-md mx-auto pb-12", children: [_jsx(StatusBanner, {}), _jsxs("div", { className: "bg-white p-8 rounded-[2.5rem] shadow-xl border border-gray-100 space-y-8", children: [_jsxs("div", { className: "space-y-4 text-center", children: [_jsx("h2", { className: "text-xl font-bold text-gray-800", children: "Set Sleep Time" }), _jsx("p", { className: "text-gray-500 text-sm", children: "Adjust the time if you are logging a session that already started." })] }), _jsx("div", { className: "pt-4", children: _jsx(TimeAdjuster, { onTimeChange: setTime, defaultValue: time }) }), _jsx("div", { className: "pt-4", children: _jsx(SleepButton, { time: time }) })] })] }));
};
export default TrackerPage;
@@ -13,11 +13,19 @@ const TrackerPage: React.FC = () => {
const [time, setTime] = useState<string>(getLocalTime());
return (
<div className="max-w-md mx-auto space-y-6">
<div className="max-w-md mx-auto pb-12">
<StatusBanner />
<div className="bg-white p-6 rounded-2xl shadow-lg">
<TimeAdjuster onTimeChange={setTime} defaultValue={time} />
<div className="mt-6">
<div className="bg-white p-8 rounded-[2.5rem] shadow-xl border border-gray-100 space-y-8">
<div className="space-y-4 text-center">
<h2 className="text-xl font-bold text-gray-800">Set Sleep Time</h2>
<p className="text-gray-500 text-sm">Adjust the time if you are logging a session that already started.</p>
</div>
<div className="pt-4">
<TimeAdjuster onTimeChange={setTime} defaultValue={time} />
</div>
<div className="pt-4">
<SleepButton time={time} />
</div>
</div>
+32
View File
@@ -0,0 +1,32 @@
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'baby-sleep-settings';
const DEFAULT_SETTINGS = {
dayStart: '07:00',
dayEnd: '20:00',
wakeupRef: '04:00',
bedtimeRef: '19:00',
statsInterval: 7,
};
export function useSettings() {
const [settings, setSettings] = useState(() => {
if (typeof window === 'undefined')
return DEFAULT_SETTINGS;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
}
catch {
return DEFAULT_SETTINGS;
}
}
return DEFAULT_SETTINGS;
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings]);
const update = (key, value) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
return { settings, update };
}
+60
View File
@@ -0,0 +1,60 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchActiveSession, createSession, endSession, fetchSessions, deleteSession, updateSession } from '../api/client';
export function useSleepSession() {
const queryClient = useQueryClient();
const activeSessionQuery = useQuery({
queryKey: ['activeSession'],
queryFn: fetchActiveSession,
refetchInterval: 30000, // Poll every 30 seconds
});
const createMutation = useMutation({
mutationFn: createSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activeSession'] });
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
const endMutation = useMutation({
mutationFn: ({ sessionId, endTime }) => endSession(sessionId, { end_time: endTime }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activeSession'] });
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
return {
activeSession: activeSessionQuery.data,
isLoading: activeSessionQuery.isLoading,
isFetching: activeSessionQuery.isFetching,
createSession: createMutation.mutateAsync,
endSession: endMutation.mutateAsync,
isCreating: createMutation.isPending,
isEnding: endMutation.isPending,
};
}
export function useSessions() {
const queryClient = useQueryClient();
const sessionsQuery = useQuery({
queryKey: ['sessions'],
queryFn: () => fetchSessions(),
});
const deleteMutation = useMutation({
mutationFn: deleteSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
const updateMutation = useMutation({
mutationFn: ({ sessionId, payload }) => updateSession(sessionId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
return {
sessions: sessionsQuery.data?.sessions || [],
total: sessionsQuery.data?.total || 0,
isLoading: sessionsQuery.isLoading,
isDeleting: deleteMutation.isPending,
deleteSession: deleteMutation.mutateAsync,
updateSession: updateMutation.mutateAsync,
};
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes } from '../api/client';
export function useStats(from_date, to_date, day_start = '07:00', day_end = '20:00', wakeup_ref = '04:00', bedtime_ref = '19:00') {
const dailySummaryQuery = useQuery({
queryKey: ['dailySummary', from_date, to_date, day_start, day_end],
queryFn: () => fetchDailySummary(from_date, to_date, day_start, day_end),
});
const timetableQuery = useQuery({
queryKey: ['timetable', from_date, to_date, day_start, day_end],
queryFn: () => fetchTimetable(from_date, to_date, day_start, day_end),
});
const wakeupBedtimeQuery = useQuery({
queryKey: ['wakeSleepTimes', from_date, to_date, wakeup_ref, bedtime_ref],
queryFn: () => fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref),
});
return {
dailySummary: dailySummaryQuery.data,
timetable: timetableQuery.data,
wakeupBedtime: wakeupBedtimeQuery.data,
isLoading: dailySummaryQuery.isLoading || timetableQuery.isLoading || wakeupBedtimeQuery.isLoading,
};
}
+8
View File
@@ -0,0 +1,8 @@
import { jsx as _jsx } from "react/jsx-runtime";
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')).render(_jsx(React.StrictMode, { children: _jsx(QueryClientProvider, { client: queryClient, children: _jsx(App, {}) }) }));
+1
View File
@@ -0,0 +1 @@
export {};
+109
View File
@@ -0,0 +1,109 @@
/**
* Chart data transformation utilities for baby sleep tracker
*/
/**
* Build timetable slots for visualization
* @param slots - raw timetable slots from API
* @returns grouped slots by date
*/
export function buildTimetableSlots(slots) {
return slots.reduce((acc, slot) => {
if (!acc[slot.date]) {
acc[slot.date] = [];
}
acc[slot.date].push(slot);
return acc;
}, {});
}
/**
* Transform wakeup minutes for Recharts LineChart
* @param days - wakeup/bedtime data from API
* @returns array formatted for LineChart
*/
export function computeWakeupMinutes(days, wakeupRef = "04:00") {
return days.map((day) => ({
date: day.date,
minutes: day.wakeup_minutes ?? 0,
time: day.wakeup_minutes !== null
? formatMinutesAfter(day.wakeup_minutes, wakeupRef)
: "N/A",
}));
}
/**
* Transform bedtime minutes for Recharts LineChart
* @param days - wakeup/bedtime data from API
* @returns array formatted for LineChart
*/
export function computeBedtimeMinutes(days, bedtimeRef = "19:00") {
return days.map((day) => ({
date: day.date,
minutes: day.bedtime_minutes ?? 0,
time: day.bedtime_minutes !== null
? formatMinutesAfter(day.bedtime_minutes, bedtimeRef)
: "N/A",
}));
}
/**
* Transform daily summary for Recharts BarChart (vertical layout)
* @param days - daily summary data from API
* @returns array formatted for BarChart
*/
export function computeDailySummaryData(days) {
return days.map((day) => ({
date: day.date,
daySleep: day.day_minutes,
nightSleep: day.night_minutes,
total: day.total_minutes,
}));
}
/**
* Calculate average summary stats from daily data
* @param days - daily summary data from API
* @returns averages for total, day, and night sleep
*/
export function computeAverageStats(days) {
if (!days.length) {
return { avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 };
}
const totals = days.reduce((acc, day) => ({
total: acc.total + day.total_minutes,
day: acc.day + day.day_minutes,
night: acc.night + day.night_minutes,
}), { total: 0, day: 0, night: 0 });
const count = days.length;
return {
avgTotal: Math.round(totals.total / count),
avgDay: Math.round(totals.day / count),
avgNight: Math.round(totals.night / count),
count,
};
}
/**
* Convert minutes after reference to clock time string
* Internal helper - kept for chartHelpers module
*/
function formatMinutesAfter(minutes, reference) {
const [refHours, refMinutes] = reference.split(":").map(Number);
const totalRefMinutes = refHours * 60 + refMinutes;
const targetMinutes = totalRefMinutes + minutes;
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
const hours = Math.floor(normalizedMinutes / 60);
const mins = normalizedMinutes % 60;
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
}
/**
* Reverse date order (newest first for charts)
* @param days - array of day data
* @returns reversed array
*/
export function reverseDateOrder(days) {
return [...days].reverse();
}
/**
* Filter days to only include those with data
* @param days - array of day data
* @returns filtered array
*/
export function filterDaysWithData(days) {
return days.filter((day) => (day.total_minutes ?? 0) > 0);
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Time formatting utilities for baby sleep tracker
*/
/**
* Convert minutes after a reference time to clock time string
* @param minutes - minutes after reference time (can be negative)
* @param reference - reference time as HH:MM string
* @returns formatted time string like "07:15"
*/
export function formatMinutesAfter(minutes, reference = "04:00") {
if (minutes === null)
return "N/A";
const [refHours, refMinutes] = reference.split(":").map(Number);
const totalRefMinutes = refHours * 60 + refMinutes;
const targetMinutes = totalRefMinutes + minutes;
// Handle wrap around midnight
const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440;
const hours = Math.floor(normalizedMinutes / 60);
const mins = normalizedMinutes % 60;
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
}
/**
* Convert a Date to datetime-local input value format (local time)
* @param date - Date object
* @returns string in "YYYY-MM-DDTHH:MM" format
*/
export function toLocalDatetimeInputValue(date) {
const offset = date.getTimezoneOffset() * 60 * 1000;
const localDate = new Date(date.getTime() - offset);
return localDate.toISOString().slice(0, 16);
}
export function formatElapsedTime(utcTimestamp) {
let isoString = utcTimestamp;
if (utcTimestamp.includes(' ')) {
isoString = utcTimestamp.replace(' ', 'T');
}
if (!isoString.endsWith('Z')) {
isoString += 'Z';
}
const then = new Date(isoString);
const now = new Date();
const diffMs = now.getTime() - then.getTime();
const diffMins = Math.floor(diffMs / 60000);
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
if (hours > 0)
return `${hours}h ${mins}m`;
return `${mins}m`;
}
/**
* Clamp a date to day boundaries (start or end of day)
* @param date - Date to clamp
* @param bound - 'start' for 00:00, 'end' for 23:59:59.999
* @returns new Date clamped to day boundary
*/
export function clampToDay(date, bound) {
const result = new Date(date);
if (bound === "start") {
result.setHours(0, 0, 0, 0);
}
else {
result.setHours(23, 59, 59, 999);
}
return result;
}
/**
* Parse HH:MM time string to Date on a given day
* @param timeStr - HH:MM format
* @param baseDate - base date (defaults to today)
* @returns Date object
* @throws Error if timeStr is invalid
*/
export function parseTime(timeStr, baseDate = new Date()) {
const [hours, minutes] = timeStr.split(":").map(Number);
const result = new Date(baseDate);
result.setHours(hours, minutes, 0, 0);
return result;
}
/**
* Get date string for N days ago
* @param days - number of days ago
* @returns ISO date string (YYYY-MM-DD)
*/
export function getDaysAgo(days) {
const date = new Date();
date.setDate(date.getDate() - days);
return date.toISOString().split("T")[0];
}
/**
* Get date string for today
* @returns ISO date string (YYYY-MM-DD)
*/
export function getToday() {
return new Date().toISOString().split("T")[0];
}
/**
* Convert HH:MM time string to total minutes from start of day
* @param timeStr - HH:MM format
* @returns total minutes
*/
export function timeToMinutes(timeStr) {
const [hours, minutes] = timeStr.split(":").map(Number);
return hours * 60 + minutes;
}
/**
* Convert total minutes to a human-readable string (e.g., "1h 30m" or "45m")
* @param totalMinutes - Total number of minutes
* @returns Formatted string
*/
export function formatMinutes(totalMinutes) {
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function formatElapsedTime(utcTimestamp: string): string {
}
const then = new Date(isoString);
const now = new Date();
const diffMs = now - then;
const diffMs = now.getTime() - then.getTime();
const diffMins = Math.floor(diffMs / 60000);
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client"]
},
"include": ["src"]
}