6 Commits

10 changed files with 170 additions and 468 deletions
+4
View File
@@ -1,6 +1,10 @@
# Created by https://www.toptal.com/developers/gitignore/api/node,python # Created by https://www.toptal.com/developers/gitignore/api/node,python
# Edit at https://www.toptal.com/developers/gitignore?templates=node,python # Edit at https://www.toptal.com/developers/gitignore?templates=node,python
# Frontend generated files
*.js
*.jsx
### Node ### ### Node ###
# Logs # Logs
logs logs
+23 -18
View File
@@ -113,22 +113,27 @@ async def update_session(
return sleep_session return sleep_session
async def get_all_time_stats(session: AsyncSession) -> tuple[int, float]: async def get_sleep_counts_by_start_date(
statement = select(func.count(), func.min(SleepSession.start_time)) session: AsyncSession,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
) -> List[tuple]:
if to_date and to_date.hour == 0 and to_date.minute == 0 and to_date.second == 0 and to_date.microsecond == 0:
to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999)
query_to_date = to_date.replace(tzinfo=None) if to_date and to_date.tzinfo else to_date
query_from_date = from_date.replace(tzinfo=None) if from_date and from_date.tzinfo else from_date
statement = (
select(func.date(SleepSession.start_time), func.count())
.group_by(func.date(SleepSession.start_time))
.order_by(func.date(SleepSession.start_time).desc())
)
if query_from_date:
statement = statement.where(SleepSession.start_time >= query_from_date)
if query_to_date:
statement = statement.where(SleepSession.start_time <= query_to_date)
result = await session.exec(statement) result = await session.exec(statement)
total_sessions, first_session_time = result.one() return list(result.all())
if total_sessions == 0 or first_session_time is None:
return 0, 0.0
# DB has naive datetimes
first_session_time_naive = first_session_time.replace(tzinfo=None)
# Get current date (naive for comparison)
today_date = datetime.now().date()
first_date = first_session_time_naive.date()
num_days = (today_date - first_date).days + 1
avg_sleeps = total_sessions / num_days if num_days > 0 else 0.0
return total_sessions, avg_sleeps
+19 -7
View File
@@ -3,7 +3,7 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.database import get_session from app.database import get_session
from app.crud import get_sessions, get_all_time_stats from app.crud import get_sessions, get_sleep_counts_by_start_date
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse
from app.utils.time_utils import get_day_night_splits from app.utils.time_utils import get_day_night_splits
@@ -106,14 +106,26 @@ async def get_summary(
to_date: datetime, to_date: datetime,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date) # Period Stats
total_sleeps = len(sessions) period_counts = await get_sleep_counts_by_start_date(session, from_date=from_date, to_date=to_date)
total_sleeps = sum(count for _, count in period_counts)
num_days = (to_date.date() - from_date.date()).days + 1 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 avg_sleeps_per_day = total_sleeps / num_days if num_days > 0 else 0.0
total_sleeps_all_time, avg_sleeps_per_day_all_time = await get_all_time_stats(session) # All-Time Stats
all_counts = await get_sleep_counts_by_start_date(session)
total_sleeps_all_time = sum(count for _, count in all_counts)
avg_sleeps_per_day_all_time = 0.0
if all_counts:
# counts are ordered by desc date (from crud.py)
# so last one is earliest.
earliest_date_str = all_counts[-1][0]
# SQLite date() returns YYYY-MM-DD string
earliest_date = datetime.strptime(earliest_date_str, "%Y-%m-%d").date()
today = datetime.now(timezone.utc).date()
days_all_time = (today - earliest_date).days + 1
avg_sleeps_per_day_all_time = total_sleeps_all_time / days_all_time if days_all_time > 0 else 0.0
return { return {
"total_sleeps": total_sleeps, "total_sleeps": total_sleeps,
"avg_sleeps_per_day": avg_sleeps_per_day, "avg_sleeps_per_day": avg_sleeps_per_day,
+34 -59
View File
@@ -1,69 +1,44 @@
import pytest import pytest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from httpx import AsyncClient, ASGITransport from app.models import SleepSession
from app.main import app from sqlmodel.ext.asyncio.session import AsyncSession
from app.database import engine from app.database import engine, get_session
from app.models import SQLModel from sqlmodel import SQLModel
@pytest.fixture @pytest.mark.asyncio
async def setup_db(): async def test_summary_double_counting_midnight(api_client):
# Setup: Fresh database for this test
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all) await conn.run_sync(SQLModel.metadata.drop_all)
await conn.run_sync(SQLModel.metadata.create_all) await conn.run_sync(SQLModel.metadata.create_all)
@pytest.mark.asyncio # Use a fresh session for insertion
async def test_list_sessions_date_only_filter(setup_db): async for db_session in get_session():
# Test that passing a date (midnight) also works and includes the whole day # 1. Setup: Insert a sleep session that spans across midnight
target_date = datetime(2023, 10, 1, tzinfo=timezone.utc) start_time = datetime(2024, 1, 1, 23, 0)
other_date = datetime(2023, 10, 2, tzinfo=timezone.utc) end_time = datetime(2024, 1, 2, 7, 0)
new_session = SleepSession(start_time=start_time, end_time=end_time)
db_session.add(new_session)
await db_session.commit()
break # Exit after one iteration
# 2. Call the stats endpoint for a period that includes this session
from_date = "2024-01-01T00:00:00"
to_date = "2024-01-02T23:59:59"
async with AsyncClient( response = await api_client.get(f"/api/stats/summary?from_date={from_date}&to_date={to_date}")
transport=ASGITransport(app=app), base_url="http://test"
) as ac: assert response.status_code == 200
# 1. Create a session that starts LATER in the day on OTHER date (should be excluded) data = response.json()
# We end it immediately to avoid 409s in the test
start_time_other = other_date.replace(hour=20, minute=0) # 3. Verify that total_sleeps is exactly 1 for the requested period
resp_other = await ac.post("/api/sessions", json={"start_time": start_time_other.isoformat()}) print(f"DEBUG: data={data}")
assert resp_other.status_code == 201 assert data["total_sleeps"] == 1, f"Expected 1 sleep session, got {data['total_sleeps']}"
session_id_other = resp_other.json()["id"]
await ac.patch(f"/api/sessions/{session_id_other}/end", json={"end_time": (start_time_other + timedelta(minutes=30)).isoformat()}) # 4. Verify that all-time stats also count this session exactly once
assert data["total_sleeps_all_time"] == 1, f"Expected 1 all-time sleep session, got {data['total_sleeps_all_time']}"
# 2. Create a session that starts ON the target_date (should be included)
start_time_target = target_date.replace(hour=20, minute=0)
resp_target = await ac.post("/api/sessions", json={"start_time": start_time_target.isoformat()})
assert resp_target.status_code == 201
session_id_target = resp_target.json()["id"]
await ac.patch(f"/api/sessions/{session_id_target}/end", json={"end_time": (start_time_target + timedelta(minutes=30)).isoformat()})
# 3. Create a session that starts EARLIER on the target_date (should be included)
start_time_early = target_date.replace(hour=2, minute=0)
resp_early = await ac.post("/api/sessions", json={"start_time": start_time_early.isoformat()})
assert resp_early.status_code == 201
session_id_early = resp_early.json()["id"]
await ac.patch(f"/api/sessions/{session_id_early}/end", json={"end_time": (start_time_early + timedelta(minutes=30)).isoformat()})
# 4. Create a session that is on the OTHER date (should be excluded)
start_time_other2 = other_date.replace(hour=2, minute=0)
resp_other2 = await ac.post("/api/sessions", json={"start_time": start_time_other2.isoformat()})
assert resp_other2.status_code == 201
session_id_other2 = resp_other2.json()["id"]
await ac.patch(f"/api/sessions/{session_id_other2}/end", json={"end_time": (start_time_other2 + timedelta(minutes=30)).isoformat()})
# Query with to_date = target_date (midnight)
# This should be expanded to 23:59:59 on target_date.
# Should include the 2 sessions on target_date (target and early).
# Should exclude the 2 sessions on other_date.
to_date_str = target_date.strftime("%Y-%m-%d") # "2023-10-01"
print(f"\nGET: to_date={to_date_str}")
response = await ac.get(f"/api/sessions?to_date={to_date_str}")
print(f"GET RESPONSE: {response.status_code}, body={response.json()}")
assert response.status_code == 200
sessions = response.json()["sessions"]
# We expect exactly 2 sessions (the ones on target_date)
assert len(sessions) == 2, f"Expected 2 sessions (the ones on {to_date_str}), but got {len(sessions)}. (Body: {response.json()})"
for s in sessions:
# Verify that the sessions returned are indeed from the target_date
assert target_date.strftime("%Y-%m-%d") in s["start_time"]
+2 -1
View File
@@ -9,7 +9,8 @@
"preview": "vite preview", "preview": "vite preview",
"test": "DEBUG_PRINT_LIMIT=0 vitest run --reporter=dot --no-color", "test": "DEBUG_PRINT_LIMIT=0 vitest run --reporter=dot --no-color",
"test:all": "vitest run && npx playwright test", "test:all": "vitest run && npx playwright test",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"clean": "find src -name '*.js' -o -name '*.jsx' -delete"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.100.5", "@tanstack/react-query": "^5.100.5",
+27 -18
View File
@@ -51,8 +51,13 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
const min = Math.min(...validMinutes); const min = Math.min(...validMinutes);
const max = Math.max(...validMinutes); const max = Math.max(...validMinutes);
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length; const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
const sorted = [...validMinutes].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 !== 0
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
return { min, max, avg }; return { min, max, avg, median };
}, [wakeupBedtime]); }, [wakeupBedtime]);
if (isLoading) return <div className="text-center p-4">Loading plot...</div>; if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
@@ -79,11 +84,11 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
tickFormatter={(value) => formatMinutesAfter(Number(value), bedtime_ref)} tickFormatter={(value) => formatMinutesAfter(Number(value), bedtime_ref)}
domain={['dataMin - 30', 'dataMax + 30']} domain={['dataMin - 30', 'dataMax + 30']}
/> />
<Tooltip <Tooltip
formatter={(value: number) => [ formatter={(value: unknown) => [
formatMinutesAfter(value, bedtime_ref), typeof value === 'number' ? formatMinutesAfter(value, bedtime_ref) : String(value ?? ''),
'Bedtime', 'Bedtime',
]} ]}
labelFormatter={(label) => label} labelFormatter={(label) => label}
/> />
<Line <Line
@@ -97,20 +102,24 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
{stats && ( {stats && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-2 text-center"> <div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-4 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> <div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Average</p> <p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), bedtime_ref)}</p> <p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.min, bedtime_ref)}</p>
</div> </div>
</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>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Median</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.median), bedtime_ref)}</p>
</div>
</div>
)} )}
</div> </div>
); );
+27 -17
View File
@@ -42,20 +42,26 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
}).reverse(); }).reverse();
}, [wakeupBedtime, wakeup_ref]); }, [wakeupBedtime, wakeup_ref]);
const stats = useMemo(() => { const stats = useMemo(() => {
if (!wakeupBedtime?.days) return null; if (!wakeupBedtime?.days) return null;
const validMinutes = wakeupBedtime.days const validMinutes = wakeupBedtime.days
.map((day) => day.wakeup_minutes) .map((day) => day.wakeup_minutes)
.filter((m): m is number => m !== null); .filter((m): m is number => m !== null);
if (validMinutes.length === 0) return null; if (validMinutes.length === 0) return null;
const min = Math.min(...validMinutes); const min = Math.min(...validMinutes);
const max = Math.max(...validMinutes); const max = Math.max(...validMinutes);
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length; const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
return { min, max, avg }; const sorted = [...validMinutes].sort((a, b) => a - b);
}, [wakeupBedtime]); const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 !== 0
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
return { min, max, avg, median };
}, [wakeupBedtime]);
if (isLoading) return <div className="text-center p-4">Loading plot...</div>; if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
if (!chartData.length) { if (!chartData.length) {
@@ -81,11 +87,11 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
tickFormatter={(value) => formatMinutesAfter(Number(value), wakeup_ref)} tickFormatter={(value) => formatMinutesAfter(Number(value), wakeup_ref)}
domain={['dataMin - 30', 'dataMax + 30']} domain={['dataMin - 30', 'dataMax + 30']}
/> />
<Tooltip <Tooltip
formatter={(value: number) => [ formatter={(value: unknown) => [
formatMinutesAfter(value, wakeup_ref), typeof value === 'number' ? formatMinutesAfter(value, wakeup_ref) : String(value ?? ''),
'Wakeup', 'Wakeup',
]} ]}
labelFormatter={(label) => label} labelFormatter={(label) => label}
/> />
<Line <Line
@@ -99,7 +105,7 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
{stats && ( {stats && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-2 text-center"> <div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-4 gap-2 text-center">
<div> <div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Earliest</p> <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> <p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(stats.min, wakeup_ref)}</p>
@@ -112,6 +118,10 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Average</p> <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> <p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), wakeup_ref)}</p>
</div> </div>
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Median</p>
<p className="text-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.median), wakeup_ref)}</p>
</div>
</div> </div>
)} )}
</div> </div>
-1
View File
@@ -1 +0,0 @@
"use strict";
+2 -1
View File
@@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
export default defineConfig({ export default defineConfig({
base: './',
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
@@ -18,4 +19,4 @@ export default defineConfig({
}, },
}, },
}, },
}) })
+32 -346
View File
@@ -1,351 +1,37 @@
# Proposed Implementation Plan # Plan: Add Median Statistic to Wakeup and Bedtime Plots
## Phase 1: Settings Update ## Overview
* **`frontend/src/hooks/useSettings.ts`**: Add `sessionsPerPage` to the settings state and define a default value (e.g., 10). The goal is to add a fourth statistic, "Median", to the summary section below the Wakeup and Bedtime trend graphs in `WakeupPlot.tsx` and `BedtimePlot.tsx`. Currently, these components display "Earliest", "Latest", and "Average".
* **`frontend/src/components/stats/SettingsPopover.tsx`**:
* Update the props and local state to include `sessionsPerPage`.
* Add a dropdown in the settings UI for selecting "Results per page" (e.g., 5, 10, 25, 50).
* **`frontend/src/components/stats/StatsPage.tsx`**: Update the `onSave` handler to persist the new `sessionsPerPage` setting.
## Phase 2: Pagination UI and Logic ## Detailed Tasks
* **`frontend/src/components/stats/Pagination.tsx` (New Component)**: Implement a "Google-style" pagination component with `Previous`, page numbers, and `Next` buttons.
* **`frontend/src/components/stats/SessionList.tsx`**:
* Integrate `useSettings` to fetch the selected page size.
* Implement local pagination logic (managing `currentPage`).
* Slice the session data to show only the current page's items.
* Render the new `Pagination` component at the bottom of the list.
## Phase 3: Verification ### 1. Update `frontend/src/components/stats/WakeupPlot.tsx`
* Verify that changing "Results per page" in Settings correctly updates the list. - **Sub-task 1.1: Update Statistics Calculation**
* Confirm pagination navigation (Prev/Next/Numbers) works as expected and handles edge cases (e.g., first/last page). - Locate the `stats` `useMemo` block.
- Calculate the median of `validMinutes`.
- *Note*: To calculate the median, sort the array and pick the middle element (or average of two middle elements if even).
- Include `median` in the returned object: `{ min, max, avg, median }`.
- **Sub-task 1.2: Update UI Layout**
- Find the statistics container div (currently `grid grid-cols-3`).
- Change the Tailwind class from `grid-cols-3` to `grid-cols-4`.
- **Sub-task 1.3: Add Median Display**
- Add a new `div` for the "Median" statistic.
- Use identical styling to the existing three blocks.
- Display the value using `formatMinutesAfter(Math.round(stats.median), wakeup_ref)`.
I will use multiple sub-agents to execute these tasks in parallel. ### 2. Update `frontend/src/components/stats/BedtimePlot.tsx`
- **Sub-task 2.1: Update Statistics Calculation**
- Locate the `stats` `useMemo` block.
- Calculate the median of `validMinutes`.
- Include `median` in the returned object: `{ min, max, avg, median }`.
- **Sub-task 2.2: Update UI Layout**
- Find the statistics container div (currently `grid grid-cols-3`).
- Change the Tailwind class from `grid-cols-3` to `grid-cols-4`.
- **Sub-task 2.3: Add Median Display**
- Add a new `div` for the "Median" statistic.
- Use identical styling to the existing three blocks.
- Display the value using `formatMinutesAfter(Math.round(stats.median), bedtime_ref)`.
--- ## Verification Plan
1. **Visual Inspection**: Ensure the four statistics are evenly spaced and aligned in a single row below each graph.
The user wants to implement client-side pagination for "Sleep Sessions". 2. **Logic Check**: Manually verify with a small set of known numbers (e.g., [10, 20, 30] -> median 20; [10, 20, 30, 40] -> median 25) that the calculation is correct.
Plan:
1. Update `useSettings` to include `sessionsPerPage`.
2. Update `SettingsPopover` to allow users to change `sessionsPerPage`.
3. Update `StatsPage`'s `onSave` in `SettingsPopover` to include the new setting.
4. Create a new `Pagination` component.
5. Update `SessionList` to use pagination logic and the new component.
6. Verify with Playwright and screenshots.
I'll start by using `todowrite` to track progress.
Then I will launch sub-agents.
Sub-agents:
1. `explore` (or `general`) for Phase 1 (Settings).
2. `general` for Phase 2 (Pagination component and SessionList logic).
3. `general` for Phase 3 (Playwright verification).
Wait, the user said "use as many sub-agents as possible". I'll split it.
Task 1: Update settings (useSettings, SettingsPopover, StatsPage).
Task 2: Implement Pagination component and integrate it into SessionList.
Task 3: Write Playwright tests to verify functionality and take screenshots.
Let's start with `todowrite`.
## TODOS
```json
{
"todos": [
{
"content": "Update useSettings to include sessionsPerPage",
"priority": "high",
"status": "pending"
},
{
"content": "Update SettingsPopover to allow changing sessionsPerPage",
"priority": "high",
"status": "pending"
},
{
"content": "Update StatsPage to persist sessionsPerPage in onSave",
"priority": "high",
"status": "pending"
},
{
"content": "Create Pagination component in frontend/src/components/stats/Pagination.tsx",
"priority": "high",
"status": "pending"
},
{
"content": "Update SessionList to implement client-side pagination logic and use Pagination component",
"priority": "high",
"status": "pending"
},
{
"content": "Write Playwright tests to verify pagination and take screenshots",
"priority": "medium",
"status": "pending"
}
]
}
```
## Current progress (git diff)
```
diff --git a/frontend/src/components/stats/SessionList.tsx b/frontend/src/components/stats/SessionList.tsx
index 374e41c..2bd6ea7 100644
--- a/frontend/src/components/stats/SessionList.tsx
+++ b/frontend/src/components/stats/SessionList.tsx
@@ -1,15 +1,29 @@
import React, { useState } from 'react';
import { useSessions } from '../../hooks/useSleepSession';
+import { useSettings } from '../../hooks/useSettings';
+import { Pagination } from './Pagination';
import { Edit2, Check, X, Trash2 } from 'lucide-react';
import type { SleepSessionRead } from '../../types';
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
export function SessionList() {
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions();
+ const { settings } = useSettings();
+ const sessionsPerPage = settings.sessionsPerPage;
+
+ const [currentPage, setCurrentPage] = useState(1);
const [editingId, setEditingId] = useState<number | null>(null);
const [editStart, setEditStart] = useState<string>('');
const [editEnd, setEditEnd] = useState<string>('');
+ const totalPages = Math.ceil(sessions.length / sessionsPerPage);
+ const clampedPage = Math.max(1, Math.min(currentPage, totalPages || 1));
+
+ const paginatedSessions = sessions.slice(
+ (clampedPage - 1) * sessionsPerPage,
+ clampedPage * sessionsPerPage
+ );
+
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>;
@@ -55,7 +69,7 @@ export function SessionList() {
<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) => (
+ {paginatedSessions.map((s) => (
<div key={s.id} className="border-b border-gray-100 last:border-0 py-3 sm:py-2">
{editingId === s.id ? (
<div className="flex flex-col space-y-3 py-1">
@@ -140,6 +154,13 @@ export function SessionList() {
</div>
))}
</div>
+ {sessions.length > sessionsPerPage && (
+ <Pagination
+ currentPage={clampedPage}
+ totalPages={totalPages}
+ onPageChange={setCurrentPage}
+ />
+ )}
</div>
);
}
diff --git a/frontend/src/components/stats/SettingsPopover.tsx b/frontend/src/components/stats/SettingsPopover.tsx
index 005a302..8031392 100644
--- a/frontend/src/components/stats/SettingsPopover.tsx
+++ b/frontend/src/components/stats/SettingsPopover.tsx
@@ -6,15 +6,17 @@ interface SettingsPopoverProps {
dayEnd: string;
wakeupRef: string;
bedtimeRef: string;
- onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string }) => void;
+ sessionsPerPage: number;
+ onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string; sessionsPerPage: number }) => void;
}
-export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSave }: SettingsPopoverProps) {
+export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage, 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 [localSessionsPerPage, setLocalSessionsPerPage] = useState(sessionsPerPage);
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -22,7 +24,8 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
setLocalDayEnd(dayEnd);
setLocalWakeupRef(wakeupRef);
setLocalBedtimeRef(bedtimeRef);
- }, [dayStart, dayEnd, wakeupRef, bedtimeRef]);
+ setLocalSessionsPerPage(sessionsPerPage);
+ }, [dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -42,6 +45,7 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
dayEnd: localDayEnd,
wakeupRef: localWakeupRef,
bedtimeRef: localBedtimeRef,
+ sessionsPerPage: localSessionsPerPage,
});
setIsOpen(false);
};
@@ -65,44 +69,58 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
<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"
-/>
+ <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"
-/>
+ <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"
-/>
+ <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"
-/>
+ <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>
+ <label htmlFor="sessions-per-page" className="block text-xs text-gray-500 mb-1">Results per page</label>
+ <select
+ id="sessions-per-page"
+ value={localSessionsPerPage}
+ onChange={(e) => setLocalSessionsPerPage(Number(e.target.value))}
+ className="w-full p-2 border rounded-md text-sm"
+ >
+ <option value={5}>5</option>
+ <option value={10}>10</option>
+ <option value={25}>25</option>
+ <option value={50}>50</option>
+ </select>
</div>
</div>
diff --git a/frontend/src/components/stats/StatsPage.tsx b/frontend/src/components/stats/StatsPage.tsx
index 2516d55..e3518c8 100644
--- a/frontend/src/components/stats/StatsPage.tsx
+++ b/frontend/src/components/stats/StatsPage.tsx
@@ -27,18 +27,20 @@ const StatsPage: React.FC = () => {
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Statistics</h2>
<p className="text-slate-500 text-sm mt-1">Overview of sleep patterns and trends</p>
</div>
- <SettingsPopover
- dayStart={settings.dayStart}
- dayEnd={settings.dayEnd}
- wakeupRef={settings.wakeupRef}
- bedtimeRef={settings.bedtimeRef}
- onSave={(s) => {
- update('dayStart', s.dayStart);
- update('dayEnd', s.dayEnd);
- update('wakeupRef', s.wakeupRef);
- update('bedtimeRef', s.bedtimeRef);
- }}
- />
+ <SettingsPopover
+ dayStart={settings.dayStart}
+ dayEnd={settings.dayEnd}
+ wakeupRef={settings.wakeupRef}
+ bedtimeRef={settings.bedtimeRef}
+ sessionsPerPage={settings.sessionsPerPage}
+ onSave={(s) => {
+ update('dayStart', s.dayStart);
+ update('dayEnd', s.dayEnd);
+ update('wakeupRef', s.wakeupRef);
+ update('bedtimeRef', s.bedtimeRef);
+ update('sessionsPerPage', s.sessionsPerPage);
+ }}
+ />
</div>
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts
index c587fb3..d95899b 100644
--- a/frontend/src/hooks/useSettings.ts
+++ b/frontend/src/hooks/useSettings.ts
@@ -8,6 +8,7 @@ interface Settings {
wakeupRef: string;
bedtimeRef: string;
statsInterval: number;
+ sessionsPerPage: number;
}
const DEFAULT_SETTINGS: Settings = {
@@ -16,6 +17,7 @@ const DEFAULT_SETTINGS: Settings = {
wakeupRef: '04:00',
bedtimeRef: '19:00',
statsInterval: 7,
+ sessionsPerPage: 10,
};
export function useSettings() {
```
## Last command
The last command you ran was this:
```
npx playwright test frontend/tests/e2e/pagination.spec.ts --reporter=list
```
so please run this again and see the output, as this might help us know where to pick things up.