Initial commit

This commit is contained in:
2026-04-24 08:02:13 +02:00
commit 867c06295c
76 changed files with 10573 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "sqlite+aiosqlite:///./data/sleep.db"
CORS_ORIGINS: str = "http://localhost:5173"
LOG_LEVEL: str = "info"
model_config = {"env_file": ".env", "extra": "ignore"}
settings = Settings()
+113
View File
@@ -0,0 +1,113 @@
from datetime import datetime, timezone
from typing import List, Optional, Union
from sqlmodel import select, func
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models import SleepSession
def ensure_aware(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
async def get_sessions(
session: AsyncSession,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = 200,
) -> tuple[List[SleepSession], int]:
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)
# If we have an aware to_date, but the DB is naive,
# we should make it naive for comparison to avoid errors in some drivers.
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
count_statement = select(func.count()).select_from(SleepSession)
if query_from_date:
count_statement = count_statement.where(SleepSession.start_time >= query_from_date)
if query_to_date:
count_statement = count_statement.where(SleepSession.start_time <= query_to_date)
count_result = await session.exec(count_statement)
total = count_result.one() or 0
statement = select(SleepSession).order_by(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)
statement = statement.limit(limit)
result = await session.exec(statement)
sessions = result.all()
return list(sessions), total
async def get_active_session(session: AsyncSession) -> Optional[SleepSession]:
statement = select(SleepSession).where(SleepSession.end_time == None)
result = await session.exec(statement)
return result.first()
async def create_sleep_session(session: AsyncSession, start_time: datetime) -> SleepSession:
new_session = SleepSession(start_time=start_time)
session.add(new_session)
await session.commit()
await session.refresh(new_session)
return new_session
async def end_sleep_session(
session: AsyncSession, session_id: int, end_time: datetime
) -> Union[SleepSession, bool, str, None]:
statement = select(SleepSession).where(SleepSession.id == session_id)
result = await session.exec(statement)
sleep_session = result.first()
if not sleep_session:
return None # Not found
if sleep_session.end_time is not None:
return False # Already ended
# Normalize to aware for comparison
end_time_aware = end_time.replace(tzinfo=timezone.utc) if end_time.tzinfo is None else end_time
start_time_aware = sleep_session.start_time.replace(tzinfo=timezone.utc) if sleep_session.start_time.tzinfo is None else sleep_session.start_time
if end_time_aware < start_time_aware:
return "invalid" # End time before start time
# To store back to naive DB, we strip tzinfo if it was aware
sleep_session.end_time = end_time.replace(tzinfo=None) if end_time.tzinfo else end_time
session.add(sleep_session)
await session.commit()
await session.refresh(sleep_session)
return sleep_session
async def delete_session(session: AsyncSession, session_id: int) -> bool:
statement = select(SleepSession).where(SleepSession.id == session_id)
result = await session.exec(statement)
sleep_session = result.first()
if not sleep_session:
return False
await session.delete(sleep_session)
await session.commit()
return True
async def update_session(
session: AsyncSession, session_id: int, update_data: dict
) -> Optional[SleepSession]:
statement = select(SleepSession).where(SleepSession.id == session_id)
result = await session.exec(statement)
sleep_session = result.first()
if not sleep_session:
return None
for key, value in update_data.items():
if hasattr(sleep_session, key):
setattr(sleep_session, key, value)
session.add(sleep_session)
await session.commit()
await session.refresh(sleep_session)
return sleep_session
+23
View File
@@ -0,0 +1,23 @@
from sqlmodel import SQLModel, Field, create_engine, Session
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker
from datetime import datetime
import datetime as dt
from app.config import settings
DATABASE_URL = settings.DATABASE_URL
engine = create_async_engine(DATABASE_URL, echo=False)
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async def get_session() -> AsyncSession:
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
+28
View File
@@ -0,0 +1,28 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import engine, init_db
from app.routers import sessions, stats
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(lifespan=lifespan)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS.split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(sessions.router)
app.include_router(stats.router)
+14
View File
@@ -0,0 +1,14 @@
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import SQLModel, Field
class SleepSession(SQLModel, table=True):
__tablename__ = "sleep_session"
id: Optional[int] = Field(default=None, primary_key=True)
start_time: datetime = Field(nullable=False)
end_time: Optional[datetime] = Field(default=None, nullable=True)
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+103
View File
@@ -0,0 +1,103 @@
from datetime import datetime, time, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel.ext.asyncio.session import AsyncSession
from app.database import get_session
from app.crud import get_sessions, end_sleep_session
from app.schemas import (
SessionListResponse,
SleepSessionUpdate,
SleepSessionRead,
SleepSessionCreate,
)
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@router.post("", response_model=SleepSessionRead, status_code=status.HTTP_201_CREATED)
async def create_session(
payload: SleepSessionCreate, session: AsyncSession = Depends(get_session)
):
from app.crud import get_active_session, create_sleep_session
active = await get_active_session(session)
if active:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="An active sleep session is already open.",
)
return await create_sleep_session(session, payload.start_time)
@router.get("/active")
async def read_active_session(session: AsyncSession = Depends(get_session)):
from app.crud import get_active_session
active = await get_active_session(session)
return {"session": active}
@router.get("", response_model=SessionListResponse)
async def list_sessions(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = 200,
session: AsyncSession = Depends(get_session),
):
sessions, total = await get_sessions(session, from_date, to_date, limit)
return {"sessions": sessions, "total": total}
@router.patch("/{session_id}/end", response_model=None)
async def end_session_endpoint(
session_id: int,
payload: SleepSessionUpdate,
session: AsyncSession = Depends(get_session),
):
if not payload.end_time:
raise HTTPException(
status_code=400, detail="end_time is required to end a session."
)
result = await end_sleep_session(session, session_id, payload.end_time)
if result is None:
raise HTTPException(status_code=404, detail="Session not found.")
if result is False:
raise HTTPException(status_code=409, detail="Session has already ended.")
if result == "invalid":
raise HTTPException(
status_code=400, detail="end_time cannot be before start_time."
)
return result
@router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_session_endpoint(
session_id: int, session: AsyncSession = Depends(get_session)
):
from app.crud import delete_session
success = await delete_session(session, session_id)
if not success:
raise HTTPException(status_code=404, detail="Session not found.")
return None
@router.put("/{session_id}", response_model=None)
async def update_session_endpoint(
session_id: int,
payload: SleepSessionUpdate,
session: AsyncSession = Depends(get_session),
):
from app.crud import update_session
updated = await update_session(
session, session_id, payload.model_dump(exclude_unset=True)
)
if not updated:
raise HTTPException(status_code=404, detail="Session not found.")
return updated
+159
View File
@@ -0,0 +1,159 @@
from datetime import datetime, time, timedelta
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.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse
from app.utils.time_utils import get_day_night_splits
router = APIRouter(prefix="/api/stats", tags=["stats"])
@router.get("/daily-summary", response_model=DailySummaryResponse)
async def get_daily_summary(
from_date: datetime,
to_date: datetime,
day_start: str = Query("07:00"),
day_end: str = Query("20:00"),
session: AsyncSession = Depends(get_session),
):
try:
day_start_time = time.fromisoformat(day_start)
day_end_time = time.fromisoformat(day_end)
except ValueError:
return {"days": []}
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
summary_map = {}
for s in sessions:
if not s.end_time:
continue
segments = get_day_night_splits(
s.start_time, s.end_time, day_start_time, day_end_time
)
for seg in segments:
date_str = seg["start"].strftime("%Y-%m-%d")
if date_str not in summary_map:
summary_map[date_str] = {"day": 0.0, "night": 0.0}
duration = (seg["end"] - seg["start"]).total_seconds() / 60.0
if seg["type"] == "day":
summary_map[date_str]["day"] += duration
else:
summary_map[date_str]["night"] += duration
days = []
for date_str in sorted(summary_map.keys()):
data = summary_map[date_str]
days.append(
{
"date": date_str,
"total_minutes": int(round(data["day"] + data["night"])),
"day_minutes": int(round(data["day"])),
"night_minutes": int(round(data["night"])),
}
)
return {"days": days}
@router.get("/timetable", response_model=TimetableResponse)
async def get_timetable(
from_date: datetime,
to_date: datetime,
day_start: str = Query("07:00"),
day_end: str = Query("20:00"),
session: AsyncSession = Depends(get_session),
):
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
slots = []
current_date = from_date.date()
while current_date <= to_date.date():
date_str = current_date.strftime("%Y-%m-%d")
for i in range(96):
slot_start_time = datetime.combine(current_date, time(0, 0)) + timedelta(
minutes=i * 15
)
slot_end_time = slot_start_time + timedelta(minutes=15)
slot_start_str = slot_start_time.strftime("%H:%M")
slot_end_str = slot_end_time.strftime("%H:%M")
is_sleeping = False
for s in sessions:
if s.start_time <= slot_end_time and (
s.end_time is None or s.end_time >= slot_start_time
):
is_sleeping = True
break
slots.append(
{
"date": date_str,
"slot_start": slot_start_str,
"slot_end": slot_end_str,
"sleeping": is_sleeping,
}
)
current_date += timedelta(days=1)
return {"slots": slots}
@router.get("/wake-sleep-times", response_model=WakeupBedtimeResponse)
async def get_wake_sleep_times(
from_date: datetime,
to_date: datetime,
wakeup_ref: str = Query("04:00"),
bedtime_ref: str = Query("19:00"),
session: AsyncSession = Depends(get_session),
):
try:
w_ref_time = time.fromisoformat(wakeup_ref)
b_ref_time = time.fromisoformat(bedtime_ref)
except ValueError:
return {"days": []}
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
days_data = []
current_date = from_date.date()
while current_date <= to_date.date():
date_str = current_date.strftime("%Y-%m-%d")
day_sessions = [
s
for s in sessions
if s.start_time.date() == current_date
or (s.end_time and s.end_time.date() == current_date)
]
wakeup_min = None
bedtime_min = None
if day_sessions:
# Find first wakeup after wakeup_ref
wakeups = []
for s in day_sessions:
if s.end_time and s.end_time.time() >= w_ref_time:
diff = (
s.end_time - datetime.combine(current_date, w_ref_time)
).total_seconds() / 60.0
wakeups.append(int(diff))
if wakeups:
wakeup_min = min(wakeups)
# Find last bedtime before bedtime_ref
bedtimes = []
for s in day_sessions:
if s.start_time.time() <= b_ref_time:
diff = (
s.start_time - datetime.combine(current_date, b_ref_time)
).total_seconds() / 60.0
bedtimes.append(int(diff))
if bedtimes:
bedtime_min = max(bedtimes)
days_data.append(
{
"date": date_str,
"wakeup_minutes": wakeup_min,
"bedtime_minutes": bedtime_min,
}
)
current_date += timedelta(days=1)
return {"days": days_data}
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
class SleepSessionCreate(BaseModel):
start_time: datetime
class SleepSessionUpdate(BaseModel):
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
notes: Optional[str] = None
class SleepSessionRead(BaseModel):
id: int
start_time: datetime
end_time: Optional[datetime]
notes: Optional[str]
created_at: datetime
updated_at: datetime
class SessionListResponse(BaseModel):
sessions: List[SleepSessionRead]
total: int
class DailySummaryDay(BaseModel):
date: str
total_minutes: int
day_minutes: int
night_minutes: int
class DailySummaryResponse(BaseModel):
days: List[DailySummaryDay]
class TimetableSlot(BaseModel):
date: str
slot_start: str
slot_end: str
sleeping: bool
class TimetableResponse(BaseModel):
slots: List[TimetableSlot]
class WakeupBedtimeDay(BaseModel):
date: str
wakeup_minutes: Optional[int]
bedtime_minutes: Optional[int]
class WakeupBedtimeResponse(BaseModel):
days: List[WakeupBedtimeDay]
+64
View File
@@ -0,0 +1,64 @@
from datetime import datetime, time, timedelta
from typing import List
def get_day_night_splits(
session_start: datetime,
session_end: datetime,
day_start_boundary: time,
day_end_boundary: time,
) -> List[dict]:
"""
Splits a sleep session into day and night segments based on boundaries.
A segment is defined by [segment_start, segment_end] and its type (day/night).
"""
segments = []
points = [session_start, session_end]
for offset in [0, 1]:
base_date = session_start.date() + timedelta(days=offset)
try:
b1 = datetime.combine(base_date, day_start_boundary).replace(
tzinfo=session_start.tzinfo
)
b2 = datetime.combine(base_date, day_end_boundary).replace(
tzinfo=session_start.tzinfo
)
if session_start < b1 < session_end:
points.append(b1)
if session_start < b2 < session_end:
points.append(b2)
except (ValueError, AttributeError):
continue
sorted_points = sorted(list(set(points)))
for i in range(len(sorted_points) - 1):
seg_start = sorted_points[i]
seg_end = sorted_points[i + 1]
midpoint = seg_start + (seg_end - seg_start) / 2
is_day = day_start_pattern(
midpoint.time(), day_start_boundary, day_end_boundary
)
segments.append(
{"start": seg_start, "end": seg_end, "type": "day" if is_day else "night"}
)
return segments
def day_start_pattern(current_time: time, start: time, end: time) -> bool:
if start <= end:
return start <= current_time < end
else: # Spans midnight
return current_time >= start or current_time < end
from datetime import datetime, time, timedelta
def minutes_after(dt: datetime, reference: time) -> float:
"""Returns minutes after a reference time."""
ref_delta = timedelta(hours=reference.hour, minutes=reference.minute)
dt_delta = timedelta(hours=dt.hour, minutes=dt.minute)
return (dt_delta - ref_delta).total_seconds() / 60.0