Add stats
This commit is contained in:
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from app.database import get_session
|
||||
from app.crud import get_sessions
|
||||
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse
|
||||
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse
|
||||
from app.utils.time_utils import get_day_night_splits
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||
@@ -100,6 +100,24 @@ async def get_timetable(
|
||||
return {"slots": slots}
|
||||
|
||||
|
||||
@router.get("/summary", response_model=SummaryResponse)
|
||||
async def get_summary(
|
||||
from_date: datetime,
|
||||
to_date: datetime,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
|
||||
total_sleeps = len(sessions)
|
||||
|
||||
num_days = (to_date.date() - from_date.date()).days + 1
|
||||
avg_sleeps_per_day = total_sleeps / num_days if num_days > 0 else 0.0
|
||||
|
||||
return {
|
||||
"total_sleeps": total_sleeps,
|
||||
"avg_sleeps_per_day": avg_sleeps_per_day,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/wake-sleep-times", response_model=WakeupBedtimeResponse)
|
||||
async def get_wake_sleep_times(
|
||||
from_date: datetime,
|
||||
|
||||
@@ -57,3 +57,9 @@ class WakeupBedtimeDay(BaseModel):
|
||||
|
||||
class WakeupBedtimeResponse(BaseModel):
|
||||
days: List[WakeupBedtimeDay]
|
||||
|
||||
|
||||
class SummaryResponse(BaseModel):
|
||||
total_sleeps: int
|
||||
avg_sleeps_per_day: float
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
SessionListResponse,
|
||||
DailySummaryResponse,
|
||||
TimetableResponse,
|
||||
WakeupBedtimeResponse
|
||||
WakeupBedtimeResponse,
|
||||
SummaryResponse
|
||||
} from '@/types';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
@@ -39,6 +40,10 @@ async function fetchStats<T>(endpoint: string, from_date: string, to_date: strin
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchSummary(from_date: string, to_date: string): Promise<SummaryResponse> {
|
||||
return fetchStats('summary', from_date, to_date);
|
||||
}
|
||||
|
||||
export async function fetchDailySummary(from_date: string, to_date: string, day_start: string, day_end: string): Promise<DailySummaryResponse> {
|
||||
return fetchStats('daily-summary', from_date, to_date, { day_start, day_end });
|
||||
}
|
||||
|
||||
@@ -40,6 +40,21 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
||||
}).reverse();
|
||||
}, [wakeupBedtime, bedtime_ref]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!wakeupBedtime?.days) return null;
|
||||
const validMinutes = wakeupBedtime.days
|
||||
.map((day) => day.bedtime_minutes)
|
||||
.filter((m): m is number => m !== null);
|
||||
|
||||
if (validMinutes.length === 0) return null;
|
||||
|
||||
const min = Math.min(...validMinutes);
|
||||
const max = Math.max(...validMinutes);
|
||||
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
|
||||
|
||||
return { min, max, avg };
|
||||
}, [wakeupBedtime]);
|
||||
|
||||
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>;
|
||||
@@ -64,13 +79,13 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
||||
tickFormatter={(value) => formatMinutesAfter(Number(value), bedtime_ref)}
|
||||
domain={['dataMin - 30', 'dataMax + 30']}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: any) => [
|
||||
formatMinutesAfter(Number(value), bedtime_ref),
|
||||
'Bedtime',
|
||||
]}
|
||||
labelFormatter={(label) => label}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [
|
||||
formatMinutesAfter(value, bedtime_ref),
|
||||
'Bedtime',
|
||||
]}
|
||||
labelFormatter={(label) => label}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="minutes"
|
||||
@@ -81,6 +96,22 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{stats && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.min, bedtime_ref)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Latest</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.max, bedtime_ref)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Average</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), bedtime_ref)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { IntervalPicker } from './IntervalPicker';
|
||||
import { SettingsPopover } from './SettingsPopover';
|
||||
import SummaryCards from './SummaryCards';
|
||||
import DailySummary from './DailySummary';
|
||||
import Timetable from './SleepTimetable';
|
||||
import WakeupPlot from './WakeupPlot';
|
||||
@@ -8,6 +9,7 @@ import BedtimePlot from './BedtimePlot';
|
||||
import { SessionList } from './SessionList';
|
||||
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import { useSummary } from '../../hooks/useStats';
|
||||
|
||||
const StatsPage: React.FC = () => {
|
||||
const { settings, update } = useSettings();
|
||||
@@ -16,6 +18,7 @@ const StatsPage: React.FC = () => {
|
||||
const to_date = now.toLocaleDateString('en-CA'); // YYYY-MM-DD in local time
|
||||
const from_date = new Date(now.setDate(now.getDate() - settings.statsInterval)).toLocaleDateString('en-CA');
|
||||
|
||||
const { data: summary } = useSummary(from_date, to_date);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8 pb-12">
|
||||
@@ -36,7 +39,7 @@ const StatsPage: React.FC = () => {
|
||||
update('bedtimeRef', s.bedtimeRef);
|
||||
}}
|
||||
/>
|
||||
</div >
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
|
||||
<IntervalPicker
|
||||
@@ -45,6 +48,8 @@ const StatsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SummaryCards summary={summary} />
|
||||
|
||||
<ErrorBoundary>
|
||||
<DailySummary
|
||||
from_date={from_date}
|
||||
@@ -71,7 +76,7 @@ const StatsPage: React.FC = () => {
|
||||
bedtime_ref={settings.bedtimeRef}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div >
|
||||
</div>
|
||||
|
||||
<ErrorBoundary>
|
||||
<Timetable
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Moon, Calendar } from 'lucide-react';
|
||||
import type { SummaryResponse } from '@/types';
|
||||
|
||||
interface SummaryCardsProps {
|
||||
summary?: SummaryResponse;
|
||||
}
|
||||
|
||||
const SummaryCards: React.FC<SummaryCardsProps> = ({ summary }) => {
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100 flex items-center space-x-4">
|
||||
<div className="p-3 bg-indigo-50 rounded-2xl text-indigo-600">
|
||||
<Moon size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Total Sleeps</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{summary.total_sleeps}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100 flex items-center space-x-4">
|
||||
<div className="p-3 bg-amber-50 rounded-2xl text-amber-600">
|
||||
<Calendar size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Avg Sleeps / Day</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{summary.avg_sleeps_per_day.toFixed(1)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SummaryCards;
|
||||
@@ -42,6 +42,21 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
||||
}).reverse();
|
||||
}, [wakeupBedtime, wakeup_ref]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!wakeupBedtime?.days) return null;
|
||||
const validMinutes = wakeupBedtime.days
|
||||
.map((day) => day.wakeup_minutes)
|
||||
.filter((m): m is number => m !== null);
|
||||
|
||||
if (validMinutes.length === 0) return null;
|
||||
|
||||
const min = Math.min(...validMinutes);
|
||||
const max = Math.max(...validMinutes);
|
||||
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
|
||||
|
||||
return { min, max, avg };
|
||||
}, [wakeupBedtime]);
|
||||
|
||||
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>;
|
||||
@@ -67,8 +82,8 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
||||
domain={['dataMin - 30', 'dataMax + 30']}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: any) => [
|
||||
formatMinutesAfter(Number(value), wakeup_ref),
|
||||
formatter={(value: number) => [
|
||||
formatMinutesAfter(value, wakeup_ref),
|
||||
'Wakeup',
|
||||
]}
|
||||
labelFormatter={(label) => label}
|
||||
@@ -83,7 +98,23 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div >
|
||||
{stats && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.min, wakeup_ref)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Latest</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.max, wakeup_ref)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Average</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), wakeup_ref)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
|
||||
interface ClockPickerProps {
|
||||
hour: number;
|
||||
minute: number;
|
||||
onChange: (hour: number, minute: number) => void;
|
||||
}
|
||||
|
||||
interface NumberDialPickerProps {
|
||||
mode: 'hour' | 'minute';
|
||||
value: number;
|
||||
onChange: (val: number) => void;
|
||||
onModeChange?: (mode: 'hour' | 'minute') => void;
|
||||
}
|
||||
|
||||
const NumberDialPicker: React.FC<NumberDialPickerProps> = ({ mode, value, onChange, onModeChange }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const totalItems = mode === 'hour' ? 24 : 60;
|
||||
const items = Array.from({ length: totalItems }, (_, i) => i);
|
||||
|
||||
const handleDialClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
const x = e.clientX - centerX;
|
||||
const y = e.clientY - centerY;
|
||||
|
||||
let angle = Math.atan2(y, x) * (180 / Math.PI) + 90;
|
||||
if (angle < 0) angle += 360;
|
||||
|
||||
const step = 360 / totalItems;
|
||||
// Use a small epsilon or rounding to ensure clicking near boundaries works well
|
||||
const selectedIndex = Math.round(angle / step) % totalItems;
|
||||
|
||||
onChange(selectedIndex);
|
||||
|
||||
if (mode === 'hour' && onModeChange) {
|
||||
setTimeout(() => onModeChange('minute'), 150);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div className="flex bg-gray-100 p-1 rounded-full w-full max-w-[280px] mb-6">
|
||||
<button
|
||||
onClick={() => onModeChange?.('hour')}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
mode === 'hour' ? 'bg-white shadow-sm text-indigo-600' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
Hour
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onModeChange?.('minute')}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
mode === 'minute' ? 'bg-white shadow-sm text-indigo-600' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
Minute
|
||||
</button>
|
||||
</div >
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleDialClick}
|
||||
className="relative w-64 h-64 rounded-full bg-gray-50 border-4 border-white shadow-inner cursor-pointer touch-none select-none overflow-hidden"
|
||||
style={{ backgroundImage: 'radial-gradient(circle, #f3f4f6 1px, transparent 1px)', backgroundSize: '20px 20px' }}
|
||||
>
|
||||
<div className="absolute top-1/2 left-1/2 w-1 h-1 bg-indigo-600 -translate-x-1/2 -translate-y-1/2 z-30 opacity-50" />
|
||||
|
||||
{items.map((item) => {
|
||||
const angle = (item * (360 / totalItems)) - 90;
|
||||
return (
|
||||
<div
|
||||
key={item}
|
||||
className={`absolute top-1/2 left-1/2 text-sm font-bold transition-all duration-200
|
||||
${item === value ? 'text-indigo-600 scale-125' : 'text-gray-400'}
|
||||
`}
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) rotate(${angle}deg) translateY(-108px) rotate(${-angle}deg)`
|
||||
}}
|
||||
>
|
||||
{item.toString().padStart(2, '0')}
|
||||
</div >
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="absolute top-1/2 left-1/2 w-12 h-12 bg-indigo-50 rounded-full -translate-x-1/2 -translate-y-1/2 z-0" />
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
const ClockPicker: React.FC<ClockPickerProps> = ({ hour, minute, onChange }) => {
|
||||
const [mode, setMode] = useState<'hour' | 'minute'>('hour');
|
||||
|
||||
const handleTimeChange = (newHour: number, newMinute: number) => {
|
||||
onChange(newHour, newMinute);
|
||||
};
|
||||
|
||||
const handleNumberChange = (val: number) => {
|
||||
if (mode === 'hour') {
|
||||
handleTimeChange(val, minute);
|
||||
} else {
|
||||
handleTimeChange(hour, val);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NumberDialPicker
|
||||
mode={mode}
|
||||
value={mode === 'hour' ? hour : minute}
|
||||
onChange={handleNumberChange}
|
||||
onModeChange={setMode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClockPicker;
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes } from '../api/client';
|
||||
import type { DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse } from '../types';
|
||||
import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes, fetchSummary } from '../api/client';
|
||||
import type { DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse } from '../types';
|
||||
|
||||
export function useSummary(from_date: string, to_date: string) {
|
||||
return useQuery<SummaryResponse>({
|
||||
queryKey: ['summary', from_date, to_date],
|
||||
queryFn: () => fetchSummary(from_date, to_date),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStats(
|
||||
from_date: string,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export interface SummaryResponse {
|
||||
total_sleeps: number;
|
||||
avg_sleeps_per_day: number;
|
||||
}
|
||||
|
||||
export interface SleepSession {
|
||||
id: number;
|
||||
start_time: string;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE_URL = 'http://localhost:5173';
|
||||
|
||||
test('visual inspection of TimeAdjuster', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/playground`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Select 'TimeAdjuster'
|
||||
const select = page.locator('select#component-select');
|
||||
await select.selectOption({ label: 'TimeAdjuster' });
|
||||
|
||||
// Wait for component to render
|
||||
const mainArea = page.getByRole('main').last();
|
||||
await expect(mainArea).toBeVisible();
|
||||
|
||||
// Take a clean, high-resolution screenshot of the component
|
||||
const componentContainer = mainArea.locator('div').first(); // The wrapper in playground
|
||||
await componentContainer.screenshot({ path: 'time_adjuster_component_clean.png' });
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Reference in New Issue
Block a user