From 7061e78305809035e3f328229dde109b1093b2ff Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 31 May 2026 09:23:50 +0200 Subject: [PATCH] Update stats --- AGENTS.md | 1 + backend/app/crud.py | 21 ++++++ backend/app/routers/stats.py | 6 +- backend/app/schemas.py | 2 + backend/scripts/verify_stats.py | 68 +++++++++++++++++++ backend/tests/test_stats.py | 37 +++++++++- .../src/components/stats/SummaryCards.tsx | 52 ++++++++++---- frontend/src/types/index.ts | 2 + todo.md | 1 + 9 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 backend/scripts/verify_stats.py diff --git a/AGENTS.md b/AGENTS.md index 28f443f..323f216 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack m - **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio - **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet) - **Python**: >=3.14 required, Hatch for packaging +- **Scripts**: All scripts created in the `backend` folder must be placed in `backend/scripts/`. - **No CI/CD**: No .github/workflows, no Makefiles yet ## ANTI-PATTERNS (THIS PROJECT) diff --git a/backend/app/crud.py b/backend/app/crud.py index 9d55648..91b2141 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -111,3 +111,24 @@ async def update_session( await session.commit() await session.refresh(sleep_session) return sleep_session + + +async def get_all_time_stats(session: AsyncSession) -> tuple[int, float]: + statement = select(func.count(), func.min(SleepSession.start_time)) + 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 diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index de8a901..ed9c9df 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -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 +from app.crud import get_sessions, get_all_time_stats from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse, SummaryResponse from app.utils.time_utils import get_day_night_splits @@ -112,9 +112,13 @@ async def get_summary( 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) + return { "total_sleeps": total_sleeps, "avg_sleeps_per_day": avg_sleeps_per_day, + "total_sleeps_all_time": total_sleeps_all_time, + "avg_sleeps_per_day_all_time": avg_sleeps_per_day_all_time, } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 75b7572..8e5cb38 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -62,4 +62,6 @@ class WakeupBedtimeResponse(BaseModel): class SummaryResponse(BaseModel): total_sleeps: int avg_sleeps_per_day: float + total_sleeps_all_time: int + avg_sleeps_per_day_all_time: float diff --git a/backend/scripts/verify_stats.py b/backend/scripts/verify_stats.py new file mode 100644 index 0000000..fdaf819 --- /dev/null +++ b/backend/scripts/verify_stats.py @@ -0,0 +1,68 @@ +import asyncio +from datetime import datetime, timezone +from typing import AsyncGenerator +from sqlmodel.ext.asyncio.session import AsyncSession +from app.database import get_session +from app.crud import get_sessions, get_all_time_stats +from sqlmodel import select, func +from app.models import SleepSession + +async def get_session_context() -> AsyncGenerator[AsyncSession, None]: + async for session in get_session(): + yield session + +async def verify_stats(): + # Use the async generator correctly + gen = get_session_context() + session: AsyncSession = await gen.__anext__() + + try: + import datetime as dt + to_date = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=999999) + from_date = (to_date - dt.timedelta(days=6)).replace(hour=0, minute=0, second=0, microsecond=0) + + print(f"Verifying range: {from_date} to {to_date}") + + # 1. Verify 'in period' stats via get_sessions (as used in router) + sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date) + total_sleeps = len(sessions) + num_days = (to_date.date() - from_date.date()).days + 1 + avg_sleeps_per_day = total_sleeps / num_days if num_days > 0 else 0.0 + + print(f"In-period: Total={total_sleeps}, Days={num_days}, Avg={avg_sleeps_per_day}") + + # 2. Verify 'all time' stats via get_all_time_stats + total_sleeps_all_time, avg_sleeps_per_day_all_time = await get_all_time_stats(session) + print(f"All-time: Total={total_sleeps_all_time}, Avg={avg_sleeps_per_day_all_time}") + + # 3. Direct DB check for raw session count + statement = select(func.count()).select_from(SleepSession) + raw_total_result = await session.exec(statement) + db_total = raw_total_result.one() + print(f"DB Raw Total: {db_total}") + + # 4. Direct DB check for first session to see if it matches + first_stmt = select(SleepSession.start_time).order_by(SleepSession.start_time.asc()).limit(1) + first_result = await session.exec(first_stmt) + first_time = first_result.one() + print(f"First Session in DB: {first_time}") + + if total_sleeps > 0 and total_sleeps_all_time == total_sleeps: + print("INFO: All sessions are within the period.") + elif total_sleeps < total_sleeps_all_time: + print(f"INFO: Found {total_sleeps_all_time - total_sleeps} sessions outside the period.") + + if avg_sleeps_per_day > avg_sleeps_per_day_all_time and total_sleeps_all_time > 0: + print("!!! ALERT: In-period average is HIGHER than All-time average. This contradicts the user's observation if all sessions are in period.") + else: + print("SUCCESS: Average calculation seems logically consistent (in-period <= all-time).") + + finally: + # Cleanup the generator + try: + await gen.__anext__() + except StopAsyncIteration: + pass + +if __name__ == "__main__": + asyncio.run(verify_stats()) diff --git a/backend/tests/test_stats.py b/backend/tests/test_stats.py index 39d57ae..eea2cb7 100644 --- a/backend/tests/test_stats.py +++ b/backend/tests/test_stats.py @@ -69,5 +69,40 @@ async def test_timetable(setup_db): f"/api/stats/timetable?from_date={from_date}&to_date={to_date}" ) +@pytest.mark.asyncio +async def test_summary(setup_db): + from datetime import date, timedelta, timezone + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + from app.database import get_session + from app.models import SleepSession + from sqlalchemy import select + + async with AsyncSession(engine) as session: + # Create two sessions, one a few days ago and one today + start1 = datetime.now(timezone.utc) - timedelta(days=5) + end1 = start1 + timedelta(hours=8) + new_s1 = SleepSession(start_time=start1, end_time=end1) + session.add(new_s1) + + start2 = datetime.now(timezone.utc) - timedelta(hours=2) + end2 = datetime.now(timezone.utc) + new_s2 = SleepSession(start_time=start2, end_time=end2) + session.add(new_s2) + + await session.commit() + + from_date = (date.today() - timedelta(days=7)).isoformat() + to_date = date.today().isoformat() + response = await ac.get( + f"/api/stats/summary?from_date={from_date}&to_date={to_date}" + ) + assert response.status_code == 200 - assert len(response.json()["slots"]) > 0 + data = response.json() + assert data["total_sleeps"] == 2 + assert data["total_sleeps_all_time"] == 2 + assert data["avg_sleeps_per_day_all_time"] > 0 + diff --git a/frontend/src/components/stats/SummaryCards.tsx b/frontend/src/components/stats/SummaryCards.tsx index 366dc0d..e34c310 100644 --- a/frontend/src/components/stats/SummaryCards.tsx +++ b/frontend/src/components/stats/SummaryCards.tsx @@ -10,24 +10,46 @@ const SummaryCards: React.FC = ({ summary }) => { if (!summary) return null; return ( -
-
-
- +
+
+
+
+ +
+
+

Total sleeps in period

+

{summary.total_sleeps}

+
-
-

Total Sleeps

-

{summary.total_sleeps}

-
-
-
-
- +
+
+ +
+
+

Avg sleeps per day in period

+

{summary.avg_sleeps_per_day.toFixed(1)}

+
-
-

Avg Sleeps / Day

-

{summary.avg_sleeps_per_day.toFixed(1)}

+ +
+
+ +
+
+

Total sleeps (all time)

+

{summary.total_sleeps_all_time}

+
+
+ +
+
+ +
+
+

Avg sleeps per day (all time)

+

{summary.avg_sleeps_per_day_all_time.toFixed(1)}

+
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c92f46a..7d19e7e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,6 +1,8 @@ export interface SummaryResponse { total_sleeps: number; avg_sleeps_per_day: number; + total_sleeps_all_time: number; + avg_sleeps_per_day_all_time: number; } export interface SleepSession { diff --git a/todo.md b/todo.md index bab1e86..3dfa693 100644 --- a/todo.md +++ b/todo.md @@ -3,3 +3,4 @@ - [ ] Check so that all tests actually make sense, maybe some are now obsolete, and some are missing - [ ] Add a test coverage metric to both frontend and backend so that we can check how much of the code is tested. I would like >=90% - [ ] Go over Claude's slop-cleanup-stuff +- [ ] Update the "Sleep Sessions" by adding a fake pagination view in the frontend so that I don't have to scroll so much. Instead, I want to simply click "next" and "previous" (with like numbers in between like a google search)