Initial commit

This commit is contained in:
2026-04-24 08:02:13 +02:00
commit 867c06295c
76 changed files with 10573 additions and 0 deletions
+21
View File
@@ -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;