2026-03-22 08:17:11 +01:00
"""
Sleep Module Router ( v9d Phase 2 b )
Endpoints :
- CRUD : list , create / upsert , update , delete
- Stats : 7 - day average , trends , phase distribution , sleep debt
- Correlations : sleep ↔ resting HR , training , weight ( Phase 2 e )
"""
from fastapi import APIRouter , Depends , HTTPException , UploadFile , File
from pydantic import BaseModel
2026-04-05 17:35:48 +02:00
from datetime import datetime , timedelta , date
2026-04-10 07:30:48 +02:00
from typing import Literal
2026-03-22 08:17:11 +01:00
from auth import require_auth
2026-04-10 07:30:48 +02:00
from csv_parser . sleep_apple_import import import_apple_sleep_nights
2026-03-22 08:17:11 +01:00
from db import get_db , get_cursor
router = APIRouter ( prefix = " /api/sleep " , tags = [ " sleep " ] )
2026-04-05 17:35:48 +02:00
2026-03-22 08:17:11 +01:00
# ── Models ────────────────────────────────────────────────────────────────────
class SleepCreate ( BaseModel ) :
date : str # YYYY-MM-DD
bedtime : str | None = None # HH:MM
wake_time : str | None = None # HH:MM
duration_minutes : int
quality : int | None = None # 1-5
wake_count : int | None = None
deep_minutes : int | None = None
rem_minutes : int | None = None
light_minutes : int | None = None
awake_minutes : int | None = None
note : str = " "
source : Literal [ ' manual ' , ' apple_health ' , ' garmin ' ] = ' manual '
class SleepResponse ( BaseModel ) :
id : int
profile_id : str
date : str
bedtime : str | None
wake_time : str | None
duration_minutes : int
duration_formatted : str
quality : int | None
wake_count : int | None
deep_minutes : int | None
rem_minutes : int | None
light_minutes : int | None
awake_minutes : int | None
sleep_segments : list | None
sleep_efficiency : float | None
deep_percent : float | None
rem_percent : float | None
note : str
source : str
created_at : str
class SleepStatsResponse ( BaseModel ) :
avg_duration_minutes : float
avg_quality : float | None
total_nights : int
nights_below_goal : int
sleep_goal_minutes : int
class SleepDebtResponse ( BaseModel ) :
sleep_debt_minutes : int
sleep_debt_formatted : str
days_analyzed : int
sleep_goal_minutes : int
# ── Helper Functions ──────────────────────────────────────────────────────────
def format_duration ( minutes : int ) - > str :
""" Convert minutes to ' Xh Ymin ' format. """
hours = minutes / / 60
mins = minutes % 60
return f " { hours } h { mins } min "
def calculate_sleep_efficiency ( duration_min : int , awake_min : int | None ) - > float | None :
""" Sleep efficiency = duration / (duration + awake) * 100. """
if awake_min is None or awake_min == 0 :
return None
total = duration_min + awake_min
return round ( ( duration_min / total ) * 100 , 1 ) if total > 0 else None
def calculate_phase_percent ( phase_min : int | None , duration_min : int ) - > float | None :
""" Calculate phase percentage of total duration. """
if phase_min is None or duration_min == 0 :
return None
return round ( ( phase_min / duration_min ) * 100 , 1 )
def row_to_sleep_response ( row : dict ) - > SleepResponse :
""" Convert DB row to SleepResponse. """
return SleepResponse (
id = row [ ' id ' ] ,
profile_id = row [ ' profile_id ' ] ,
date = str ( row [ ' date ' ] ) ,
bedtime = str ( row [ ' bedtime ' ] ) if row [ ' bedtime ' ] else None ,
wake_time = str ( row [ ' wake_time ' ] ) if row [ ' wake_time ' ] else None ,
duration_minutes = row [ ' duration_minutes ' ] ,
duration_formatted = format_duration ( row [ ' duration_minutes ' ] ) ,
quality = row [ ' quality ' ] ,
wake_count = row [ ' wake_count ' ] ,
deep_minutes = row [ ' deep_minutes ' ] ,
rem_minutes = row [ ' rem_minutes ' ] ,
light_minutes = row [ ' light_minutes ' ] ,
awake_minutes = row [ ' awake_minutes ' ] ,
sleep_segments = row [ ' sleep_segments ' ] ,
sleep_efficiency = calculate_sleep_efficiency ( row [ ' duration_minutes ' ] , row [ ' awake_minutes ' ] ) ,
deep_percent = calculate_phase_percent ( row [ ' deep_minutes ' ] , row [ ' duration_minutes ' ] ) ,
rem_percent = calculate_phase_percent ( row [ ' rem_minutes ' ] , row [ ' duration_minutes ' ] ) ,
note = row [ ' note ' ] or " " ,
source = row [ ' source ' ] ,
created_at = str ( row [ ' created_at ' ] )
)
# ── CRUD Endpoints ────────────────────────────────────────────────────────────
@router.get ( " " )
def list_sleep (
limit : int = 90 ,
session : dict = Depends ( require_auth )
) :
""" List sleep entries for current profile (last N days). """
pid = session [ ' profile_id ' ]
with get_db ( ) as conn :
cur = get_cursor ( conn )
cur . execute ( """
SELECT * FROM sleep_log
WHERE profile_id = % s
ORDER BY date DESC
LIMIT % s
""" , (pid, limit))
rows = cur . fetchall ( )
return [ row_to_sleep_response ( row ) for row in rows ]
@router.get ( " /by-date/ {date} " )
def get_sleep_by_date (
date : str ,
session : dict = Depends ( require_auth )
) :
""" Get sleep entry for specific date. """
pid = session [ ' profile_id ' ]
with get_db ( ) as conn :
cur = get_cursor ( conn )
cur . execute ( """
SELECT * FROM sleep_log
WHERE profile_id = % s AND date = % s
""" , (pid, date))
row = cur . fetchone ( )
if not row :
raise HTTPException ( 404 , " No sleep entry for this date " )
return row_to_sleep_response ( row )
@router.post ( " " )
def create_sleep (
data : SleepCreate ,
session : dict = Depends ( require_auth )
) :
""" Create or update sleep entry (upsert by date). """
pid = session [ ' profile_id ' ]
2026-03-22 08:28:44 +01:00
# Convert empty strings to None for TIME fields
bedtime = data . bedtime if data . bedtime else None
wake_time = data . wake_time if data . wake_time else None
2026-03-22 14:01:47 +01:00
# Plausibility check: sleep phases (deep+rem+light) should sum to duration
# Note: awake_minutes is NOT part of sleep duration (tracked separately)
if any ( [ data . deep_minutes , data . rem_minutes , data . light_minutes ] ) :
sleep_phase_sum = ( data . deep_minutes or 0 ) + ( data . rem_minutes or 0 ) + ( data . light_minutes or 0 )
diff = abs ( data . duration_minutes - sleep_phase_sum )
2026-03-22 13:09:34 +01:00
if diff > 5 :
raise HTTPException (
400 ,
2026-03-22 14:01:47 +01:00
f " Plausibilitätsprüfung fehlgeschlagen: Schlafphasen-Summe ( { sleep_phase_sum } min) weicht um { diff } min von Schlafdauer ( { data . duration_minutes } min) ab. Max. Toleranz: 5 min. Hinweis: Wachphasen werden nicht zur Schlafdauer gezählt. "
2026-03-22 13:09:34 +01:00
)
2026-03-22 08:17:11 +01:00
with get_db ( ) as conn :
cur = get_cursor ( conn )
# Upsert: INSERT ... ON CONFLICT DO UPDATE
cur . execute ( """
INSERT INTO sleep_log (
profile_id , date , bedtime , wake_time , duration_minutes ,
quality , wake_count , deep_minutes , rem_minutes , light_minutes ,
awake_minutes , note , source , updated_at
) VALUES (
% s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , CURRENT_TIMESTAMP
)
ON CONFLICT ( profile_id , date ) DO UPDATE SET
bedtime = EXCLUDED . bedtime ,
wake_time = EXCLUDED . wake_time ,
duration_minutes = EXCLUDED . duration_minutes ,
quality = EXCLUDED . quality ,
wake_count = EXCLUDED . wake_count ,
deep_minutes = EXCLUDED . deep_minutes ,
rem_minutes = EXCLUDED . rem_minutes ,
light_minutes = EXCLUDED . light_minutes ,
awake_minutes = EXCLUDED . awake_minutes ,
note = EXCLUDED . note ,
source = EXCLUDED . source ,
updated_at = CURRENT_TIMESTAMP
RETURNING *
""" , (
2026-03-22 08:28:44 +01:00
pid , data . date , bedtime , wake_time , data . duration_minutes ,
2026-03-22 08:17:11 +01:00
data . quality , data . wake_count , data . deep_minutes , data . rem_minutes ,
data . light_minutes , data . awake_minutes , data . note , data . source
) )
row = cur . fetchone ( )
conn . commit ( )
return row_to_sleep_response ( row )
@router.put ( " / {id} " )
def update_sleep (
id : int ,
data : SleepCreate ,
session : dict = Depends ( require_auth )
) :
""" Update existing sleep entry by ID. """
pid = session [ ' profile_id ' ]
2026-03-22 08:28:44 +01:00
# Convert empty strings to None for TIME fields
bedtime = data . bedtime if data . bedtime else None
wake_time = data . wake_time if data . wake_time else None
2026-03-22 14:01:47 +01:00
# Plausibility check: sleep phases (deep+rem+light) should sum to duration
# Note: awake_minutes is NOT part of sleep duration (tracked separately)
if any ( [ data . deep_minutes , data . rem_minutes , data . light_minutes ] ) :
sleep_phase_sum = ( data . deep_minutes or 0 ) + ( data . rem_minutes or 0 ) + ( data . light_minutes or 0 )
diff = abs ( data . duration_minutes - sleep_phase_sum )
2026-03-22 13:09:34 +01:00
if diff > 5 :
raise HTTPException (
400 ,
2026-03-22 14:01:47 +01:00
f " Plausibilitätsprüfung fehlgeschlagen: Schlafphasen-Summe ( { sleep_phase_sum } min) weicht um { diff } min von Schlafdauer ( { data . duration_minutes } min) ab. Max. Toleranz: 5 min. Hinweis: Wachphasen werden nicht zur Schlafdauer gezählt. "
2026-03-22 13:09:34 +01:00
)
2026-03-22 08:17:11 +01:00
with get_db ( ) as conn :
cur = get_cursor ( conn )
cur . execute ( """
UPDATE sleep_log SET
date = % s ,
bedtime = % s ,
wake_time = % s ,
duration_minutes = % s ,
quality = % s ,
wake_count = % s ,
deep_minutes = % s ,
rem_minutes = % s ,
light_minutes = % s ,
awake_minutes = % s ,
note = % s ,
updated_at = CURRENT_TIMESTAMP
WHERE id = % s AND profile_id = % s
RETURNING *
""" , (
2026-03-22 08:28:44 +01:00
data . date , bedtime , wake_time , data . duration_minutes ,
2026-03-22 08:17:11 +01:00
data . quality , data . wake_count , data . deep_minutes , data . rem_minutes ,
data . light_minutes , data . awake_minutes , data . note , id , pid
) )
row = cur . fetchone ( )
if not row :
raise HTTPException ( 404 , " Sleep entry not found " )
conn . commit ( )
return row_to_sleep_response ( row )
@router.delete ( " / {id} " )
def delete_sleep (
id : int ,
session : dict = Depends ( require_auth )
) :
""" Delete sleep entry. """
pid = session [ ' profile_id ' ]
with get_db ( ) as conn :
cur = get_cursor ( conn )
cur . execute ( """
DELETE FROM sleep_log
WHERE id = % s AND profile_id = % s
""" , (id, pid))
conn . commit ( )
return { " deleted " : id }
# ── Stats Endpoints ───────────────────────────────────────────────────────────
@router.get ( " /stats " )
def get_sleep_stats (
days : int = 7 ,
session : dict = Depends ( require_auth )
) :
""" Get sleep statistics (average duration, quality, nights below goal). """
pid = session [ ' profile_id ' ]
with get_db ( ) as conn :
cur = get_cursor ( conn )
# Get sleep goal from profile
cur . execute ( " SELECT sleep_goal_minutes FROM profiles WHERE id = %s " , ( pid , ) )
profile = cur . fetchone ( )
sleep_goal = profile [ ' sleep_goal_minutes ' ] if profile and profile [ ' sleep_goal_minutes ' ] else 450
# Calculate stats
cur . execute ( """
SELECT
AVG ( duration_minutes ) : : FLOAT as avg_duration ,
AVG ( quality ) : : FLOAT as avg_quality ,
COUNT ( * ) as total_nights ,
COUNT ( CASE WHEN duration_minutes < % s THEN 1 END ) as nights_below_goal
FROM sleep_log
WHERE profile_id = % s
AND date > = CURRENT_DATE - INTERVAL ' %s days '
""" , (sleep_goal, pid, days))
stats = cur . fetchone ( )
return SleepStatsResponse (
avg_duration_minutes = round ( stats [ ' avg_duration ' ] , 1 ) if stats [ ' avg_duration ' ] else 0 ,
avg_quality = round ( stats [ ' avg_quality ' ] , 1 ) if stats [ ' avg_quality ' ] else None ,
total_nights = stats [ ' total_nights ' ] ,
nights_below_goal = stats [ ' nights_below_goal ' ] ,
sleep_goal_minutes = sleep_goal
)
@router.get ( " /debt " )
def get_sleep_debt (
days : int = 14 ,
session : dict = Depends ( require_auth )
) :
""" Calculate sleep debt over last N days. """
pid = session [ ' profile_id ' ]
with get_db ( ) as conn :
cur = get_cursor ( conn )
# Get sleep goal
cur . execute ( " SELECT sleep_goal_minutes FROM profiles WHERE id = %s " , ( pid , ) )
profile = cur . fetchone ( )
sleep_goal = profile [ ' sleep_goal_minutes ' ] if profile and profile [ ' sleep_goal_minutes ' ] else 450
# Calculate debt
cur . execute ( """
SELECT
SUM ( % s - duration_minutes ) as debt_minutes ,
COUNT ( * ) as nights_analyzed
FROM sleep_log
WHERE profile_id = % s
AND date > = CURRENT_DATE - INTERVAL ' %s days '
""" , (sleep_goal, pid, days))
result = cur . fetchone ( )
debt_min = int ( result [ ' debt_minutes ' ] ) if result [ ' debt_minutes ' ] else 0
nights = result [ ' nights_analyzed ' ] if result [ ' nights_analyzed ' ] else 0
# Format debt
if debt_min == 0 :
formatted = " 0 – kein Defizit "
elif debt_min > 0 :
formatted = f " + { format_duration ( debt_min ) } "
else :
formatted = f " − { format_duration ( abs ( debt_min ) ) } "
return SleepDebtResponse (
sleep_debt_minutes = debt_min ,
sleep_debt_formatted = formatted ,
days_analyzed = nights ,
sleep_goal_minutes = sleep_goal
)
@router.get ( " /trend " )
def get_sleep_trend (
days : int = 30 ,
session : dict = Depends ( require_auth )
) :
""" Get sleep duration and quality trend over time. """
pid = session [ ' profile_id ' ]
with get_db ( ) as conn :
cur = get_cursor ( conn )
cur . execute ( """
SELECT
date ,
duration_minutes ,
quality
FROM sleep_log
WHERE profile_id = % s
AND date > = CURRENT_DATE - INTERVAL ' %s days '
ORDER BY date ASC
""" , (pid, days))
rows = cur . fetchall ( )
return [
{
" date " : str ( row [ ' date ' ] ) ,
" duration_minutes " : row [ ' duration_minutes ' ] ,
" quality " : row [ ' quality ' ]
}
for row in rows
]
@router.get ( " /phases " )
def get_sleep_phases (
days : int = 30 ,
session : dict = Depends ( require_auth )
) :
""" Get sleep phase distribution (deep, REM, light, awake) over time. """
pid = session [ ' profile_id ' ]
with get_db ( ) as conn :
cur = get_cursor ( conn )
cur . execute ( """
SELECT
date ,
deep_minutes ,
rem_minutes ,
light_minutes ,
awake_minutes ,
duration_minutes
FROM sleep_log
WHERE profile_id = % s
AND date > = CURRENT_DATE - INTERVAL ' %s days '
AND ( deep_minutes IS NOT NULL OR rem_minutes IS NOT NULL )
ORDER BY date ASC
""" , (pid, days))
rows = cur . fetchall ( )
return [
{
" date " : str ( row [ ' date ' ] ) ,
" deep_minutes " : row [ ' deep_minutes ' ] ,
" rem_minutes " : row [ ' rem_minutes ' ] ,
" light_minutes " : row [ ' light_minutes ' ] ,
" awake_minutes " : row [ ' awake_minutes ' ] ,
" deep_percent " : calculate_phase_percent ( row [ ' deep_minutes ' ] , row [ ' duration_minutes ' ] ) ,
" rem_percent " : calculate_phase_percent ( row [ ' rem_minutes ' ] , row [ ' duration_minutes ' ] )
}
for row in rows
]
2026-03-22 11:49:09 +01:00
# ── Import Endpoints ──────────────────────────────────────────────────────────
@router.post ( " /import/apple-health " )
async def import_apple_health_sleep (
file : UploadFile = File ( . . . ) ,
session : dict = Depends ( require_auth )
) :
"""
Import sleep data from Apple Health CSV export .
2026-04-05 17:35:48 +02:00
Supports :
- Segment - Export : Start , End , Duration ( hr ) , Value ( Kern / Tief / REM / Wach oder Core / Deep / … )
- Schlafanalyse ( Nacht - Zusammenfassung ) : Date / Time , Start , End , Total Sleep ( hr ) ,
Core / Light ( hr ) , Deep ( hr ) , REM ( hr ) , Awake ( hr ) , …
2026-03-22 11:49:09 +01:00
2026-04-05 17:35:48 +02:00
- Aggregates segments by night ( wake date ) where applicable
- Stores Roh - Segmente in JSONB ( bei Zusammenfassung oft leer )
- Überschreibt keine manuellen Einträge ( source = ' manual ' )
2026-03-22 11:49:09 +01:00
"""
pid = session [ ' profile_id ' ]
content = await file . read ( )
2026-04-10 07:30:48 +02:00
try :
csv_text = content . decode ( " utf-8-sig " )
except UnicodeDecodeError :
csv_text = content . decode ( " latin-1 " )
2026-03-22 11:49:09 +01:00
with get_db ( ) as conn :
cur = get_cursor ( conn )
2026-04-10 07:30:48 +02:00
try :
r = import_apple_sleep_nights ( cur , pid , csv_text )
except ValueError as e :
raise HTTPException ( status_code = 400 , detail = str ( e ) )
2026-03-22 11:49:09 +01:00
conn . commit ( )
2026-04-10 07:30:48 +02:00
imported = r [ " inserted " ] + r [ " updated " ]
skipped = r [ " skipped " ]
total = r [ " rows_total " ]
2026-03-22 11:49:09 +01:00
return {
" imported " : imported ,
" skipped " : skipped ,
2026-04-10 07:30:48 +02:00
" total_nights " : total ,
" message " : f " { imported } Nächte importiert, { skipped } übersprungen (manuelle Einträge) " ,
2026-03-22 11:49:09 +01:00
}