diff --git a/backend/routers/log.py b/backend/routers/log.py index 3746a99..c0c24d7 100644 --- a/backend/routers/log.py +++ b/backend/routers/log.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from database import get_db -from schemas import LogEntryCreate, LogEntryRead, LogEntryUpdate +from schemas import DaySummaryResponse, LogEntryCreate, LogEntryRead, LogEntryUpdate from services import log as svc 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)) +@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) def update_log_entry(entry_id: int, data: LogEntryUpdate, db: Session = Depends(get_db)): """Update quantity, meal_slot, and/or sort_order. diff --git a/backend/schemas.py b/backend/schemas.py index 7473e6a..f532834 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -182,3 +182,38 @@ class TargetRead(BaseModel): protein_g: float | None carbs_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 diff --git a/backend/services/log.py b/backend/services/log.py index 32a4dab..7991334 100644 --- a/backend/services/log.py +++ b/backend/services/log.py @@ -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, + ) diff --git a/backend/services/nutrition.py b/backend/services/nutrition.py index 46c367c..92902c8 100644 --- a/backend/services/nutrition.py +++ b/backend/services/nutrition.py @@ -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 + } diff --git a/backend/tests/test_summary.py b/backend/tests/test_summary.py new file mode 100644 index 0000000..cc7cf40 --- /dev/null +++ b/backend/tests/test_summary.py @@ -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