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:
Craig
2026-07-26 13:27:03 +01:00
parent 87d7eca468
commit 5372e8cbca
5 changed files with 482 additions and 2 deletions
+7 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from schemas import LogEntryCreate, LogEntryRead, LogEntryUpdate from schemas import DaySummaryResponse, LogEntryCreate, LogEntryRead, LogEntryUpdate
from services import log as svc from services import log as svc
router = APIRouter(prefix="/api/log", tags=["log"]) router = APIRouter(prefix="/api/log", tags=["log"])
@@ -29,6 +29,12 @@ def create_log_entry(data: LogEntryCreate, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@router.get("/summary", response_model=DaySummaryResponse)
def get_day_summary(date: date, db: Session = Depends(get_db)):
"""Computed nutrition totals for the day vs. the applicable target (§3.3)."""
return svc.get_day_summary(db, date)
@router.put("/{entry_id}", response_model=LogEntryRead) @router.put("/{entry_id}", response_model=LogEntryRead)
def update_log_entry(entry_id: int, data: LogEntryUpdate, db: Session = Depends(get_db)): def update_log_entry(entry_id: int, data: LogEntryUpdate, db: Session = Depends(get_db)):
"""Update quantity, meal_slot, and/or sort_order. """Update quantity, meal_slot, and/or sort_order.
+35
View File
@@ -182,3 +182,38 @@ class TargetRead(BaseModel):
protein_g: float | None protein_g: float | None
carbs_g: float | None carbs_g: float | None
fat_g: float | None fat_g: float | None
# ── Day Summary (TICKET-004) ────────────────────────────────────────────────
class DaySummaryNutrition(BaseModel):
"""Summed nutrition totals for a single day.
All fields are floats (summed from scaled per-unit values).
Foods with null nutrition fields contribute 0.
"""
calories: float
protein_g: float
carbs_g: float
fat_g: float
fiber_g: float
saturated_fat_g: float
sugars_g: float
sodium_g: float
class DaySummaryResponse(BaseModel):
"""Response shape for GET /api/log/summary — the contract the frontend
ProgressBar consumes.
Remaining-vs-target arithmetic: LEFT TO THE FRONTEND.
The server returns raw totals and the applicable target (or null).
The frontend computes remaining = target.calories - totals.calories
and similar for macros where both target and total exist.
"""
date: date
totals: DaySummaryNutrition
target: TargetRead | None
+55 -1
View File
@@ -15,7 +15,15 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from models import DailyLogEntry, Food 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: def _now() -> datetime:
@@ -125,3 +133,49 @@ class FoodNotAvailableError(Exception):
def __init__(self, food_id: int): def __init__(self, food_id: int):
super().__init__(f"Food {food_id} not available for logging") super().__init__(f"Food {food_id} not available for logging")
self.food_id = food_id 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,
)
+42
View File
@@ -9,6 +9,27 @@ Routers never compute nutrition; the frontend never re-derives it.
from models import Food 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: 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.""" """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). (recursively, with cycle detection — spec §2.2).
""" """
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type) 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
}
+343
View File
@@ -0,0 +1,343 @@
"""Day summary tests — TICKET-004 (spec §2.1 quantity interpretation, §3.3 /api/log/summary, §8.1 rule 1).
Covers:
- weight-type scaling (150g of 380kcal/100g food = 570)
- count-type scaling (2 × 70kcal egg = 140)
- mixed entries summing
- null nutrition fields contribute 0
- correct target selected for historical dates
- no-target case (target: null)
- meal entries contribute 0 (TODO TICKET-007)
"""
# ── Helpers ──────────────────────────────────────────────────────────────────
def _create_food(client, **overrides) -> dict:
"""Create a food via POST and return the response JSON."""
payload = {
"name": "Test Food",
"calories_per_unit": 250.0,
"source": "manual",
"unit_type": "weight",
}
payload.update(overrides)
resp = client.post("/api/foods", json=payload)
assert resp.status_code == 201, f"food create failed: {resp.text}"
return resp.json()
def _log_entry(client, food_id, quantity, date="2025-06-15", **overrides) -> dict:
"""Create a log entry and return the parsed JSON (asserts 201)."""
payload = {"food_id": food_id, "quantity": quantity, "date": date}
payload.update(overrides)
resp = client.post("/api/log", json=payload)
assert resp.status_code == 201, f"log create failed: {resp.text}"
return resp.json()
def _get_summary(client, date="2025-06-15") -> dict:
"""Call the summary endpoint and return parsed JSON (asserts 200)."""
resp = client.get("/api/log/summary", params={"date": date})
assert resp.status_code == 200, f"summary failed: {resp.text}"
return resp.json()
# ── Basic shape ──────────────────────────────────────────────────────────────
def test_summary_empty_date_returns_zeros_and_null_target(client):
"""A date with no log entries → all nutrition fields zero, target null."""
data = _get_summary(client, "2099-01-01")
assert data["date"] == "2099-01-01"
assert data["target"] is None
t = data["totals"]
assert t["calories"] == 0.0
assert t["protein_g"] == 0.0
assert t["carbs_g"] == 0.0
assert t["fat_g"] == 0.0
# Bonus fields
assert t["fiber_g"] == 0.0
assert t["saturated_fat_g"] == 0.0
assert t["sugars_g"] == 0.0
assert t["sodium_g"] == 0.0
# ── Weight-type scaling (§2.1) ───────────────────────────────────────────────
def test_weight_type_scaling_150g_of_380kcal_per_100g(client):
"""150g of a 380kcal/100g food = 570 kcal. Math: 380 × 150/100 = 570."""
food = _create_food(client, name="Olive Oil", calories_per_unit=380.0, unit_type="weight")
_log_entry(client, food["id"], quantity=150.0, date="2025-07-01")
data = _get_summary(client, "2025-07-01")
assert data["totals"]["calories"] == 570.0
def test_weight_type_scaling_macros(client):
"""Macros also scale per 100g for weight-type foods."""
food = _create_food(
client,
unit_type="weight",
calories_per_unit=200.0,
protein_per_unit=10.0,
carbs_per_unit=20.0,
fat_per_unit=5.0,
)
_log_entry(client, food["id"], quantity=250.0, date="2025-07-02")
data = _get_summary(client, "2025-07-02")
t = data["totals"]
assert t["calories"] == 500.0 # 200 × 250/100
assert t["protein_g"] == 25.0 # 10 × 250/100
assert t["carbs_g"] == 50.0 # 20 × 250/100
assert t["fat_g"] == 12.5 # 5 × 250/100
# ── Count-type scaling (§2.1) ────────────────────────────────────────────────
def test_count_type_scaling_2_eggs(client):
"""2 × 70kcal egg = 140 kcal. Math: 70 × 2 = 140."""
food = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
_log_entry(client, food["id"], quantity=2.0, date="2025-07-03")
data = _get_summary(client, "2025-07-03")
assert data["totals"]["calories"] == 140.0
def test_count_type_scaling_macros(client):
"""Count-type macros scale per item."""
food = _create_food(
client,
unit_type="count",
calories_per_unit=90.0,
protein_per_unit=6.0,
carbs_per_unit=1.0,
fat_per_unit=7.0,
)
_log_entry(client, food["id"], quantity=3.0, date="2025-07-04")
data = _get_summary(client, "2025-07-04")
t = data["totals"]
assert t["calories"] == 270.0 # 90 × 3
assert t["protein_g"] == 18.0 # 6 × 3
assert t["carbs_g"] == 3.0 # 1 × 3
assert t["fat_g"] == 21.0 # 7 × 3
# ── Mixed entries summing ────────────────────────────────────────────────────
def test_multiple_entries_summed(client):
"""Two weight-type foods on the same date → totals are summed."""
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
_log_entry(client, f1["id"], quantity=200.0, date="2025-07-05") # 260 kcal
_log_entry(client, f2["id"], quantity=150.0, date="2025-07-05") # 247.5 kcal
data = _get_summary(client, "2025-07-05")
assert data["totals"]["calories"] == 507.5 # 260 + 247.5
def test_mixed_unit_types_summed(client):
"""Weight + count entries on the same date sum correctly."""
weight_food = _create_food(
client, name="Pasta", calories_per_unit=350.0, unit_type="weight",
protein_per_unit=12.0, carbs_per_unit=70.0, fat_per_unit=2.0,
)
count_food = _create_food(
client, name="Meatball", calories_per_unit=50.0, unit_type="count",
protein_per_unit=4.0, carbs_per_unit=1.0, fat_per_unit=3.0,
)
_log_entry(client, weight_food["id"], quantity=200.0, date="2025-07-06") # 700 kcal
_log_entry(client, count_food["id"], quantity=4.0, date="2025-07-06") # 200 kcal
data = _get_summary(client, "2025-07-06")
t = data["totals"]
assert t["calories"] == 900.0 # 700 + 200
assert t["protein_g"] == 40.0 # 24 + 16
assert t["carbs_g"] == 144.0 # 140 + 4
assert t["fat_g"] == 16.0 # 4 + 12
# ── Null nutrition fields contribute 0 ───────────────────────────────────────
def test_null_calories_contributes_zero(client):
"""A food with null calories_per_unit contributes 0 (not an error)."""
# Per the CHECK constraint, non-meal foods MUST have calories_per_unit,
# so we use is_meal=True to get null calories.
food = _create_food(
client, name="Null Cal Food", is_meal=True, calories_per_unit=None,
source="meal",
)
_log_entry(client, food["id"], quantity=100.0, date="2025-07-07")
data = _get_summary(client, "2025-07-07")
assert data["totals"]["calories"] == 0.0 # meals → 0 for now
def test_null_macros_contribute_zero(client):
"""Foods with null macro fields contribute 0 for those fields."""
# Create a food with calories but no macros (all null by default)
food = _create_food(
client, name="Sugar Water", calories_per_unit=40.0,
protein_per_unit=None, carbs_per_unit=10.0, fat_per_unit=None,
unit_type="weight",
)
_log_entry(client, food["id"], quantity=200.0, date="2025-07-08")
data = _get_summary(client, "2025-07-08")
t = data["totals"]
assert t["calories"] == 80.0 # 40 × 200/100
assert t["carbs_g"] == 20.0 # 10 × 200/100
assert t["protein_g"] == 0.0 # null → 0
assert t["fat_g"] == 0.0 # null → 0
# ── Bonus nutrition fields included ──────────────────────────────────────────
def test_bonus_fields_fiber_satfat_sugars_sodium(client):
"""fiber, saturated_fat, sugars, sodium are included in summary."""
food = _create_food(
client,
calories_per_unit=200.0, unit_type="weight",
fiber_per_unit=3.0,
saturated_fat_per_unit=2.0,
sugars_per_unit=5.0,
sodium_per_unit=0.4,
)
_log_entry(client, food["id"], quantity=100.0, date="2025-07-09")
data = _get_summary(client, "2025-07-09")
t = data["totals"]
assert t["fiber_g"] == 3.0
assert t["saturated_fat_g"] == 2.0
assert t["sugars_g"] == 5.0
assert t["sodium_g"] == 0.4
# ── Target selection (historical lookup from TICKET-002) ─────────────────────
def test_summary_includes_correct_target_for_date(client):
"""Summary returns the target whose half-open date range covers the log date."""
from database import SessionLocal
from models import Target
from sqlalchemy import delete as sa_delete
# Create targets with sequential start dates; each auto-closes the previous
resp1 = client.post("/api/targets", json={"start_date": "2025-01-01", "calories": 2000})
assert resp1.status_code == 201
tid1 = resp1.json()["id"]
resp2 = client.post("/api/targets", json={"start_date": "2025-04-01", "calories": 2200})
assert resp2.status_code == 201
tid2 = resp2.json()["id"]
resp3 = client.post("/api/targets", json={"start_date": "2025-07-01", "calories": 2500})
assert resp3.status_code == 201
tid3 = resp3.json()["id"]
# Close the last active target to clean up after ourselves
client.put(f"/api/targets/{tid3}", json={"end_date": "2025-12-31"})
try:
# Feb 2025 → target 1 (2000 kcal)
data = _get_summary(client, "2025-02-15")
assert data["target"] is not None
assert data["target"]["id"] == tid1
assert data["target"]["calories"] == 2000
# May 2025 → target 2 (2200 kcal)
data = _get_summary(client, "2025-05-15")
assert data["target"] is not None
assert data["target"]["id"] == tid2
assert data["target"]["calories"] == 2200
# Sep 2025 → target 3 (2500 kcal)
data = _get_summary(client, "2025-09-15")
assert data["target"] is not None
assert data["target"]["id"] == tid3
assert data["target"]["calories"] == 2500
finally:
# Hard-delete these targets so they don't leak into other tests
db = SessionLocal()
try:
db.execute(sa_delete(Target).where(Target.id.in_([tid1, tid2, tid3])))
db.commit()
finally:
db.close()
def test_summary_no_target_returns_null(client):
"""When no target covers the requested date, target is null."""
# Use a date far in the past before any target was created
data = _get_summary(client, "2000-01-01")
assert data["target"] is None
# ── Meal entries contribute 0 (TODO TICKET-007) ──────────────────────────────
def test_meal_entry_contributes_zero(client):
"""Meal foods have null calories_per_unit per the CHECK constraint,
so they temporarily contribute 0 to the day's totals."""
meal = _create_food(
client, name="My Meal", is_meal=True, calories_per_unit=None,
source="meal",
)
_log_entry(client, meal["id"], quantity=1.0, date="2025-07-10")
data = _get_summary(client, "2025-07-10")
assert data["totals"]["calories"] == 0.0
assert data["totals"]["protein_g"] == 0.0
assert data["totals"]["carbs_g"] == 0.0
assert data["totals"]["fat_g"] == 0.0
def test_meal_mixed_with_regular_foods(client):
"""Meal entries contribute 0 while regular foods contribute normally."""
regular = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
meal = _create_food(
client, name="Lunch Meal", is_meal=True, calories_per_unit=None,
source="meal",
)
_log_entry(client, regular["id"], quantity=200.0, date="2025-07-11") # 260 kcal
_log_entry(client, meal["id"], quantity=1.0, date="2025-07-11") # 0 kcal
data = _get_summary(client, "2025-07-11")
assert data["totals"]["calories"] == 260.0 # only the regular food
# ── Past/future dates ───────────────────────────────────────────────────────
def test_arbitrary_past_date(client):
"""Summary works for any past date with correct historical data."""
food = _create_food(client, name="Old Food", calories_per_unit=100.0)
_log_entry(client, food["id"], quantity=50.0, date="2023-12-25")
data = _get_summary(client, "2023-12-25")
assert data["totals"]["calories"] == 50.0 # 100 × 50/100
def test_arbitrary_future_date(client):
"""Summary works for future dates (no entries → zeros)."""
data = _get_summary(client, "2030-06-15")
assert data["date"] == "2030-06-15"
assert data["totals"]["calories"] == 0.0
# target may be null or a still-active target — just verify totals are correct
# ── Date isolation ───────────────────────────────────────────────────────────
def test_summary_isolated_by_date(client):
"""Entries on different dates don't cross-contaminate summaries."""
f1 = _create_food(client, name="Monday Food", calories_per_unit=100.0)
f2 = _create_food(client, name="Tuesday Food", calories_per_unit=200.0)
_log_entry(client, f1["id"], quantity=100.0, date="2025-07-12") # 100 kcal
_log_entry(client, f2["id"], quantity=100.0, date="2025-07-13") # 200 kcal
assert _get_summary(client, "2025-07-12")["totals"]["calories"] == 100.0
assert _get_summary(client, "2025-07-13")["totals"]["calories"] == 200.0