Update stats
This commit is contained in:
@@ -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
|
- **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, 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
|
- **No CI/CD**: No .github/workflows, no Makefiles yet
|
||||||
|
|
||||||
## ANTI-PATTERNS (THIS PROJECT)
|
## ANTI-PATTERNS (THIS PROJECT)
|
||||||
|
|||||||
@@ -111,3 +111,24 @@ async def update_session(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(sleep_session)
|
await session.refresh(sleep_session)
|
||||||
return 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
|
||||||
|
|||||||
@@ -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
|
from app.crud import get_sessions, get_all_time_stats
|
||||||
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
|
||||||
|
|
||||||
@@ -112,9 +112,13 @@ async def get_summary(
|
|||||||
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)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_sleeps": total_sleeps,
|
"total_sleeps": total_sleeps,
|
||||||
"avg_sleeps_per_day": avg_sleeps_per_day,
|
"avg_sleeps_per_day": avg_sleeps_per_day,
|
||||||
|
"total_sleeps_all_time": total_sleeps_all_time,
|
||||||
|
"avg_sleeps_per_day_all_time": avg_sleeps_per_day_all_time,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,4 +62,6 @@ class WakeupBedtimeResponse(BaseModel):
|
|||||||
class SummaryResponse(BaseModel):
|
class SummaryResponse(BaseModel):
|
||||||
total_sleeps: int
|
total_sleeps: int
|
||||||
avg_sleeps_per_day: float
|
avg_sleeps_per_day: float
|
||||||
|
total_sleeps_all_time: int
|
||||||
|
avg_sleeps_per_day_all_time: float
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -69,5 +69,40 @@ async def test_timetable(setup_db):
|
|||||||
f"/api/stats/timetable?from_date={from_date}&to_date={to_date}"
|
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 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
|
||||||
|
|
||||||
|
|||||||
@@ -10,24 +10,46 @@ const SummaryCards: React.FC<SummaryCardsProps> = ({ summary }) => {
|
|||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<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 flex items-center space-x-4">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
<div className="p-3 bg-indigo-50 rounded-2xl text-indigo-600">
|
<div className="p-3 bg-indigo-50 rounded-2xl text-indigo-600">
|
||||||
<Moon size={28} />
|
<Moon size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-500">Total Sleeps</p>
|
<p className="text-xs font-medium text-gray-500">Total sleeps in period</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{summary.total_sleeps}</p>
|
<p className="text-lg font-bold text-gray-900">{summary.total_sleeps}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-[2rem] shadow-md border border-gray-100 flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="p-3 bg-amber-50 rounded-2xl text-amber-600">
|
<div className="p-3 bg-amber-50 rounded-2xl text-amber-600">
|
||||||
<Calendar size={28} />
|
<Calendar size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-500">Avg Sleeps / Day</p>
|
<p className="text-xs font-medium text-gray-500">Avg sleeps per day in period</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{summary.avg_sleeps_per_day.toFixed(1)}</p>
|
<p className="text-lg font-bold text-gray-900">{summary.avg_sleeps_per_day.toFixed(1)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-3 bg-indigo-100 rounded-2xl text-indigo-700">
|
||||||
|
<Moon size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Total sleeps (all time)</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{summary.total_sleeps_all_time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-3 bg-amber-100 rounded-2xl text-amber-700">
|
||||||
|
<Calendar size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500">Avg sleeps per day (all time)</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{summary.avg_sleeps_per_day_all_time.toFixed(1)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface SummaryResponse {
|
export interface SummaryResponse {
|
||||||
total_sleeps: number;
|
total_sleeps: number;
|
||||||
avg_sleeps_per_day: number;
|
avg_sleeps_per_day: number;
|
||||||
|
total_sleeps_all_time: number;
|
||||||
|
avg_sleeps_per_day_all_time: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SleepSession {
|
export interface SleepSession {
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
- [ ] Check so that all tests actually make sense, maybe some are now obsolete, and some are missing
|
- [ ] 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%
|
- [ ] 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
|
- [ ] 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user