From 4459b1ab7eddddd2daa0d2b96eb6c81466fb878a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:14:08 -0700 Subject: [PATCH] feat(services): add shopping_list service with pantry diff refs kiwi#68 --- app/services/meal_plan/__init__.py | 1 + app/services/meal_plan/shopping_list.py | 88 +++++++++++++++++++ .../services/test_meal_plan_shopping_list.py | 51 +++++++++++ 3 files changed, 140 insertions(+) create mode 100644 app/services/meal_plan/__init__.py create mode 100644 app/services/meal_plan/shopping_list.py create mode 100644 tests/services/test_meal_plan_shopping_list.py diff --git a/app/services/meal_plan/__init__.py b/app/services/meal_plan/__init__.py new file mode 100644 index 0000000..245ab0b --- /dev/null +++ b/app/services/meal_plan/__init__.py @@ -0,0 +1 @@ +"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core).""" diff --git a/app/services/meal_plan/shopping_list.py b/app/services/meal_plan/shopping_list.py new file mode 100644 index 0000000..ea441ad --- /dev/null +++ b/app/services/meal_plan/shopping_list.py @@ -0,0 +1,88 @@ +# app/services/meal_plan/shopping_list.py +"""Compute a shopping list from a meal plan and current pantry inventory. + +Pure function — no DB or network calls. Takes plain dicts from the Store +and returns GapItem dataclasses. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class GapItem: + ingredient_name: str + needed_raw: str | None # first quantity token from recipe text, e.g. "300g" + have_quantity: float | None # pantry quantity when partial match + have_unit: str | None + covered: bool + retailer_links: list = field(default_factory=list) # filled by API layer + + +_QUANTITY_RE = re.compile(r"^(\d+[\d./]*\s*(?:g|kg|ml|l|oz|lb|cup|cups|tsp|tbsp|tbsps|tsps)?)\b", re.I) + + +def _extract_quantity(ingredient_text: str) -> str | None: + """Pull the leading quantity string from a raw ingredient line.""" + m = _QUANTITY_RE.match(ingredient_text.strip()) + return m.group(1).strip() if m else None + + +def _normalise(name: str) -> str: + """Lowercase, strip possessives and plural -s for fuzzy matching.""" + return name.lower().strip().rstrip("s") + + +def compute_shopping_list( + recipes: list[dict], + inventory: list[dict], +) -> tuple[list[GapItem], list[GapItem]]: + """Return (gap_items, covered_items) for a list of recipe dicts + inventory dicts. + + Deduplicates by normalised ingredient name — the first recipe's quantity + string wins when the same ingredient appears in multiple recipes. + """ + if not recipes: + return [], [] + + # Build pantry lookup: normalised_name → inventory row + pantry: dict[str, dict] = {} + for item in inventory: + pantry[_normalise(item["name"])] = item + + # Collect unique ingredients with their first quantity token + seen: dict[str, str | None] = {} # normalised_name → needed_raw + for recipe in recipes: + names: list[str] = recipe.get("ingredient_names") or [] + raw_lines: list[str] = recipe.get("ingredients") or [] + for i, name in enumerate(names): + key = _normalise(name) + if key in seen: + continue + raw = raw_lines[i] if i < len(raw_lines) else "" + seen[key] = _extract_quantity(raw) + + gaps: list[GapItem] = [] + covered: list[GapItem] = [] + + for norm_name, needed_raw in seen.items(): + pantry_row = pantry.get(norm_name) + if pantry_row: + covered.append(GapItem( + ingredient_name=norm_name, + needed_raw=needed_raw, + have_quantity=pantry_row.get("quantity"), + have_unit=pantry_row.get("unit"), + covered=True, + )) + else: + gaps.append(GapItem( + ingredient_name=norm_name, + needed_raw=needed_raw, + have_quantity=None, + have_unit=None, + covered=False, + )) + + return gaps, covered diff --git a/tests/services/test_meal_plan_shopping_list.py b/tests/services/test_meal_plan_shopping_list.py new file mode 100644 index 0000000..77d6b50 --- /dev/null +++ b/tests/services/test_meal_plan_shopping_list.py @@ -0,0 +1,51 @@ +# tests/services/test_meal_plan_shopping_list.py +"""Unit tests for shopping_list.py — no network, no DB.""" +from __future__ import annotations + +import pytest +from app.services.meal_plan.shopping_list import GapItem, compute_shopping_list + + +def _recipe(ingredient_names: list[str], ingredients: list[str]) -> dict: + return {"ingredient_names": ingredient_names, "ingredients": ingredients} + + +def _inv_item(name: str, quantity: float, unit: str) -> dict: + return {"name": name, "quantity": quantity, "unit": unit} + + +def test_item_in_pantry_is_covered(): + recipes = [_recipe(["pasta"], ["500g pasta"])] + inventory = [_inv_item("pasta", 400, "g")] + gaps, covered = compute_shopping_list(recipes, inventory) + assert len(covered) == 1 + assert covered[0].ingredient_name == "pasta" + assert covered[0].covered is True + assert len(gaps) == 0 + + +def test_item_not_in_pantry_is_gap(): + recipes = [_recipe(["chicken breast"], ["300g chicken breast"])] + inventory = [] + gaps, covered = compute_shopping_list(recipes, inventory) + assert len(gaps) == 1 + assert gaps[0].ingredient_name == "chicken breast" + assert gaps[0].covered is False + assert gaps[0].needed_raw == "300g" + + +def test_duplicate_ingredient_across_recipes_deduplicates(): + recipes = [ + _recipe(["onion"], ["2 onions"]), + _recipe(["onion"], ["1 onion"]), + ] + inventory = [] + gaps, _ = compute_shopping_list(recipes, inventory) + names = [g.ingredient_name for g in gaps] + assert names.count("onion") == 1 + + +def test_empty_plan_returns_empty_lists(): + gaps, covered = compute_shopping_list([], []) + assert gaps == [] + assert covered == []