Compare commits
6 Commits
c371a3d8e8
...
db0f717560
| Author | SHA1 | Date | |
|---|---|---|---|
| db0f717560 | |||
| aa4e28e7f6 | |||
| 837c3b84cb | |||
| 96fb3cb988 | |||
| 8eb5946108 | |||
| 7e3bd0a16b |
@@ -1,6 +1,10 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node,python
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,python
|
||||
|
||||
# Frontend generated files
|
||||
*.js
|
||||
*.jsx
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
|
||||
+23
-18
@@ -113,22 +113,27 @@ async def update_session(
|
||||
return sleep_session
|
||||
|
||||
|
||||
async def get_all_time_stats(session: AsyncSession) -> tuple[int, float]:
|
||||
statement = select(func.count(), func.min(SleepSession.start_time))
|
||||
async def get_sleep_counts_by_start_date(
|
||||
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)
|
||||
total_sessions, first_session_time = result.one()
|
||||
|
||||
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
|
||||
return list(result.all())
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import List, Optional
|
||||
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, 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.utils.time_utils import get_day_night_splits
|
||||
|
||||
@@ -106,13 +106,25 @@ async def get_summary(
|
||||
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)
|
||||
|
||||
# Period Stats
|
||||
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
|
||||
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 {
|
||||
"total_sleeps": total_sleeps,
|
||||
|
||||
@@ -1,69 +1,44 @@
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from app.main import app
|
||||
from app.database import engine
|
||||
from app.models import SQLModel
|
||||
from app.models import SleepSession
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from app.database import engine, get_session
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_db():
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_double_counting_midnight(api_client):
|
||||
# Setup: Fresh database for this test
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.drop_all)
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_date_only_filter(setup_db):
|
||||
# Test that passing a date (midnight) also works and includes the whole day
|
||||
target_date = datetime(2023, 10, 1, tzinfo=timezone.utc)
|
||||
other_date = datetime(2023, 10, 2, tzinfo=timezone.utc)
|
||||
# Use a fresh session for insertion
|
||||
async for db_session in get_session():
|
||||
# 1. Setup: Insert a sleep session that spans across midnight
|
||||
start_time = datetime(2024, 1, 1, 23, 0)
|
||||
end_time = datetime(2024, 1, 2, 7, 0)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
# 1. Create a session that starts LATER in the day on OTHER date (should be excluded)
|
||||
# We end it immediately to avoid 409s in the test
|
||||
start_time_other = other_date.replace(hour=20, minute=0)
|
||||
resp_other = await ac.post("/api/sessions", json={"start_time": start_time_other.isoformat()})
|
||||
assert resp_other.status_code == 201
|
||||
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()})
|
||||
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. 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()})
|
||||
# 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"
|
||||
|
||||
# 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()}")
|
||||
response = await api_client.get(f"/api/stats/summary?from_date={from_date}&to_date={to_date}")
|
||||
|
||||
assert response.status_code == 200
|
||||
sessions = response.json()["sessions"]
|
||||
data = response.json()
|
||||
|
||||
# 3. Verify that total_sleeps is exactly 1 for the requested period
|
||||
print(f"DEBUG: data={data}")
|
||||
assert data["total_sleeps"] == 1, f"Expected 1 sleep session, got {data['total_sleeps']}"
|
||||
|
||||
# 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']}"
|
||||
|
||||
|
||||
|
||||
|
||||
# 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"]
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "DEBUG_PRINT_LIMIT=0 vitest run --reporter=dot --no-color",
|
||||
"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": {
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
|
||||
@@ -51,8 +51,13 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
||||
const min = Math.min(...validMinutes);
|
||||
const max = Math.max(...validMinutes);
|
||||
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]);
|
||||
|
||||
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
|
||||
@@ -80,8 +85,8 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
||||
domain={['dataMin - 30', 'dataMax + 30']}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [
|
||||
formatMinutesAfter(value, bedtime_ref),
|
||||
formatter={(value: unknown) => [
|
||||
typeof value === 'number' ? formatMinutesAfter(value, bedtime_ref) : String(value ?? ''),
|
||||
'Bedtime',
|
||||
]}
|
||||
labelFormatter={(label) => label}
|
||||
@@ -97,7 +102,7 @@ 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 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>
|
||||
@@ -110,6 +115,10 @@ const BedtimePlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_
|
||||
<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>
|
||||
|
||||
@@ -54,7 +54,13 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
||||
const max = Math.max(...validMinutes);
|
||||
const avg = validMinutes.reduce((sum, m) => sum + m, 0) / validMinutes.length;
|
||||
|
||||
return { min, max, avg };
|
||||
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, median };
|
||||
}, [wakeupBedtime]);
|
||||
|
||||
if (isLoading) return <div className="text-center p-4">Loading plot...</div>;
|
||||
@@ -82,8 +88,8 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
||||
domain={['dataMin - 30', 'dataMax + 30']}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [
|
||||
formatMinutesAfter(value, wakeup_ref),
|
||||
formatter={(value: unknown) => [
|
||||
typeof value === 'number' ? formatMinutesAfter(value, wakeup_ref) : String(value ?? ''),
|
||||
'Wakeup',
|
||||
]}
|
||||
labelFormatter={(label) => label}
|
||||
@@ -99,7 +105,7 @@ const WakeupPlot: React.FC<WakeupBedtimeProps> = ({ from_date, to_date, wakeup_r
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
{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, 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-sm font-semibold text-gray-800">{formatMinutesAfter(Math.round(stats.avg), wakeup_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), wakeup_ref)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"use strict";
|
||||
@@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -1,351 +1,37 @@
|
||||
# Proposed Implementation Plan
|
||||
# Plan: Add Median Statistic to Wakeup and Bedtime Plots
|
||||
|
||||
## Phase 1: Settings Update
|
||||
* **`frontend/src/hooks/useSettings.ts`**: Add `sessionsPerPage` to the settings state and define a default value (e.g., 10).
|
||||
* **`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.
|
||||
## Overview
|
||||
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".
|
||||
|
||||
## Phase 2: Pagination UI and Logic
|
||||
* **`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.
|
||||
## Detailed Tasks
|
||||
|
||||
## Phase 3: Verification
|
||||
* Verify that changing "Results per page" in Settings correctly updates the list.
|
||||
* Confirm pagination navigation (Prev/Next/Numbers) works as expected and handles edge cases (e.g., first/last page).
|
||||
### 1. Update `frontend/src/components/stats/WakeupPlot.tsx`
|
||||
- **Sub-task 1.1: Update Statistics Calculation**
|
||||
- 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)`.
|
||||
|
||||
---
|
||||
|
||||
The user wants to implement client-side pagination for "Sleep Sessions".
|
||||
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.
|
||||
## Verification Plan
|
||||
1. **Visual Inspection**: Ensure the four statistics are evenly spaced and aligned in a single row below each graph.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user