9 Commits

Author SHA1 Message Date
jacobh2 db0f717560 Add relative base, this is test to see if it works to proxy via HA
Build Backend / build (push) Failing after 2m16s
Build Frontend / build (push) Failing after 9m59s
2026-06-03 21:51:45 +02:00
jacobh2 aa4e28e7f6 chore: cleanup deleted playground.js file 2026-06-03 13:47:03 +02:00
jacobh2 837c3b84cb chore: ignore JS/JSX files and add clean script to frontend 2026-06-03 13:45:43 +02:00
jacobh2 96fb3cb988 docs: update plan with implementation details 2026-06-03 13:40:37 +02:00
jacobh2 8eb5946108 feat(frontend): add median statistic to wakeup and bedtime plots 2026-06-03 13:37:17 +02:00
jacobh2 7e3bd0a16b fix: prevent double-counting of sleep sessions in stats by grouping by start date 2026-06-03 09:20:33 +02:00
jacobh2 c371a3d8e8 fix(frontend): prevent RangeError in TimeAdjuster and add validation to SleepButton
Build Frontend / build (push) Failing after 3m15s
2026-06-02 21:20:38 +02:00
jacobh2 13bc53ea59 And not it says it is done
Build Frontend / build (push) Failing after 2m45s
2026-05-31 17:45:32 +02:00
jacobh2 cdc57a441b Pagination (but according to agent, not done) 2026-05-31 17:40:03 +02:00
32 changed files with 7574 additions and 239 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
+55 -24
View File
@@ -1,13 +1,12 @@
# PROJECT KNOWLEDGE BASE # PROJECT BABY MONITOR
**Generated:** 2026-04-23
**Mode:** init-deep (create-new)
## OVERVIEW ## OVERVIEW
Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack monorepo: React 19/Vite 6/TypeScript frontend + Python 3.14/FastAPI backend. Optimized for Raspberry Pi 4 (ARM64). Baby monitor - self-hosted web app for tracking infant sleep. Dual-stack monorepo: React 19/Vite 6/TypeScript frontend + Python 3.14/FastAPI backend. Optimized for Raspberry Pi 4 (ARM64).
## STRUCTURE ## STRUCTURE
``` ```
./ ./
├── frontend/ # React 19 SPA ├── frontend/ # React 19 SPA
@@ -21,6 +20,7 @@ Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack m
│ │ └── utils/ │ │ └── utils/
│ └── tests/ # e2e (Playwright), unit (Vitest) │ └── tests/ # e2e (Playwright), unit (Vitest)
├── backend/ # FastAPI + SQLModel ├── backend/ # FastAPI + SQLModel
│ ├── scripts/ # one-off scripts for migrations / fixes / etc
│ ├── app/ │ ├── app/
│ │ ├── routers/ # API endpoints │ │ ├── routers/ # API endpoints
│ │ └── utils/ │ │ └── utils/
@@ -40,37 +40,68 @@ Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack m
## CONVENTIONS ## CONVENTIONS
- **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio - **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio.
- **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet) - **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet).
- **Python**: >=3.14 required, Hatch for packaging - **Python**: >=3.14 required. Managed via `uv` with Hatch backend for packaging.
- **Scripts**: All scripts created in the `backend` folder must be placed in `backend/scripts/`. - **Scripts**: All scripts created in the backend folder must be placed explicitly in `backend/scripts/`.
- **No CI/CD**: No .github/workflows, no Makefiles yet - **CI/CD**: Deployment is managed via Gitea workflows (`.gitea/` directory). Do not create `.github/` directories or Makefiles.
## ANTI-PATTERNS (THIS PROJECT) ## ANTI-PATTERNS (CRITICAL GUARDRAILS)
- No type suppression (`as any`, `@ts-ignore`, `@ts-expect-error`) - type safety enforced - **Type Safety**: No type suppression (`as any`, `@ts-ignore`, `@ts-expect-error`). Type safety is strictly enforced.
- No empty catch blocks - error handling required - **Error Handling**: No empty catch blocks. Explicit error handling/logging is required.
- No deleting failing tests to "pass" - **Testing Integrity**: NEVER delete or comment out failing tests to make a suite "pass". Fix the code or update the test properly.
- **Frontend JS Slop**: NEVER create `.js` files for the frontend. Use only TypeScript (`.ts`, `.tsx`) and write high-quality, type-safe code. Do not repeat this mistake. - **Frontend JS Slop**: NEVER create `.js` or `.jsx` files for the frontend. Use only TypeScript (`.ts`, `.tsx`). Write clean, type-safe code.
- **Assumption Error**: NEVER assume the content of ANY files EVER. ALWAYS read the file to confirm. Do not rely on the name. - **Assumption Error**: NEVER assume the content of ANY files. ALWAYS read the target file to confirm its structure and implementation before modifying or referencing it.
## COMMANDS ## COMMANDS
### Backend Development
```bash ```bash
# Backend cd backend
cd backend && uv sync uv sync
uv run uvicorn app.main:app --reload uv run uvicorn app.main:app --reload
uv run pytest uv run pytest
```
# Frontend ### Frontend Development
cd frontend && npm install
```bash
cd frontend
npm install
npm run dev npm run dev
# Use 'npm test' with caution as it may hang; use specific test scripts instead.
# WARNING: 'npm test' may hang if run globally. Use specific file paths or vitest flags.
npm run test npm run test
``` ```
## 🛑 DIRECTORY NAVIGATION & FILE DISCOVERY
**Context:** This is a full-stack monorepo containing massive generated/dependency folders (`node_modules`, `dist`, `venv`, `.venv`, `__pycache__`, etc.). Unrestricted file listings will bloat your context window, crash your session, and cause severe token waste.
* **NEVER use `ls -R`:** This command is strictly forbidden in this repository.
* **Primary Discovery:** To map out files or explore the project tree, **ALWAYS use `git ls-files**`. This is your default command and ensures you only interact with human-written source code.
* **Structural Context:** If you explicitly need a visual directory layout to understand architecture, use `tree` appended with the mandatory ignore flags:
```bash
tree -I 'node_modules|.git|dist|build|__pycache__|venv|.venv|env|.pytest_cache|.mypy_cache|.ruff_cache|*.egg-info'
```
* **Searching Code:** If you need to search for text or code blocks across the repo, **ALWAYS use `git grep "search_term"**` instead of standard `grep -r` to avoid crawling ignored or minified assets.
## 🧠 TASK DELEGATION & CONTEXT PRESERVATION
**Context:** The main OpenCode agent context window is our most critical resource. Executing complex tasks, debugging cross-file issues, or writing massive features directly in the main thread causes severe context bloat, leading to code hallucinations and dropped instructions.
* **Delegate Aggressively:** For any complex implementation, structural change, or task larger than a single file edit, you **MUST spawn a sub-agent**. Act strictly as the architectural orchestrator; let sub-agents handle the iterative coding labor.
* **Scope Narrowly:** Give sub-agents highly specific, isolated, and atomic objectives (e.g., *"Read `backend/app/routers/auth.py` and summarize endpoints"* or *"Write a unit test for the frontend sleep tracker component"*).
* **Strict Summarization:** Sub-agents are **STRICTLY FORBIDDEN** from passing raw terminal dumps, unedited file blocks, or their internal step-by-step logs back to the main thread.
* **Return Format:** Sub-agents must return *only* clear, actionable results back to the orchestrator: a concise summary of findings, the exact lines of code that require modification, or direct confirmation of a successful execution.
## NOTES ## NOTES
- Entry point: `frontend/src/main.tsx` + `backend/app/main.py` * **Entry Points:** `frontend/src/main.tsx` (Frontend) & `backend/app/main.py` (Backend).
- DB: SQLite via aiosqlite (async-first backend) * **Database:** Local SQLite managed via `aiosqlite` for an entirely async-first data layer.
- Build: Docker for ARM64 Raspberry Pi deployment * **Target Architecture:** Built specifically for ARM64 deployment on a Raspberry Pi 4 via Docker.
+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
+17 -5
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,13 +106,25 @@ 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,
+29 -54
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)
async with AsyncClient( new_session = SleepSession(start_time=start_time, end_time=end_time)
transport=ASGITransport(app=app), base_url="http://test" db_session.add(new_session)
) as ac: await db_session.commit()
# 1. Create a session that starts LATER in the day on OTHER date (should be excluded) break # Exit after one iteration
# 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()})
# 2. Create a session that starts ON the target_date (should be included) # 2. Call the stats endpoint for a period that includes this session
start_time_target = target_date.replace(hour=20, minute=0) from_date = "2024-01-01T00:00:00"
resp_target = await ac.post("/api/sessions", json={"start_time": start_time_target.isoformat()}) to_date = "2024-01-02T23:59:59"
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) response = await api_client.get(f"/api/stats/summary?from_date={from_date}&to_date={to_date}")
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) assert response.status_code == 200
start_time_other2 = other_date.replace(hour=2, minute=0) data = response.json()
resp_other2 = await ac.post("/api/sessions", json={"start_time": start_time_other2.isoformat()})
assert resp_other2.status_code == 201 # 3. Verify that total_sleeps is exactly 1 for the requested period
session_id_other2 = resp_other2.json()["id"] print(f"DEBUG: data={data}")
await ac.patch(f"/api/sessions/{session_id_other2}/end", json={"end_time": (start_time_other2 + timedelta(minutes=30)).isoformat()}) 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']}"
# 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"]
+247
View File
@@ -0,0 +1,247 @@
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() {
+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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+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>
); );
@@ -0,0 +1,104 @@
import React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
// Always include first page
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
// Calculate middle pages
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
if (!pages.includes(i)) {
pages.push(i);
}
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
// Always include last page
if (!pages.includes(totalPages)) {
pages.push(totalPages);
}
}
return pages;
};
const pages = getPageNumbers();
return (
<div data-testid="pagination" className="flex items-center justify-center gap-1 mt-6 py-4">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-gray-600"
aria-label="Previous page"
>
<ChevronLeft size={20} />
</button>
<div className="flex items-center gap-1">
{pages.map((page, idx) => {
if (page === '...') {
return (
<div key={`ellipsis-${idx}`} className="px-2 text-gray-400">
<MoreHorizontal size={16} />
</div>
);
}
const pageNum = page as number;
const isActive = currentPage === pageNum;
return (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={`min-w-[36px] h-9 px-2 rounded-lg text-sm font-medium transition-all ${
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-gray-600"
aria-label="Next page"
>
<ChevronRight size={20} />
</button>
</div>
);
}
+23 -2
View File
@@ -1,15 +1,29 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useSessions } from '../../hooks/useSleepSession'; import { useSessions } from '../../hooks/useSleepSession';
import { useSettings } from '../../hooks/useSettings';
import { Pagination } from './Pagination';
import { Edit2, Check, X, Trash2 } from 'lucide-react'; import { Edit2, Check, X, Trash2 } from 'lucide-react';
import type { SleepSessionRead } from '../../types'; import type { SleepSessionRead } from '../../types';
import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time'; import { formatDateTime, toLocalDatetimeInputValue, getDurationMinutes, formatMinutes } from '../../utils/time';
export function SessionList() { export function SessionList() {
const { sessions, isLoading, updateSession, deleteSession, isDeleting } = useSessions(); 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 [editingId, setEditingId] = useState<number | null>(null);
const [editStart, setEditStart] = useState<string>(''); const [editStart, setEditStart] = useState<string>('');
const [editEnd, setEditEnd] = 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 (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>; if (!sessions.length) return <div className="text-center p-4 text-gray-500">No sessions recorded.</div>;
@@ -52,10 +66,10 @@ export function SessionList() {
}; };
return ( return (
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200"> <div className="session-list 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> <h3 className="text-lg font-semibold mb-4 text-gray-800">Sleep Sessions</h3>
<div className="space-y-2"> <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"> <div key={s.id} className="border-b border-gray-100 last:border-0 py-3 sm:py-2">
{editingId === s.id ? ( {editingId === s.id ? (
<div className="flex flex-col space-y-3 py-1"> <div className="flex flex-col space-y-3 py-1">
@@ -140,6 +154,13 @@ export function SessionList() {
</div> </div>
))} ))}
</div> </div>
{sessions.length > sessionsPerPage && (
<Pagination
currentPage={clampedPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
)}
</div> </div>
); );
} }
@@ -6,15 +6,17 @@ interface SettingsPopoverProps {
dayEnd: string; dayEnd: string;
wakeupRef: string; wakeupRef: string;
bedtimeRef: 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 [isOpen, setIsOpen] = useState(false);
const [localDayStart, setLocalDayStart] = useState(dayStart); const [localDayStart, setLocalDayStart] = useState(dayStart);
const [localDayEnd, setLocalDayEnd] = useState(dayEnd); const [localDayEnd, setLocalDayEnd] = useState(dayEnd);
const [localWakeupRef, setLocalWakeupRef] = useState(wakeupRef); const [localWakeupRef, setLocalWakeupRef] = useState(wakeupRef);
const [localBedtimeRef, setLocalBedtimeRef] = useState(bedtimeRef); const [localBedtimeRef, setLocalBedtimeRef] = useState(bedtimeRef);
const [localSessionsPerPage, setLocalSessionsPerPage] = useState(sessionsPerPage);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -22,7 +24,8 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
setLocalDayEnd(dayEnd); setLocalDayEnd(dayEnd);
setLocalWakeupRef(wakeupRef); setLocalWakeupRef(wakeupRef);
setLocalBedtimeRef(bedtimeRef); setLocalBedtimeRef(bedtimeRef);
}, [dayStart, dayEnd, wakeupRef, bedtimeRef]); setLocalSessionsPerPage(sessionsPerPage);
}, [dayStart, dayEnd, wakeupRef, bedtimeRef, sessionsPerPage]);
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
@@ -42,6 +45,7 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
dayEnd: localDayEnd, dayEnd: localDayEnd,
wakeupRef: localWakeupRef, wakeupRef: localWakeupRef,
bedtimeRef: localBedtimeRef, bedtimeRef: localBedtimeRef,
sessionsPerPage: localSessionsPerPage,
}); });
setIsOpen(false); setIsOpen(false);
}; };
@@ -65,44 +69,58 @@ export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSav
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label> <label htmlFor="day-start" className="block text-xs text-gray-500 mb-1">Day starts (HH:MM)</label>
<input <input
id="day-start" id="day-start"
type="time" type="time"
value={localDayStart} value={localDayStart}
onChange={(e) => setLocalDayStart(e.target.value)} onChange={(e) => setLocalDayStart(e.target.value)}
className="w-full p-2 border rounded-md text-sm" className="w-full p-2 border rounded-md text-sm"
/> />
</div> </div>
<div> <div>
<label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label> <label htmlFor="day-end" className="block text-xs text-gray-500 mb-1">Day ends (HH:MM)</label>
<input <input
id="day-end" id="day-end"
type="time" type="time"
value={localDayEnd} value={localDayEnd}
onChange={(e) => setLocalDayEnd(e.target.value)} onChange={(e) => setLocalDayEnd(e.target.value)}
className="w-full p-2 border rounded-md text-sm" className="w-full p-2 border rounded-md text-sm"
/> />
</div> </div>
<div> <div>
<label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label> <label htmlFor="wakeup-ref" className="block text-xs text-gray-500 mb-1">Wakeup ref (HH:MM)</label>
<input <input
id="wakeup-ref" id="wakeup-ref"
type="time" type="time"
value={localWakeupRef} value={localWakeupRef}
onChange={(e) => setLocalWakeupRef(e.target.value)} onChange={(e) => setLocalWakeupRef(e.target.value)}
className="w-full p-2 border rounded-md text-sm" className="w-full p-2 border rounded-md text-sm"
/> />
</div> </div>
<div> <div>
<label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label> <label htmlFor="bedtime-ref" className="block text-xs text-gray-500 mb-1">Bedtime ref (HH:MM)</label>
<input <input
id="bedtime-ref" id="bedtime-ref"
type="time" type="time"
value={localBedtimeRef} value={localBedtimeRef}
onChange={(e) => setLocalBedtimeRef(e.target.value)} onChange={(e) => setLocalBedtimeRef(e.target.value)}
className="w-full p-2 border rounded-md text-sm" 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>
</div> </div>
+14 -12
View File
@@ -27,18 +27,20 @@ const StatsPage: React.FC = () => {
<h2 className="text-3xl font-extrabold text-slate-900 tracking-tight">Statistics</h2> <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> <p className="text-slate-500 text-sm mt-1">Overview of sleep patterns and trends</p>
</div> </div>
<SettingsPopover <SettingsPopover
dayStart={settings.dayStart} dayStart={settings.dayStart}
dayEnd={settings.dayEnd} dayEnd={settings.dayEnd}
wakeupRef={settings.wakeupRef} wakeupRef={settings.wakeupRef}
bedtimeRef={settings.bedtimeRef} bedtimeRef={settings.bedtimeRef}
onSave={(s) => { sessionsPerPage={settings.sessionsPerPage}
update('dayStart', s.dayStart); onSave={(s) => {
update('dayEnd', s.dayEnd); update('dayStart', s.dayStart);
update('wakeupRef', s.wakeupRef); update('dayEnd', s.dayEnd);
update('bedtimeRef', s.bedtimeRef); update('wakeupRef', s.wakeupRef);
}} update('bedtimeRef', s.bedtimeRef);
/> update('sessionsPerPage', s.sessionsPerPage);
}}
/>
</div> </div>
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100"> <div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100">
+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>
@@ -10,6 +10,10 @@ const SleepButton: React.FC<SleepButtonProps> = ({ time }) => {
const isSleeping = !!activeSession; const isSleeping = !!activeSession;
const handleClick = async () => { const handleClick = async () => {
if (!time) {
alert('Please select a valid time.');
return;
}
try { try {
if (isSleeping && activeSession?.id) { if (isSleeping && activeSession?.id) {
await endSession({ sessionId: activeSession.id, endTime: time }); await endSession({ sessionId: activeSession.id, endTime: time });
+57
View File
@@ -0,0 +1,57 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
const STORAGE_KEY = 'baby-sleep-settings';
export interface Settings {
dayStart: string;
dayEnd: string;
wakeupRef: string;
bedtimeRef: string;
statsInterval: number;
sessionsPerPage: number;
}
interface SettingsContextType {
settings: Settings;
update: (key: keyof Settings, value: string | number) => void;
}
export const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
const DEFAULT_SETTINGS: Settings = {
dayStart: '07:00',
dayEnd: '20:00',
wakeupRef: '04:00',
bedtimeRef: '19:00',
statsInterval: 7,
sessionsPerPage: 10,
};
export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<Settings>(() => {
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
} catch {
return DEFAULT_SETTINGS;
}
}
return DEFAULT_SETTINGS;
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings]);
const update = (key: keyof Settings, value: string | number) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
return (
<SettingsContext.Provider value={{ settings, update }}>
{children}
</SettingsContext.Provider>
);
}
+7 -41
View File
@@ -1,44 +1,10 @@
import { useState, useEffect } from 'react'; import { useContext } from 'react';
import { SettingsContext, Settings } from './SettingsProvider';
const STORAGE_KEY = 'baby-sleep-settings';
interface Settings {
dayStart: string;
dayEnd: string;
wakeupRef: string;
bedtimeRef: string;
statsInterval: number;
}
const DEFAULT_SETTINGS: Settings = {
dayStart: '07:00',
dayEnd: '20:00',
wakeupRef: '04:00',
bedtimeRef: '19:00',
statsInterval: 7,
};
export function useSettings() { export function useSettings() {
const [settings, setSettings] = useState<Settings>(() => { const context = useContext(SettingsContext);
if (typeof window === 'undefined') return DEFAULT_SETTINGS; if (context === undefined) {
const stored = localStorage.getItem(STORAGE_KEY); throw new Error('useSettings must be used within a SettingsProvider');
if (stored) { }
try { return context;
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
} catch {
return DEFAULT_SETTINGS;
}
}
return DEFAULT_SETTINGS;
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings]);
const update = (key: keyof Settings, value: string | number) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
return { settings, update };
} }
+5 -2
View File
@@ -2,14 +2,17 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import './index.css' import './index.css'
import App from './App' import App from './App'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { SettingsProvider } from './hooks/SettingsProvider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient() const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <SettingsProvider>
<App />
</SettingsProvider>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
) )
-1
View File
@@ -1 +0,0 @@
"use strict";
+3
View File
@@ -90,6 +90,9 @@ export function formatDateTime(date: Date | string): string {
*/ */
export function toUTCFromLocalInput(inputValue: string): string { export function toUTCFromLocalInput(inputValue: string): string {
const d = new Date(inputValue); const d = new Date(inputValue);
if (isNaN(d.getTime())) {
return "";
}
return d.toISOString(); return d.toISOString();
} }
+127
View File
@@ -0,0 +1,127 @@
import { test, expect } from '@playwright/test';
test.describe('Pagination', () => {
test.beforeEach(async ({ page }) => {
// Monitor network requests to see if our mock is hit
page.on('request', request => console.log('>>', request.method(), request.url()));
page.on('response', response => console.log('<<', response.status(), response.url()));
// Mock the sessions API
await page.route('**/api/sessions', async (route) => {
const mockSessions = Array.from({ length: 15 }, (_, i) => ({
id: i + 1,
start_time: new Date(2024, 0, 1 + i).toISOString(),
end_time: new Date(2024, 0, 1 + i, 8, 0).toISOString(),
notes: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ sessions: mockSessions, total: 15 }),
});
});
// Mock other stats APIs to prevent crashes from accessing undefined properties
await page.route(/\/api\/stats\/.*/, async (route) => {
const url = route.request().url();
let body = {};
if (url.includes('summary')) {
body = {
total_sleeps: 10,
avg_sleeps_per_day: 2.5,
total_sleeps_all_time: 100,
avg_sleeps_per_day_all_time: 2.0,
};
} else if (url.includes('daily-summary')) {
body = {
days: [
{ date: '2024-01-01', total_minutes: 480, day_minutes: 240, night_minutes: 240 },
],
};
} else if (url.includes('timetable')) {
body = {
slots: [],
};
} else if (url.includes('wake-sleep-times')) {
body = {
days: [
{ date: '2024-01-01', wakeup_minutes: 480, bedtime_minutes: 1200 },
],
};
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
await page.goto('http://localhost:5173/stats');
});
test('Pagination component is visible when sessions exceed page size', async ({ page }) => {
// Wait for one of the mocked dates to appear to ensure data is loaded
await expect(page.locator('body')).toContainText('2024-01-01', { timeout: 10000 });
await page.screenshot({ path: 'screenshots/pagination_visible.png' });
const pagination = page.getByTestId('pagination');
await expect(pagination).toBeVisible();
// Use a more specific selector for the first page button
await expect(page.getByTestId('pagination').getByRole('button', { name: '1' })).toBeVisible();
});
test('Changing results per page in settings updates the list', async ({ page }) => {
// Wait for data to load first
await expect(page.locator('body')).toContainText('2024-01-01', { timeout: 10000 });
await page.screenshot({ path: 'screenshots/before_settings_change.png' });
// Open Settings
await page.getByRole('button', { name: /Settings/i }).click();
await expect(page.locator('text=Results per page')).toBeVisible();
// Change to 5
await page.getByLabel(/Results per page/i).selectOption('5');
await page.getByRole('button', { name: /Save/i }).click();
// Wait for the UI to update and settings to be saved
await page.waitForTimeout(2000);
await page.screenshot({ path: 'screenshots/after_settings_change.png' });
// Verify sessions per page is 5 (only 5 should be visible)
const sessionRows = page.locator('.session-list .border-b');
await expect(sessionRows).toHaveCount(5);
// Verify settings is saved in localStorage
const localStorage = await page.evaluate(() => {
return window.localStorage.getItem('baby-sleep-settings');
});
expect(localStorage).toContain('"sessionsPerPage":5');
});
test('Navigating through pages works', async ({ page }) => {
// Wait for data to load first
await expect(page.locator('body')).toContainText('2024-01-01', { timeout: 10000 });
// Set to 5 via settings first
await page.getByRole('button', { name: /Settings/i }).click();
await page.getByLabel(/Results per page/i).selectOption('5');
await page.getByRole('button', { name: /Save/i }).click();
await page.waitForTimeout(2000);
const nextButton = page.getByRole('button', { name: /Next/i });
await expect(nextButton).toBeVisible();
await nextButton.click();
await page.waitForTimeout(1000);
// Should now show sessions from index 5 to 9 (if total is 15)
// Mocked start_time for i=5 is 2024-01-06
await expect(page.locator('.text-gray-900').filter({ hasText: '2024-01-06' }).first()).toBeVisible();
});
});
+16
View File
@@ -70,4 +70,20 @@ describe('SleepButton', () => {
await waitFor(() => expect(endSessionMock).toHaveBeenCalled()); await waitFor(() => expect(endSessionMock).toHaveBeenCalled());
}); });
it('shows alert when time is invalid', async () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
(useSleepSession as any).mockReturnValue({
activeSession: null,
isCreating: false,
isEnding: false,
});
renderWithProviders(<SleepButton time="" />);
const button = screen.getByRole('button', { name: /Start Sleep/i });
fireEvent.click(button);
expect(alertSpy).toHaveBeenCalledWith('Please select a valid time.');
alertSpy.mockRestore();
});
}); });
+14
View File
@@ -29,4 +29,18 @@ describe('TimeAdjuster', () => {
fireEvent.click(resetButton); fireEvent.click(resetButton);
expect(handleChange).toHaveBeenCalled(); expect(handleChange).toHaveBeenCalled();
}); });
it('handles invalid date input gracefully (returns empty string)', async () => {
// Since we changed the implementation, it should no longer throw.
// We can't easily test the useEffect error anymore because it doesn't throw,
// but we can check if onTimeChange is called with "" for invalid input.
const handleChange = vi.fn();
const { container } = render(<TimeAdjuster onTimeChange={handleChange} />);
const input = container.querySelector('input');
fireEvent.change(input!, { target: { value: '2026-06-0' } });
// We might need to wait for the effect
await new Promise((r) => setTimeout(r, 0));
expect(handleChange).toHaveBeenCalledWith("");
});
}); });
+16
View File
@@ -12,6 +12,7 @@ import {
adjustForTimezone, adjustForTimezone,
getLocalOffsetFromReference, getLocalOffsetFromReference,
formatDateTime, formatDateTime,
toUTCFromLocalInput,
} from '../../src/utils/time'; } from '../../src/utils/time';
describe('formatMinutesAfter', () => { describe('formatMinutesAfter', () => {
@@ -152,3 +153,18 @@ describe('getLocalOffsetFromReference', () => {
expect(offset).toBe(localMins - refMins); expect(offset).toBe(localMins - refMins);
}); });
}); });
describe('toUTCFromLocalInput', () => {
it('converts valid datetime-local string to ISO UTC string', () => {
const input = '2024-01-15T10:00';
// This is tricky because it depends on local timezone.
// But we just want to see if it works for a valid input.
const result = toUTCFromLocalInput(input);
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/);
});
it('returns empty string on invalid datetime-local string', () => {
expect(toUTCFromLocalInput('2026-06-0')).toBe('');
});
});
+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: {
+37
View File
@@ -0,0 +1,37 @@
# Plan: Add Median Statistic to Wakeup and Bedtime Plots
## 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".
## Detailed Tasks
### 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)`.
### 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.
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+6653
View File
File diff suppressed because it is too large Load Diff