Initial commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
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 (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-100 text-gray-900">
|
||||
<Navbar />
|
||||
<main className="container mx-auto mt-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<TrackerPage />} />
|
||||
<Route path="/stats" element={<StatsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,16 @@
|
||||
# frontend/src/api
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
API client for backend communication.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `client.ts` | Fetch client setup |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- TanStack Query hooks for data fetching
|
||||
- Types from `../types`
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { SleepSessionCreate, SleepSessionUpdate, SleepSessionRead, SessionListResponse } from '@/types';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
export function localToUTC(localValue: string): string {
|
||||
return localValue;
|
||||
}
|
||||
|
||||
export async function fetchActiveSession(): Promise<SleepSessionRead | null> {
|
||||
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?: string, to_date?: string, limit: number = 200): Promise<SessionListResponse> {
|
||||
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: SleepSessionCreate): Promise<SleepSessionRead> {
|
||||
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: number, payload: SleepSessionUpdate): Promise<SleepSessionRead> {
|
||||
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: number, payload: SleepSessionUpdate): Promise<SleepSessionRead> {
|
||||
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: number): Promise<void> {
|
||||
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: string, to_date: string, day_start: string, day_end: string): Promise<any> {
|
||||
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: string, to_date: string, day_start: string, day_end: string): Promise<any> {
|
||||
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: string, to_date: string, wakeup_ref: string, bedtime_ref: string): Promise<any> {
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("ErrorBoundary caught an error", error, errorInfo.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback || (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||
<p className="text-red-600 font-medium">Failed to load this component</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
className="mt-2 text-sm text-red-500 hover:underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const location = useLocation();
|
||||
|
||||
// Helper to check if a path is active
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,21 @@
|
||||
# frontend/src/components/stats
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Stats visualization components: SessionList, StatsPage, DailySummary, BedtimePlot, WakeupPlot, SettingsPopover, IntervalPicker, SleepTimetable.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Component | Purpose |
|
||||
|----------|---------|
|
||||
| `StatsPage.tsx` | Main stats tab container |
|
||||
| `DailySummary.tsx` | Day vs. night sleep totals |
|
||||
| `SleepTimetable.tsx` | Continuous time-slot grid |
|
||||
| `BedtimePlot.tsx` | Bedtime trend chart |
|
||||
| `WakeupPlot.tsx` | Wakeup time trend chart |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- Uses Recharts for visualization
|
||||
- Tailwind v4 for styling (no custom CSS classes)
|
||||
- Lucide icons for UI elements
|
||||
@@ -0,0 +1,93 @@
|
||||
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';
|
||||
|
||||
interface WakeupBedtimeProps {
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
wakeup_ref: string;
|
||||
bedtime_ref: string;
|
||||
}
|
||||
|
||||
const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, bedtime_ref }) => {
|
||||
const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00');
|
||||
const [chartData, setChartData] = useState<{ date: string; minutes: number; time: string }[]>([]);
|
||||
|
||||
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 <div className="text-center p-4">Loading plot...</div>;
|
||||
if (!chartData.length) {
|
||||
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
|
||||
}
|
||||
|
||||
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">Bedtime</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
tickFormatter={(value) => formatMinutesAfter(value, bedtime_ref)}
|
||||
domain={['dataMin - 30', 'dataMax + 30']}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [
|
||||
formatMinutesAfter(value, bedtime_ref),
|
||||
'Bedtime',
|
||||
]}
|
||||
labelFormatter={(label) => label}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="minutes"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#8b5cf6', r: 4 }}
|
||||
connectNulls={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BedtimePlot;
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStats } from '../../hooks/useStats';
|
||||
import { formatMinutes } from '../../utils/time';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
interface DailySummaryProps {
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
day_start: string;
|
||||
day_end: string;
|
||||
}
|
||||
|
||||
interface ChartDataItem {
|
||||
date: string;
|
||||
daySleep: number;
|
||||
nightSleep: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const DailySummary: React.FC<DailySummaryProps> = ({ from_date, to_date, day_start, day_end }) => {
|
||||
const { dailySummary, isLoading } = useStats(from_date, to_date, day_start, day_end);
|
||||
const [chartData, setChartData] = useState<ChartDataItem[]>([]);
|
||||
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,
|
||||
nightSleep: day.night_minutes,
|
||||
total: day.total_minutes,
|
||||
}));
|
||||
|
||||
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: Math.round(totals.total / count),
|
||||
avgDay: Math.round(totals.day / count),
|
||||
avgNight: Math.round(totals.night / count),
|
||||
count,
|
||||
});
|
||||
}
|
||||
}, [dailySummary]);
|
||||
|
||||
if (isLoading) return <div className="text-center p-4">Loading summary...</div>;
|
||||
if (!chartData.length) {
|
||||
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
|
||||
}
|
||||
|
||||
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">Daily Summary</h3>
|
||||
<ResponsiveContainer width="100%" height={Math.max(200, chartData.length * 30)}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 60, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis type="number" tick={{ fontSize: 12 }} tickLine={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [
|
||||
formatMinutes(value),
|
||||
name === 'daySleep' ? 'Day' : 'Night',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="nightSleep" stackId="a" fill="#1e3a5f" />
|
||||
<Bar dataKey="daySleep" stackId="a" fill="#fbbf24" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs text-gray-500">Avg Total</div>
|
||||
<div className="text-lg font-semibold text-gray-800">{formatMinutes(avgStats.avgTotal)}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs text-gray-500">Avg Day</div>
|
||||
<div className="text-lg font-semibold text-gray-800">{formatMinutes(avgStats.avgDay)}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs text-gray-500">Avg Night</div>
|
||||
<div className="text-lg font-semibold text-gray-800">{formatMinutes(avgStats.avgNight)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailySummary;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
interface IntervalPickerProps {
|
||||
value: number;
|
||||
onChange: (days: number) => void;
|
||||
}
|
||||
|
||||
const INTERVALS = [7, 14, 30, 90];
|
||||
|
||||
export function IntervalPicker({ value, onChange }: IntervalPickerProps) {
|
||||
return (
|
||||
<div className="flex justify-center space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||
{INTERVALS.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{days}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { useSessions } from '../../hooks/useSleepSession';
|
||||
|
||||
function formatDateTime(isoString: string): string {
|
||||
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(',', ' ');
|
||||
}
|
||||
|
||||
export function SessionList() {
|
||||
const { sessions, isLoading } = useSessions();
|
||||
|
||||
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>;
|
||||
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface SettingsPopoverProps {
|
||||
dayStart: string;
|
||||
dayEnd: string;
|
||||
wakeupRef: string;
|
||||
bedtimeRef: string;
|
||||
onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string }) => void;
|
||||
}
|
||||
|
||||
export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSave }: SettingsPopoverProps) {
|
||||
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<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalDayStart(dayStart);
|
||||
setLocalDayEnd(dayEnd);
|
||||
setLocalWakeupRef(wakeupRef);
|
||||
setLocalBedtimeRef(bedtimeRef);
|
||||
}, [dayStart, dayEnd, wakeupRef, bedtimeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
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 (
|
||||
<div className="relative" ref={popoverRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<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">
|
||||
<h4 className="font-semibold text-gray-800 mb-4">Settings</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
|
||||
<input
|
||||
id="day-start"
|
||||
type="time"
|
||||
value={localDayStart}
|
||||
onChange={(e) => setLocalDayStart(e.target.value)}
|
||||
className="w-full p-2 border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
|
||||
<input
|
||||
id="day-end"
|
||||
type="time"
|
||||
value={localDayEnd}
|
||||
onChange={(e) => setLocalDayEnd(e.target.value)}
|
||||
className="w-full p-2 border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
|
||||
<input
|
||||
id="wakeup-ref"
|
||||
type="time"
|
||||
value={localWakeupRef}
|
||||
onChange={(e) => setLocalWakeupRef(e.target.value)}
|
||||
className="w-full p-2 border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
|
||||
<input
|
||||
id="bedtime-ref"
|
||||
type="time"
|
||||
value={localBedtimeRef}
|
||||
onChange={(e) => setLocalBedtimeRef(e.target.value)}
|
||||
className="w-full p-2 border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { useStats } from '../../hooks/useStats';
|
||||
|
||||
interface TimetableProps {
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
day_start: string;
|
||||
day_end: string;
|
||||
};
|
||||
|
||||
const Timetable: React.FC<TimetableProps> = ({ from_date, to_date, day_start, day_end }) => {
|
||||
const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end);
|
||||
|
||||
if (isLoading) return <div className="text-center p-4">Loading timetable...</div>;
|
||||
if (!timetable?.slots || timetable.slots.length === 0) {
|
||||
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
|
||||
}
|
||||
|
||||
// Grouping slots by date to make it easier to render rows
|
||||
const groupedSlots = timetable.slots.reduce((acc: any, slot) => {
|
||||
if (!acc[slot.date]) acc[slot.date] = [];
|
||||
acc[slot.date].push(slot);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200 overflow-x-auto">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Timetable</h3>
|
||||
<div className="min-w-[800px]">
|
||||
{Object.keys(groupedSlots).map((date) => (
|
||||
<div key={date} className="mb-6 last:mb-0">
|
||||
<h4 className="text-sm font-medium text-gray-500 mb-2">{date}</h4>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(20px,1fr))] gap-1">
|
||||
{groupedSlots[date].map((slot: any) => (
|
||||
<div
|
||||
key={`${date}-${slot.slot_start}`}
|
||||
className={`h-6 w-full rounded-sm ${
|
||||
slot.sleeping ? 'bg-blue-900' : 'bg-gray-100'
|
||||
}`}
|
||||
title={`${slot.slot_start} - ${slot.slot_end}`}
|
||||
>
|
||||
<span className="text-[10px] text-white/50">{slot.slot_start}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timetable;
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
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';
|
||||
import TestErrorComponent from '../test-error-component';
|
||||
|
||||
const StatsPage: React.FC = () => {
|
||||
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 (
|
||||
<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>
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
</div >
|
||||
|
||||
<IntervalPicker
|
||||
value={settings.statsInterval}
|
||||
onChange={(days) => update('statsInterval', days)}
|
||||
/>
|
||||
|
||||
<ErrorBoundary>
|
||||
<DailySummary
|
||||
from_date={from_date}
|
||||
to_date={to_date}
|
||||
day_start={settings.dayStart}
|
||||
day_end={settings.dayEnd}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ErrorBoundary>
|
||||
<WakeupPlot
|
||||
from_date={from_date}
|
||||
to_date={to_date}
|
||||
wakeup_ref={settings.wakeupRef}
|
||||
bedtime_ref={settings.bedtimeRef}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<BedtimePlot
|
||||
from_date={from_date}
|
||||
to_date={to_date}
|
||||
wakeup_ref={settings.wakeupRef}
|
||||
bedtime_ref={settings.bedtimeRef}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div >
|
||||
|
||||
<ErrorBoundary>
|
||||
<Timetable
|
||||
from_date={from_date}
|
||||
to_date={to_date}
|
||||
day_start={settings.dayStart}
|
||||
day_end={settings.dayEnd}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary>
|
||||
<SessionList />
|
||||
</ErrorBoundary>
|
||||
|
||||
<div className="mt-8 p-4 border-2 border-dashed border-gray-300 rounded">
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-600">Error Boundary Test Area</h3>
|
||||
<ErrorBoundary>
|
||||
<TestErrorComponent />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsPage;
|
||||
@@ -0,0 +1,93 @@
|
||||
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';
|
||||
|
||||
interface WakeupBedtimeProps {
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
wakeup_ref: string;
|
||||
bedtime_ref: string;
|
||||
}
|
||||
|
||||
const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_ref }) => {
|
||||
const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00');
|
||||
const [chartData, setChartData] = useState<{ date: string; minutes: number; time: string }[]>([]);
|
||||
|
||||
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 <div className="text-center p-4">Loading plot...</div>;
|
||||
if (!chartData.length) {
|
||||
return <div className="text-center p-4 text-gray-500">No data available for this period.</div>;
|
||||
}
|
||||
|
||||
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">Wakeup Times</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
tickFormatter={(value) => formatMinutesAfter(value, wakeup_ref)}
|
||||
domain={['dataMin - 30', 'dataMax + 30']}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [
|
||||
formatMinutesAfter(value, wakeup_ref),
|
||||
'Wakeup',
|
||||
]}
|
||||
labelFormatter={(label) => label}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="minutes"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#3b82f6', r: 4 }}
|
||||
connectNulls={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WakeupPlot;
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const TestErrorComponent = () => {
|
||||
throw new Error("Test Error");
|
||||
};
|
||||
|
||||
export default TestErrorComponent;
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { useSleepSession } from '../../hooks/useSleepSession';
|
||||
|
||||
interface SleepButtonProps {
|
||||
time: string;
|
||||
}
|
||||
|
||||
const SleepButton: React.FC<SleepButtonProps> = ({ time }) => {
|
||||
const { activeSession, createSession, endSession, isCreating, isEnding } = useSleepSession();
|
||||
const isSleeping = !!activeSession;
|
||||
|
||||
console.log('DEBUG: SleepButton State:', {
|
||||
activeSession,
|
||||
isSleeping,
|
||||
isCreating,
|
||||
isEnding
|
||||
});
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
if (isSleeping && activeSession?.id) {
|
||||
await endSession({ sessionId: activeSession.id, endTime: time });
|
||||
} else {
|
||||
await createSession({ start_time: time });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update sleep session:', error?.message || error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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 ${
|
||||
isSleeping
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-blue-700 text-white hover:bg-blue-800'
|
||||
} ${isCreating || isEnding ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isCreating || isEnding ? (
|
||||
<span className="animate-pulse">Processing...</span>
|
||||
) : isSleeping ? (
|
||||
'Stop Sleep'
|
||||
) : (
|
||||
'Start Sleep'
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SleepButton;
|
||||
@@ -0,0 +1,42 @@
|
||||
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<string>('');
|
||||
|
||||
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 <div className="p-4 text-center">Loading...</div>;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBanner;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface TimeAdjusterProps {
|
||||
onTimeChange: (time: string) => void;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
function toLocalDatetimeInputValue(date: Date): string {
|
||||
const pad = (n: number) => 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: string): string {
|
||||
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: React.FC<TimeAdjusterProps> = ({ 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 (
|
||||
<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"
|
||||
/>
|
||||
<button
|
||||
onClick={resetToNow}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Reset to now
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeAdjuster;
|
||||
@@ -0,0 +1,22 @@
|
||||
import React, { useState } from 'react';
|
||||
import StatusBanner from './StatusBanner';
|
||||
import SleepButton from './SleepButton';
|
||||
import TimeAdjuster from './TimeAdjuster';
|
||||
|
||||
const TrackerPage: React.FC = () => {
|
||||
const [time, setTime] = useState<string>(new Date().toISOString().slice(0, 16));
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
<StatusBanner />
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg">
|
||||
<TimeAdjuster onTimeChange={setTime} defaultValue={time} />
|
||||
<div className="mt-6">
|
||||
<SleepButton time={time} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackerPage;
|
||||
@@ -0,0 +1,19 @@
|
||||
# frontend/src/hooks
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Custom React hooks: useStats, useSettings, useSleepSession.
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `useSleepSession.ts` | Session start/stop/CRUD |
|
||||
| `useStats.ts` | Stats query hooks |
|
||||
| `useSettings.ts` | App settings state |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- TanStack Query v5 for caching
|
||||
- React 19 hooks
|
||||
- Export typed interfaces
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'baby-sleep-settings';
|
||||
|
||||
interface Settings {
|
||||
dayStart: string;
|
||||
dayEnd: string;
|
||||
wakeupRef: string;
|
||||
bedtimeRef: string;
|
||||
statsInterval: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
dayStart: '07:00',
|
||||
dayEnd: '20:00',
|
||||
wakeupRef: '04:00',
|
||||
bedtimeRef: '19:00',
|
||||
statsInterval: 7,
|
||||
};
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState<Settings>(() => {
|
||||
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: keyof Settings, value: string | number) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return { settings, update };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchActiveSession, createSession, endSession, fetchSessions, deleteSession, updateSession } from '../api/client';
|
||||
import type { SleepSessionRead } from '../types';
|
||||
|
||||
export function useSleepSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const activeSessionQuery = useQuery<SleepSessionRead | null>({
|
||||
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 }: { sessionId: number; endTime: string }) =>
|
||||
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 }: { sessionId: number; payload: any }) =>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes } from '../api/client';
|
||||
import type { DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse } from '../types';
|
||||
|
||||
export function useStats(
|
||||
from_date: string,
|
||||
to_date: string,
|
||||
day_start = '07:00',
|
||||
day_end = '20:00',
|
||||
wakeup_ref = '04:00',
|
||||
bedtime_ref = '19:00'
|
||||
) {
|
||||
const dailySummaryQuery = useQuery<DailySummaryResponse>({
|
||||
queryKey: ['dailySummary', from_date, to_date, day_start, day_end],
|
||||
queryFn: () => fetchDailySummary(from_date, to_date, day_start, day_end),
|
||||
});
|
||||
|
||||
const timetableQuery = useQuery<TimetableResponse>({
|
||||
queryKey: ['timetable', from_date, to_date, day_start, day_end],
|
||||
queryFn: () => fetchTimetable(from_date, to_date, day_start, day_end),
|
||||
});
|
||||
|
||||
const wakeupBedtimeQuery = useQuery<WakeupBedtimeResponse>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,15 @@
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface SleepSession {
|
||||
id: number;
|
||||
start_time: string;
|
||||
end_time: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SleepSessionCreate {
|
||||
start_time: string;
|
||||
}
|
||||
|
||||
export interface SleepSessionUpdate {
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
export type SleepSessionRead = SleepSession;
|
||||
|
||||
export interface SessionListResponse {
|
||||
sessions: SleepSessionRead[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SessionListResponseData {
|
||||
sessions: SleepSessionRead[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SleepSessionCreatePayload {
|
||||
start_time: string;
|
||||
}
|
||||
|
||||
export interface SleepSessionUpdatePayload {
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Add other types as needed for stats
|
||||
export interface DailySummaryDay {
|
||||
date: string;
|
||||
total_minutes: number;
|
||||
day_minutes: number;
|
||||
night_minutes: number;
|
||||
}
|
||||
|
||||
export interface DailySummaryResponse {
|
||||
days: DailySummaryDay[];
|
||||
}
|
||||
|
||||
export interface TimetableSlot {
|
||||
date: string;
|
||||
slot_start: string;
|
||||
slot_end: string;
|
||||
sleeping: boolean;
|
||||
}
|
||||
|
||||
export interface TimetableResponse {
|
||||
slots: TimetableSlot[];
|
||||
}
|
||||
|
||||
export interface WakeupBedtimeDay {
|
||||
date: string;
|
||||
wakeup_minutes: number | null;
|
||||
bedtime_minutes: number | null;
|
||||
}
|
||||
|
||||
export interface WakeupBedtimeResponse {
|
||||
days: WakeupBedtimeDay[];
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Chart data transformation utilities for baby sleep tracker
|
||||
*/
|
||||
|
||||
import type {
|
||||
DailySummaryDay,
|
||||
TimetableSlot,
|
||||
WakeupBedtimeDay,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Build timetable slots for visualization
|
||||
* @param slots - raw timetable slots from API
|
||||
* @returns grouped slots by date
|
||||
*/
|
||||
export function buildTimetableSlots(slots: TimetableSlot[]): Record<string, TimetableSlot[]> {
|
||||
return slots.reduce(
|
||||
(acc, slot) => {
|
||||
if (!acc[slot.date]) {
|
||||
acc[slot.date] = [];
|
||||
}
|
||||
acc[slot.date].push(slot);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, TimetableSlot[]>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform wakeup minutes for Recharts LineChart
|
||||
* @param days - wakeup/bedtime data from API
|
||||
* @returns array formatted for LineChart
|
||||
*/
|
||||
export function computeWakeupMinutes(
|
||||
days: WakeupBedtimeDay[],
|
||||
wakeupRef: string = "04:00"
|
||||
): { date: string; minutes: number; time: string }[] {
|
||||
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: WakeupBedtimeDay[],
|
||||
bedtimeRef: string = "19:00"
|
||||
): { date: string; minutes: number; time: string }[] {
|
||||
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: DailySummaryDay[]
|
||||
): { date: string; daySleep: number; nightSleep: number; total: number }[] {
|
||||
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: DailySummaryDay[]): {
|
||||
avgTotal: number;
|
||||
avgDay: number;
|
||||
avgNight: number;
|
||||
count: number;
|
||||
} {
|
||||
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: number, reference: string): string {
|
||||
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<T extends { date: string }>(days: T[]): T[] {
|
||||
return [...days].reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter days to only include those with data
|
||||
* @param days - array of day data
|
||||
* @returns filtered array
|
||||
*/
|
||||
export function filterDaysWithData<T extends { total_minutes?: number }>(days: T[]): T[] {
|
||||
return days.filter((day) => (day.total_minutes ?? 0) > 0);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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: number | null, reference: string = "04:00"): string {
|
||||
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: Date): string {
|
||||
const offset = date.getTimezoneOffset() * 60 * 1000;
|
||||
const localDate = new Date(date.getTime() - offset);
|
||||
return localDate.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format elapsed time in human-readable format
|
||||
* @param startTime - start time as ISO string or Date
|
||||
* @returns formatted string like "2h 35m" or "just now"
|
||||
*/
|
||||
export function formatElapsedTime(startTime: string | Date): string {
|
||||
const start = typeof startTime === "string" ? new Date(startTime) : startTime;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - start.getTime();
|
||||
|
||||
if (diffMs < 0) return "just now";
|
||||
|
||||
const totalMinutes = Math.floor(diffMs / 60000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours === 0 && minutes === 0) return "just now";
|
||||
if (hours === 0) return `${minutes}m`;
|
||||
if (minutes === 0) return `${hours}h`;
|
||||
return `${hours}h ${minutes}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: Date, bound: "start" | "end"): Date {
|
||||
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
|
||||
*/
|
||||
export function parseTime(timeStr: string, baseDate: Date = new Date()): Date {
|
||||
const [hours, minutes] = timeStr.split(":").map(Number);
|
||||
const result = new Date(baseDate);
|
||||
result.setHours(hours, minutes, 0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format minutes as hours and minutes string
|
||||
* @param minutes - total minutes
|
||||
* @returns formatted string like "12h 30m"
|
||||
*/
|
||||
export function formatMinutes(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours === 0) return `${mins}m`;
|
||||
if (mins === 0) return `${hours}h`;
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date string for N days ago
|
||||
* @param days - number of days ago
|
||||
* @returns ISO date string (YYYY-MM-DD)
|
||||
*/
|
||||
export function getDaysAgo(days: number): string {
|
||||
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(): string {
|
||||
return new Date().toISOString().split("T")[0];
|
||||
}
|
||||
Reference in New Issue
Block a user