diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index 3188a44..de8a901 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -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, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f3bacc1..75b7572 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -57,3 +57,9 @@ class WakeupBedtimeDay(BaseModel): class WakeupBedtimeResponse(BaseModel): days: List[WakeupBedtimeDay] + + +class SummaryResponse(BaseModel): + total_sleeps: int + avg_sleeps_per_day: float + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b67e882..fe77a30 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(endpoint: string, from_date: string, to_date: strin return response.json(); } +export async function fetchSummary(from_date: string, to_date: string): Promise { + return fetchStats('summary', from_date, to_date); +} + export async function fetchDailySummary(from_date: string, to_date: string, day_start: string, day_end: string): Promise { return fetchStats('daily-summary', from_date, to_date, { day_start, day_end }); } diff --git a/frontend/src/components/stats/BedtimePlot.tsx b/frontend/src/components/stats/BedtimePlot.tsx index 4efa46e..a5db27a 100644 --- a/frontend/src/components/stats/BedtimePlot.tsx +++ b/frontend/src/components/stats/BedtimePlot.tsx @@ -40,6 +40,21 @@ const BedtimePlot: React.FC = ({ 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
Loading plot...
; if (!chartData.length) { return
No data available for this period.
; @@ -64,13 +79,13 @@ const BedtimePlot: React.FC = ({ from_date, to_date, wakeup_ tickFormatter={(value) => formatMinutesAfter(Number(value), bedtime_ref)} domain={['dataMin - 30', 'dataMax + 30']} /> - [ - formatMinutesAfter(Number(value), bedtime_ref), - 'Bedtime', - ]} - labelFormatter={(label) => label} - /> + [ + formatMinutesAfter(value, bedtime_ref), + 'Bedtime', + ]} + labelFormatter={(label) => label} + /> = ({ from_date, to_date, wakeup_ /> + {stats && ( +
+
+

Earliest

+

{formatMinutesAfter(stats.min, bedtime_ref)}

+
+
+

Latest

+

{formatMinutesAfter(stats.max, bedtime_ref)}

+
+
+

Average

+

{formatMinutesAfter(Math.round(stats.avg), bedtime_ref)}

+
+
+ )} ); }; diff --git a/frontend/src/components/stats/StatsPage.tsx b/frontend/src/components/stats/StatsPage.tsx index e35e2fd..2516d55 100644 --- a/frontend/src/components/stats/StatsPage.tsx +++ b/frontend/src/components/stats/StatsPage.tsx @@ -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 (
@@ -36,7 +39,7 @@ const StatsPage: React.FC = () => { update('bedtimeRef', s.bedtimeRef); }} /> -
+
{ />
+ + { bedtime_ref={settings.bedtimeRef} /> - + = ({ summary }) => { + if (!summary) return null; + + return ( +
+
+
+ +
+
+

Total Sleeps

+

{summary.total_sleeps}

+
+
+ +
+
+ +
+
+

Avg Sleeps / Day

+

{summary.avg_sleeps_per_day.toFixed(1)}

+
+
+
+ ); +}; + +export default SummaryCards; diff --git a/frontend/src/components/stats/WakeupPlot.tsx b/frontend/src/components/stats/WakeupPlot.tsx index 3afbd87..bd22a09 100644 --- a/frontend/src/components/stats/WakeupPlot.tsx +++ b/frontend/src/components/stats/WakeupPlot.tsx @@ -42,6 +42,21 @@ const WakeupPlot: React.FC = ({ 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
Loading plot...
; if (!chartData.length) { return
No data available for this period.
; @@ -67,8 +82,8 @@ const WakeupPlot: React.FC = ({ from_date, to_date, wakeup_r domain={['dataMin - 30', 'dataMax + 30']} /> [ - formatMinutesAfter(Number(value), wakeup_ref), + formatter={(value: number) => [ + formatMinutesAfter(value, wakeup_ref), 'Wakeup', ]} labelFormatter={(label) => label} @@ -83,7 +98,23 @@ const WakeupPlot: React.FC = ({ from_date, to_date, wakeup_r /> - + {stats && ( +
+
+

Earliest

+

{formatMinutesAfter(stats.min, wakeup_ref)}

+
+
+

Latest

+

{formatMinutesAfter(stats.max, wakeup_ref)}

+
+
+

Average

+

{formatMinutesAfter(Math.round(stats.avg), wakeup_ref)}

+
+
+ )} + ); }; diff --git a/frontend/src/components/tracker/ClockPicker.tsx b/frontend/src/components/tracker/ClockPicker.tsx new file mode 100644 index 0000000..0109599 --- /dev/null +++ b/frontend/src/components/tracker/ClockPicker.tsx @@ -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 = ({ mode, value, onChange, onModeChange }) => { + const containerRef = useRef(null); + + const totalItems = mode === 'hour' ? 24 : 60; + const items = Array.from({ length: totalItems }, (_, i) => i); + + const handleDialClick = (e: React.MouseEvent) => { + 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 ( +
+
+ + +
+ +
+
+ + {items.map((item) => { + const angle = (item * (360 / totalItems)) - 90; + return ( +
+ {item.toString().padStart(2, '0')} +
+ ); + })} + +
+
+
+ ); +}; + +const ClockPicker: React.FC = ({ 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 ( + + ); +}; + +export default ClockPicker; diff --git a/frontend/src/hooks/useStats.ts b/frontend/src/hooks/useStats.ts index 93871d6..ccf2fa1 100644 --- a/frontend/src/hooks/useStats.ts +++ b/frontend/src/hooks/useStats.ts @@ -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({ + queryKey: ['summary', from_date, to_date], + queryFn: () => fetchSummary(from_date, to_date), + }); +} export function useStats( from_date: string, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a2d66be..c92f46a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,3 +1,8 @@ +export interface SummaryResponse { + total_sleeps: number; + avg_sleeps_per_day: number; +} + export interface SleepSession { id: number; start_time: string; diff --git a/frontend/tests/e2e/time_adjuster_visual.spec.ts b/frontend/tests/e2e/time_adjuster_visual.spec.ts new file mode 100644 index 0000000..7c276ac --- /dev/null +++ b/frontend/tests/e2e/time_adjuster_visual.spec.ts @@ -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' }); +}); diff --git a/frontend/time_adjuster_after_am_pm_toggle.png b/frontend/time_adjuster_after_am_pm_toggle.png new file mode 100644 index 0000000..dc8e425 Binary files /dev/null and b/frontend/time_adjuster_after_am_pm_toggle.png differ diff --git a/frontend/time_adjuster_component_clean.png b/frontend/time_adjuster_component_clean.png new file mode 100644 index 0000000..4638b53 Binary files /dev/null and b/frontend/time_adjuster_component_clean.png differ diff --git a/frontend/time_adjuster_initial.png b/frontend/time_adjuster_initial.png new file mode 100644 index 0000000..f231166 Binary files /dev/null and b/frontend/time_adjuster_initial.png differ