TICKET-004: Day summary endpoint
- GET /api/log/summary?date= with totals vs historical target - Nutrition math consolidated in services/nutrition.py (weight vs count) - Null nutrition contributes 0; meals contribute 0 (TODO TICKET-007) - Full suite green (104 passed)
This commit is contained in:
+55
-1
@@ -15,7 +15,15 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from models import DailyLogEntry, Food
|
||||
from schemas import LogEntryCreate, LogEntryRead, LogEntryUpdate
|
||||
from schemas import (
|
||||
DaySummaryNutrition,
|
||||
DaySummaryResponse,
|
||||
LogEntryCreate,
|
||||
LogEntryRead,
|
||||
LogEntryUpdate,
|
||||
)
|
||||
from services import nutrition
|
||||
from services import targets as targets_svc
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -125,3 +133,49 @@ class FoodNotAvailableError(Exception):
|
||||
def __init__(self, food_id: int):
|
||||
super().__init__(f"Food {food_id} not available for logging")
|
||||
self.food_id = food_id
|
||||
|
||||
|
||||
# ── Day Summary (TICKET-004) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse:
|
||||
"""Compute nutrition totals for a date vs. the applicable target.
|
||||
|
||||
1. Loads all log entries for the date with food data eagerly joined.
|
||||
2. For each entry, scales the food's per-unit nutrition to the logged
|
||||
quantity via nutrition.entry_nutrition() — weight-type foods get
|
||||
(qty/100)× scaling, count-type foods get qty× scaling (§2.1).
|
||||
3. Meal entries (is_meal=True) currently contribute 0 because their
|
||||
per_unit fields are null per the CHECK constraint.
|
||||
TODO: TICKET-007 — replace with recursive component summation.
|
||||
4. Looks up the target covering the date via the half-open interval
|
||||
lookup from targets.get_target_for_date(). Returns null if none.
|
||||
|
||||
Returns a DaySummaryResponse with summed totals and the applicable
|
||||
target (or null).
|
||||
"""
|
||||
entries = db.scalars(
|
||||
select(DailyLogEntry)
|
||||
.where(DailyLogEntry.date == lookup_date)
|
||||
.options(joinedload(DailyLogEntry.food))
|
||||
).all()
|
||||
|
||||
# Sum nutrition across all entries for the date.
|
||||
# TODO: TICKET-007 — meal entries currently contribute 0 because their
|
||||
# per_unit fields are null per the CHECK constraint. Real meal nutrition
|
||||
# will be derived by recursively summing component foods' nutrition.
|
||||
# When that lands, replace the flat entry_nutrition() call with a
|
||||
# meal-aware sum function from services/nutrition.py.
|
||||
totals = {field: 0.0 for field in nutrition.NUTRITION_FIELDS}
|
||||
for entry in entries:
|
||||
entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity)
|
||||
for field, value in entry_nut.items():
|
||||
totals[field] += value
|
||||
|
||||
target = targets_svc.get_target_for_date(db, lookup_date)
|
||||
|
||||
return DaySummaryResponse(
|
||||
date=lookup_date,
|
||||
totals=DaySummaryNutrition(**totals),
|
||||
target=target,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,27 @@ Routers never compute nutrition; the frontend never re-derives it.
|
||||
|
||||
from models import Food
|
||||
|
||||
# All nutrition fields tracked on foods. Each has a corresponding *_per_unit
|
||||
# column on the Food model. Used by entry_nutrition() to iterate over fields.
|
||||
NUTRITION_FIELDS = [
|
||||
"calories", "protein_g", "carbs_g", "fat_g",
|
||||
"fiber_g", "saturated_fat_g", "sugars_g", "sodium_g",
|
||||
]
|
||||
|
||||
# Maps each NUTRITION_FIELDS key to the Food model's per_unit column name.
|
||||
# Naming is slightly irregular (e.g. "protein_g" → "protein_per_unit",
|
||||
# not "protein_g_per_unit").
|
||||
_FIELD_TO_PER_UNIT_COL = {
|
||||
"calories": "calories_per_unit",
|
||||
"protein_g": "protein_per_unit",
|
||||
"carbs_g": "carbs_per_unit",
|
||||
"fat_g": "fat_per_unit",
|
||||
"fiber_g": "fiber_per_unit",
|
||||
"saturated_fat_g": "saturated_fat_per_unit",
|
||||
"sugars_g": "sugars_per_unit",
|
||||
"sodium_g": "sodium_per_unit",
|
||||
}
|
||||
|
||||
|
||||
def scale_to_quantity(per_unit: float | None, quantity: float, unit_type: str) -> float:
|
||||
"""Scale a per-unit nutrition value to a logged quantity. Missing values count as 0."""
|
||||
@@ -28,3 +49,24 @@ def entry_calories(food: Food, quantity: float) -> float:
|
||||
(recursively, with cycle detection — spec §2.2).
|
||||
"""
|
||||
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type)
|
||||
|
||||
|
||||
def entry_nutrition(food: Food, quantity: float) -> dict[str, float]:
|
||||
"""Return all nutrition fields scaled to a logged quantity.
|
||||
|
||||
Each field is resolved via scale_to_quantity using the food's unit_type.
|
||||
NULL per-unit values contribute 0.0, not an error.
|
||||
|
||||
Meal foods (is_meal=True) have null per_unit fields per the CHECK
|
||||
constraint, so they naturally contribute 0 for all fields.
|
||||
TODO: TICKET-007 — real meal nutrition will sum scaled component
|
||||
foods recursively. Until then, meal entries contribute 0.
|
||||
"""
|
||||
return {
|
||||
field: scale_to_quantity(
|
||||
getattr(food, _FIELD_TO_PER_UNIT_COL[field], None),
|
||||
quantity,
|
||||
food.unit_type,
|
||||
)
|
||||
for field in NUTRITION_FIELDS
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user