Compare commits

..

3 commits

Author SHA1 Message Date
8b7f4e7ea2 chore: inventory endpoint cleanup, expiry predictor, tiers, gitignore test artifacts 2026-04-02 22:12:51 -07:00
a22a249280 feat(frontend): recipe UI — filters, dismissal, load more, prep notes, nutrition chips
- Style/category filter panel with active chip display
- Dismiss (excluded_ids) support — recipes don't reappear until next fresh search
- Load more appends next batch without full re-fetch
- Prep notes 'Before you start:' section above directions
- Nutrition macro chips (kcal, fat, protein, carbs, fiber, sugar, sodium)
- Composables extracted for reuse
2026-04-02 22:12:45 -07:00
07450bf59f feat: recipe engine — assembly templates, prep notes, FTS fixes, texture backfill
- Assembly template system (13 templates: burrito, fried rice, omelette, stir fry,
  pasta, sandwich, grain bowl, soup/stew, casserole, pancakes, porridge, pie, pudding)
  with role-based matching, whole-word single-keyword guard, deterministic titles
  via MD5 pantry hash
- Prep-state stripping: strips 'melted butter' → 'butter' for coverage checks;
  reconstructs actionable states as 'Before you start:' cooking instructions
  (NutritionPanel prep_notes field + RecipesView.vue display block)
- FTS5 fixes: always double-quote all terms; strip apostrophes to prevent
  syntax errors on brands like "Stouffer's"; 'plant-based' → bare 'based' crash
- Bidirectional synonym expansion: alt-meat, alt-chicken, alt-beef, alt-pork
  mapped to canonical texture class; pantry expansion covers 'hamburger' from
  'burger patties' etc.
- Texture profile backfill script (378K ingredient_profiles rows) with macro-derived
  classification in priority order (fatty → creamy → starchy → firm → fibrous →
  tender → liquid → neutral); oats/legumes starchy-first fix
- LLM prompt: ban flavoured/sweetened ingredients (vanilla yoghurt) from savoury
- Migrations 014 (nutrition macros) + 015 (recipe FTS index)
- Nutrition estimation pipeline script
- gitignore MagicMock sqlite test artifacts
2026-04-02 22:12:35 -07:00
25 changed files with 3479 additions and 472 deletions

3
.gitignore vendored
View file

@ -19,3 +19,6 @@ dist/
# Data directories # Data directories
data/ data/
# Test artifacts (MagicMock sqlite files from pytest)
<MagicMock*

View file

@ -369,6 +369,23 @@ async def list_tags(
# ── Stats ───────────────────────────────────────────────────────────────────── # ── Stats ─────────────────────────────────────────────────────────────────────
@router.post("/recalculate-expiry")
async def recalculate_expiry(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> dict:
"""Re-run the expiration predictor over all available inventory items.
Uses each item's stored purchase_date and current location. Safe to call
multiple times idempotent per session.
"""
def _run(s: Store) -> tuple[int, int]:
return s.recalculate_expiry(tier=session.tier, has_byok=session.has_byok)
updated, skipped = await asyncio.to_thread(_run, store)
return {"updated": updated, "skipped": skipped}
@router.get("/stats", response_model=InventoryStats) @router.get("/stats", response_model=InventoryStats)
async def get_inventory_stats(store: Store = Depends(get_store)): async def get_inventory_stats(store: Store = Depends(get_store)):
def _stats(): def _stats():

View file

@ -0,0 +1,18 @@
-- Migration 014: Add macro nutrition columns to recipes and ingredient_profiles.
--
-- recipes: sugar, carbs, fiber, servings, and an estimated flag.
-- ingredient_profiles: carbs, fiber, calories, sugar per 100g (for estimation fallback).
ALTER TABLE recipes ADD COLUMN sugar_g REAL;
ALTER TABLE recipes ADD COLUMN carbs_g REAL;
ALTER TABLE recipes ADD COLUMN fiber_g REAL;
ALTER TABLE recipes ADD COLUMN servings REAL;
ALTER TABLE recipes ADD COLUMN nutrition_estimated INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ingredient_profiles ADD COLUMN carbs_g_per_100g REAL DEFAULT 0.0;
ALTER TABLE ingredient_profiles ADD COLUMN fiber_g_per_100g REAL DEFAULT 0.0;
ALTER TABLE ingredient_profiles ADD COLUMN calories_per_100g REAL DEFAULT 0.0;
ALTER TABLE ingredient_profiles ADD COLUMN sugar_g_per_100g REAL DEFAULT 0.0;
CREATE INDEX idx_recipes_sugar_g ON recipes (sugar_g);
CREATE INDEX idx_recipes_carbs_g ON recipes (carbs_g);

View file

@ -0,0 +1,16 @@
-- Migration 015: FTS5 inverted index for recipe ingredient lookup.
--
-- Content table backed by `recipes` — stores only the inverted index, no text duplication.
-- MATCH queries replace O(N) LIKE scans with O(log N) token lookups.
--
-- One-time rebuild cost on 3.2M rows: ~15-30 seconds at startup.
-- Subsequent startups skip this migration entirely.
CREATE VIRTUAL TABLE IF NOT EXISTS recipes_fts USING fts5(
ingredient_names,
content=recipes,
content_rowid=id,
tokenize="unicode61"
);
INSERT INTO recipes_fts(recipes_fts) VALUES('rebuild');

View file

@ -232,6 +232,72 @@ class Store:
(str(days),), (str(days),),
) )
def recalculate_expiry(
self,
tier: str = "local",
has_byok: bool = False,
) -> tuple[int, int]:
"""Re-run the expiration predictor over all available inventory items.
Uses each item's existing purchase_date (falls back to today if NULL)
and its current location. Skips items that have an explicit
expiration_date from a source other than auto-prediction (i.e. items
whose expiry was found on a receipt or entered by the user) cannot be
distinguished all available items are recalculated.
Returns (updated_count, skipped_count).
"""
from datetime import date
from app.services.expiration_predictor import ExpirationPredictor
predictor = ExpirationPredictor()
rows = self._fetch_all(
"""SELECT i.id, i.location, i.purchase_date,
p.name AS product_name, p.category AS product_category
FROM inventory_items i
JOIN products p ON p.id = i.product_id
WHERE i.status = 'available'""",
(),
)
updated = skipped = 0
for row in rows:
cat = predictor.get_category_from_product(
row["product_name"] or "",
product_category=row.get("product_category"),
location=row.get("location"),
)
purchase_date_raw = row.get("purchase_date")
try:
purchase_date = (
date.fromisoformat(purchase_date_raw)
if purchase_date_raw
else date.today()
)
except (ValueError, TypeError):
purchase_date = date.today()
exp = predictor.predict_expiration(
cat,
row["location"] or "pantry",
purchase_date=purchase_date,
product_name=row["product_name"],
tier=tier,
has_byok=has_byok,
)
if exp is None:
skipped += 1
continue
self.conn.execute(
"UPDATE inventory_items SET expiration_date = ?, updated_at = datetime('now') WHERE id = ?",
(str(exp), row["id"]),
)
updated += 1
self.conn.commit()
return updated, skipped
# ── receipt_data ────────────────────────────────────────────────────── # ── receipt_data ──────────────────────────────────────────────────────
def upsert_receipt_data(self, receipt_id: int, data: dict) -> dict[str, Any]: def upsert_receipt_data(self, receipt_id: int, data: dict) -> dict[str, Any]:
@ -266,16 +332,323 @@ class Store:
# ── recipes ─────────────────────────────────────────────────────────── # ── recipes ───────────────────────────────────────────────────────────
def _fts_ready(self) -> bool:
"""Return True if the recipes_fts virtual table exists."""
row = self._fetch_one(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='recipes_fts'"
)
return row is not None
# Words that carry no recipe-ingredient signal and should be filtered
# out when tokenising multi-word product names for FTS expansion.
_FTS_TOKEN_STOPWORDS: frozenset[str] = frozenset({
# Common English stopwords
"a", "an", "the", "of", "in", "for", "with", "and", "or", "to",
"from", "at", "by", "as", "on", "into",
# Brand / marketing words that appear in product names
"lean", "cuisine", "healthy", "choice", "stouffer", "original",
"classic", "deluxe", "homestyle", "family", "style", "grade",
"premium", "select", "natural", "organic", "fresh", "lite",
"ready", "quick", "easy", "instant", "microwave", "frozen",
"brand", "size", "large", "small", "medium", "extra",
# Plant-based / alt-meat brand names
"daring", "gardein", "morningstar", "lightlife", "tofurky",
"quorn", "omni", "nuggs", "simulate", "simulate",
# Preparation states — "cut up chicken" is still chicken
"cut", "diced", "sliced", "chopped", "minced", "shredded",
"cooked", "raw", "whole", "boneless", "skinless", "trimmed",
"pre", "prepared", "marinated", "seasoned", "breaded", "battered",
"grilled", "roasted", "smoked", "canned", "dried", "dehydrated",
"pieces", "piece", "strips", "strip", "chunks", "chunk",
"fillets", "fillet", "cutlets", "cutlet", "tenders", "nuggets",
# Units / packaging
"oz", "lb", "lbs", "pkg", "pack", "box", "can", "bag", "jar",
})
# Maps substrings found in product-label names to canonical recipe-corpus
# ingredient terms. Checked as substring matches against the lower-cased
# full product name, then against each individual token.
_FTS_SYNONYMS: dict[str, str] = {
# Ground / minced beef
"burger patt": "hamburger",
"beef patt": "hamburger",
"ground beef": "hamburger",
"ground chuck": "hamburger",
"ground round": "hamburger",
"mince": "hamburger",
"veggie burger": "hamburger",
"beyond burger": "hamburger",
"impossible burger": "hamburger",
"plant burger": "hamburger",
"chicken patt": "hamburger", # FTS match only — recipe scoring still works
# Sausages
"kielbasa": "sausage",
"bratwurst": "sausage",
"brat ": "sausage",
"frankfurter": "hotdog",
"wiener": "hotdog",
# Chicken cuts + plant-based chicken → generic chicken for broader matching
"chicken breast": "chicken",
"chicken thigh": "chicken",
"chicken drumstick": "chicken",
"chicken wing": "chicken",
"rotisserie chicken": "chicken",
"chicken tender": "chicken",
"chicken strip": "chicken",
"chicken piece": "chicken",
"fake chicken": "chicken",
"plant chicken": "chicken",
"vegan chicken": "chicken",
"daring": "chicken", # Daring Foods brand
"gardein chick": "chicken",
"quorn chick": "chicken",
"chick'n": "chicken",
"chikn": "chicken",
"not-chicken": "chicken",
"no-chicken": "chicken",
# Plant-based beef subs — map to broad "beef" not "hamburger"
# (texture varies: strips ≠ ground; let corpus handle the specific form)
"not-beef": "beef",
"no-beef": "beef",
"plant beef": "beef",
"vegan beef": "beef",
# Plant-based pork subs
"not-pork": "pork",
"no-pork": "pork",
"plant pork": "pork",
"vegan pork": "pork",
"omnipork": "pork",
"omni pork": "pork",
# Generic alt-meat catch-alls → broad "beef" (safer than hamburger)
"fake meat": "beef",
"plant meat": "beef",
"vegan meat": "beef",
"meat-free": "beef",
"meatless": "beef",
# Pork cuts
"pork chop": "pork",
"pork loin": "pork",
"pork tenderloin": "pork",
# Tomato-based sauces
"marinara": "tomato sauce",
"pasta sauce": "tomato sauce",
"spaghetti sauce": "tomato sauce",
"pizza sauce": "tomato sauce",
# Pasta shapes — map to generic "pasta" so FTS finds any pasta recipe
"macaroni": "pasta",
"noodles": "pasta",
"spaghetti": "pasta",
"penne": "pasta",
"fettuccine": "pasta",
"rigatoni": "pasta",
"linguine": "pasta",
"rotini": "pasta",
"farfalle": "pasta",
# Cheese variants → "cheese" for broad matching
"shredded cheese": "cheese",
"sliced cheese": "cheese",
"american cheese": "cheese",
"cheddar": "cheese",
"mozzarella": "cheese",
# Cream variants
"heavy cream": "cream",
"whipping cream": "cream",
"half and half": "cream",
# Buns / rolls
"burger bun": "buns",
"hamburger bun": "buns",
"hot dog bun": "buns",
"bread roll": "buns",
"dinner roll": "buns",
# Tortillas / wraps
"flour tortilla": "tortillas",
"corn tortilla": "tortillas",
"tortilla wrap": "tortillas",
"soft taco shell": "tortillas",
"taco shell": "taco shells",
"pita bread": "pita",
"flatbread": "flatbread",
# Canned beans
"black bean": "beans",
"pinto bean": "beans",
"kidney bean": "beans",
"refried bean": "beans",
"chickpea": "beans",
"garbanzo": "beans",
# Rice variants
"white rice": "rice",
"brown rice": "rice",
"jasmine rice": "rice",
"basmati rice": "rice",
"instant rice": "rice",
"microwavable rice": "rice",
# Salsa / hot sauce
"hot sauce": "salsa",
"taco sauce": "salsa",
"enchilada sauce": "salsa",
# Sour cream substitute
"greek yogurt": "sour cream",
# Prepackaged meals
"lean cuisine": "casserole",
"stouffer": "casserole",
"healthy choice": "casserole",
"marie callender": "casserole",
}
@staticmethod
def _normalize_for_fts(name: str) -> list[str]:
"""Expand one pantry item to all FTS search terms it should contribute.
Returns the original name plus:
- Any synonym-map canonical terms (handles product-label corpus name)
- Individual significant tokens from multi-word product names
(handles packaged meals like "Lean Cuisine Chicken Alfredo" also
searches for "chicken" and "alfredo" independently)
"""
lower = name.lower().strip()
if not lower:
return []
terms: list[str] = [lower]
# Substring synonym check on full name
for pattern, canonical in Store._FTS_SYNONYMS.items():
if pattern in lower:
terms.append(canonical)
# For multi-word product names, also add individual significant tokens
if " " in lower:
for token in lower.split():
if len(token) <= 3 or token in Store._FTS_TOKEN_STOPWORDS:
continue
if token not in terms:
terms.append(token)
# Synonym-expand individual tokens too
if token in Store._FTS_SYNONYMS:
canonical = Store._FTS_SYNONYMS[token]
if canonical not in terms:
terms.append(canonical)
return terms
@staticmethod
def _build_fts_query(ingredient_names: list[str]) -> str:
"""Build an FTS5 MATCH expression ORing all ingredient terms.
Each pantry item is expanded via _normalize_for_fts so that
product-label names (e.g. "burger patties") also search for their
recipe-corpus equivalents (e.g. "hamburger"), and multi-word packaged
product names contribute their individual ingredient tokens.
"""
parts: list[str] = []
seen: set[str] = set()
for name in ingredient_names:
for term in Store._normalize_for_fts(name):
# Strip characters that break FTS5 query syntax
clean = term.replace('"', "").replace("'", "")
if not clean or clean in seen:
continue
seen.add(clean)
parts.append(f'"{clean}"')
return " OR ".join(parts)
def search_recipes_by_ingredients( def search_recipes_by_ingredients(
self, self,
ingredient_names: list[str], ingredient_names: list[str],
limit: int = 20, limit: int = 20,
category: str | None = None, category: str | None = None,
max_calories: float | None = None,
max_sugar_g: float | None = None,
max_carbs_g: float | None = None,
max_sodium_mg: float | None = None,
excluded_ids: list[int] | None = None,
) -> list[dict]: ) -> list[dict]:
"""Find recipes containing any of the given ingredient names. """Find recipes containing any of the given ingredient names.
Scores by match count and returns highest-scoring first.""" Scores by match count and returns highest-scoring first.
Uses FTS5 index (migration 015) when available O(log N) per query.
Falls back to LIKE scans on older databases.
Nutrition filters use NULL-passthrough: rows without nutrition data
always pass (they may be estimated or absent entirely).
"""
if not ingredient_names: if not ingredient_names:
return [] return []
extra_clauses: list[str] = []
extra_params: list = []
if category:
extra_clauses.append("r.category = ?")
extra_params.append(category)
if max_calories is not None:
extra_clauses.append("(r.calories IS NULL OR r.calories <= ?)")
extra_params.append(max_calories)
if max_sugar_g is not None:
extra_clauses.append("(r.sugar_g IS NULL OR r.sugar_g <= ?)")
extra_params.append(max_sugar_g)
if max_carbs_g is not None:
extra_clauses.append("(r.carbs_g IS NULL OR r.carbs_g <= ?)")
extra_params.append(max_carbs_g)
if max_sodium_mg is not None:
extra_clauses.append("(r.sodium_mg IS NULL OR r.sodium_mg <= ?)")
extra_params.append(max_sodium_mg)
if excluded_ids:
placeholders = ",".join("?" * len(excluded_ids))
extra_clauses.append(f"r.id NOT IN ({placeholders})")
extra_params.extend(excluded_ids)
where_extra = (" AND " + " AND ".join(extra_clauses)) if extra_clauses else ""
if self._fts_ready():
return self._search_recipes_fts(
ingredient_names, limit, where_extra, extra_params
)
return self._search_recipes_like(
ingredient_names, limit, where_extra, extra_params
)
def _search_recipes_fts(
self,
ingredient_names: list[str],
limit: int,
where_extra: str,
extra_params: list,
) -> list[dict]:
"""FTS5-backed ingredient search. Candidates fetched via inverted index;
match_count computed in Python over the small candidate set."""
fts_query = self._build_fts_query(ingredient_names)
if not fts_query:
return []
# Pull up to 10× limit candidates so ranking has enough headroom.
sql = f"""
SELECT r.*
FROM recipes_fts
JOIN recipes r ON r.id = recipes_fts.rowid
WHERE recipes_fts MATCH ?
{where_extra}
LIMIT ?
"""
rows = self._fetch_all(sql, (fts_query, *extra_params, limit * 10))
pantry_set = {n.lower().strip() for n in ingredient_names}
scored: list[dict] = []
for row in rows:
raw = row.get("ingredient_names") or []
names: list[str] = raw if isinstance(raw, list) else json.loads(raw or "[]")
match_count = sum(1 for n in names if n.lower() in pantry_set)
scored.append({**row, "match_count": match_count})
scored.sort(key=lambda r: (-r["match_count"], r["id"]))
return scored[:limit]
def _search_recipes_like(
self,
ingredient_names: list[str],
limit: int,
where_extra: str,
extra_params: list,
) -> list[dict]:
"""Legacy LIKE-based ingredient search (O(N×rows) — slow on large corpora)."""
like_params = [f'%"{n}"%' for n in ingredient_names] like_params = [f'%"{n}"%' for n in ingredient_names]
like_clauses = " OR ".join( like_clauses = " OR ".join(
"r.ingredient_names LIKE ?" for _ in ingredient_names "r.ingredient_names LIKE ?" for _ in ingredient_names
@ -284,20 +657,15 @@ class Store:
"CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END" "CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END"
for _ in ingredient_names for _ in ingredient_names
) )
category_clause = ""
category_params: list = []
if category:
category_clause = "AND r.category = ?"
category_params = [category]
sql = f""" sql = f"""
SELECT r.*, ({match_score}) AS match_count SELECT r.*, ({match_score}) AS match_count
FROM recipes r FROM recipes r
WHERE ({like_clauses}) WHERE ({like_clauses})
{category_clause} {where_extra}
ORDER BY match_count DESC, r.id ASC ORDER BY match_count DESC, r.id ASC
LIMIT ? LIMIT ?
""" """
all_params = like_params + like_params + category_params + [limit] all_params = like_params + like_params + extra_params + [limit]
return self._fetch_all(sql, tuple(all_params)) return self._fetch_all(sql, tuple(all_params))
def get_recipe(self, recipe_id: int) -> dict | None: def get_recipe(self, recipe_id: int) -> dict | None:

View file

@ -12,6 +12,20 @@ class SwapCandidate(BaseModel):
compensation_hints: list[dict] = Field(default_factory=list) compensation_hints: list[dict] = Field(default_factory=list)
class NutritionPanel(BaseModel):
"""Per-recipe macro summary. All values are per-serving when servings is known,
otherwise for the full recipe. None means data is unavailable."""
calories: float | None = None
fat_g: float | None = None
protein_g: float | None = None
carbs_g: float | None = None
fiber_g: float | None = None
sugar_g: float | None = None
sodium_mg: float | None = None
servings: float | None = None
estimated: bool = False # True when nutrition was inferred from ingredient profiles
class RecipeSuggestion(BaseModel): class RecipeSuggestion(BaseModel):
id: int id: int
title: str title: str
@ -20,9 +34,11 @@ class RecipeSuggestion(BaseModel):
swap_candidates: list[SwapCandidate] = Field(default_factory=list) swap_candidates: list[SwapCandidate] = Field(default_factory=list)
missing_ingredients: list[str] = Field(default_factory=list) missing_ingredients: list[str] = Field(default_factory=list)
directions: list[str] = Field(default_factory=list) directions: list[str] = Field(default_factory=list)
prep_notes: list[str] = Field(default_factory=list)
notes: str = "" notes: str = ""
level: int = 1 level: int = 1
is_wildcard: bool = False is_wildcard: bool = False
nutrition: NutritionPanel | None = None
class GroceryLink(BaseModel): class GroceryLink(BaseModel):
@ -40,6 +56,14 @@ class RecipeResult(BaseModel):
rate_limit_count: int = 0 rate_limit_count: int = 0
class NutritionFilters(BaseModel):
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
max_calories: float | None = None
max_sugar_g: float | None = None
max_carbs_g: float | None = None
max_sodium_mg: float | None = None
class RecipeRequest(BaseModel): class RecipeRequest(BaseModel):
pantry_items: list[str] pantry_items: list[str]
level: int = Field(default=1, ge=1, le=4) level: int = Field(default=1, ge=1, le=4)
@ -48,7 +72,10 @@ class RecipeRequest(BaseModel):
hard_day_mode: bool = False hard_day_mode: bool = False
max_missing: int | None = None max_missing: int | None = None
style_id: str | None = None style_id: str | None = None
category: str | None = None
tier: str = "free" tier: str = "free"
has_byok: bool = False has_byok: bool = False
wildcard_confirmed: bool = False wildcard_confirmed: bool = False
allergies: list[str] = Field(default_factory=list) allergies: list[str] = Field(default_factory=list)
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
excluded_ids: list[int] = Field(default_factory=list)

View file

@ -21,6 +21,29 @@ logger = logging.getLogger(__name__)
class ExpirationPredictor: class ExpirationPredictor:
"""Predict expiration dates based on product category and storage location.""" """Predict expiration dates based on product category and storage location."""
# Canonical location names and their aliases.
# All location strings are normalised through this before table lookup.
LOCATION_ALIASES: dict[str, str] = {
'garage_freezer': 'freezer',
'chest_freezer': 'freezer',
'deep_freezer': 'freezer',
'upright_freezer': 'freezer',
'refrigerator': 'fridge',
'frig': 'fridge',
'cupboard': 'cabinet',
'shelf': 'pantry',
'counter': 'pantry',
}
# When a category has no entry for the requested location, try these
# alternatives in order — prioritising same-temperature storage first.
LOCATION_FALLBACK: dict[str, tuple[str, ...]] = {
'freezer': ('freezer', 'fridge', 'pantry', 'cabinet'),
'fridge': ('fridge', 'pantry', 'cabinet', 'freezer'),
'pantry': ('pantry', 'cabinet', 'fridge', 'freezer'),
'cabinet': ('cabinet', 'pantry', 'fridge', 'freezer'),
}
# Default shelf life in days by category and location # Default shelf life in days by category and location
# Sources: USDA FoodKeeper app, FDA guidelines # Sources: USDA FoodKeeper app, FDA guidelines
SHELF_LIFE = { SHELF_LIFE = {
@ -39,6 +62,8 @@ class ExpirationPredictor:
'poultry': {'fridge': 2, 'freezer': 270}, 'poultry': {'fridge': 2, 'freezer': 270},
'chicken': {'fridge': 2, 'freezer': 270}, 'chicken': {'fridge': 2, 'freezer': 270},
'turkey': {'fridge': 2, 'freezer': 270}, 'turkey': {'fridge': 2, 'freezer': 270},
'tempeh': {'fridge': 10, 'freezer': 365},
'tofu': {'fridge': 5, 'freezer': 180},
'ground_meat': {'fridge': 2, 'freezer': 120}, 'ground_meat': {'fridge': 2, 'freezer': 120},
# Seafood # Seafood
'fish': {'fridge': 2, 'freezer': 180}, 'fish': {'fridge': 2, 'freezer': 180},
@ -59,9 +84,9 @@ class ExpirationPredictor:
'bread': {'pantry': 5, 'freezer': 90}, 'bread': {'pantry': 5, 'freezer': 90},
'bakery': {'pantry': 3, 'fridge': 7, 'freezer': 90}, 'bakery': {'pantry': 3, 'fridge': 7, 'freezer': 90},
# Frozen # Frozen
'frozen_foods': {'freezer': 180}, 'frozen_foods': {'freezer': 180, 'fridge': 3},
'frozen_vegetables': {'freezer': 270}, 'frozen_vegetables': {'freezer': 270, 'fridge': 4},
'frozen_fruit': {'freezer': 365}, 'frozen_fruit': {'freezer': 365, 'fridge': 4},
'ice_cream': {'freezer': 60}, 'ice_cream': {'freezer': 60},
# Pantry Staples # Pantry Staples
'canned_goods': {'pantry': 730, 'cabinet': 730}, 'canned_goods': {'pantry': 730, 'cabinet': 730},
@ -91,44 +116,127 @@ class ExpirationPredictor:
'prepared_foods': {'fridge': 4, 'freezer': 90}, 'prepared_foods': {'fridge': 4, 'freezer': 90},
} }
# Keyword lists are checked in declaration order — most specific first.
# Rules:
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
# - frozen prepared foods BEFORE generic protein terms
# - multi-word phrases before single words where ambiguity exists
CATEGORY_KEYWORDS = { CATEGORY_KEYWORDS = {
# ── Frozen prepared foods ─────────────────────────────────────────────
# Before raw protein entries so plant-based frozen products don't
# inherit 23 day raw-meat shelf lives.
'ice_cream': ['ice cream', 'gelato', 'frozen yogurt', 'sorbet', 'sherbet'],
'frozen_fruit': [
'frozen berries', 'frozen mango', 'frozen strawberries',
'frozen blueberries', 'frozen raspberries', 'frozen peaches',
'frozen fruit', 'frozen cherries',
],
'frozen_vegetables': [
'frozen veg', 'frozen corn', 'frozen peas', 'frozen broccoli',
'frozen spinach', 'frozen edamame', 'frozen green beans',
'frozen mixed vegetables', 'frozen carrots',
'peas & carrots', 'peas and carrots', 'mixed vegetables',
'spring rolls', 'vegetable spring rolls',
],
'frozen_foods': [
'plant-based', 'plant based', 'meatless', 'impossible',
"chik'n", 'chikn', 'veggie burger', 'veggie patty',
'nugget', 'tater tot', 'waffle fries', 'hash brown',
'onion ring', 'fish stick', 'fish fillet', 'potsticker',
'dumpling', 'egg roll', 'empanada', 'tamale', 'falafel',
'mac & cheese bite', 'cauliflower wing', 'ranchero potato',
],
# ── Canned / shelf-stable processed goods ─────────────────────────────
# Before raw protein keywords so "canned chicken", "cream of chicken",
# and "lentil soup" resolve here rather than to raw chicken/cream.
'canned_goods': [
'canned', 'can of', 'tin of', 'tinned',
'cream of ', 'condensed soup', 'condensed cream',
'baked beans', 'refried beans',
'canned beans', 'canned tomatoes', 'canned corn', 'canned peas',
'canned soup', 'canned tuna', 'canned salmon', 'canned chicken',
'canned fruit', 'canned peaches', 'canned pears',
'enchilada sauce', 'tomato sauce', 'tomato paste',
'lentil soup', 'bean soup', 'chicken noodle soup',
],
# ── Condiments & brined items ─────────────────────────────────────────
# Before produce/protein terms so brined olives, jarred peppers, etc.
# don't inherit raw vegetable shelf lives.
'ketchup': ['ketchup', 'catsup'],
'mustard': ['mustard', 'dijon', 'dijion', 'stoneground mustard'],
'mayo': ['mayo', 'mayonnaise', 'miracle whip'],
'soy_sauce': ['soy sauce', 'tamari', 'shoyu'],
'salad_dressing': ['salad dressing', 'ranch', 'italian dressing', 'vinaigrette'],
'condiments': [
# brined / jarred items
'dill chips', 'hamburger chips', 'gherkin',
'olive', 'capers', 'jalapeño', 'jalapeno', 'pepperoncini',
'pimiento', 'banana pepper', 'cornichon',
# sauces
'hot sauce', 'hot pepper sauce', 'sriracha', 'cholula',
'worcestershire', 'barbecue sauce', 'bbq sauce',
'chipotle sauce', 'chipotle mayo', 'chipotle creamy',
'salsa', 'chutney', 'relish',
'teriyaki', 'hoisin', 'oyster sauce', 'fish sauce',
'miso', 'ssamjang', 'gochujang', 'doenjang',
'soybean paste', 'fermented soybean',
# nut butters / spreads
'peanut butter', 'almond butter', 'tahini', 'hummus',
# seasoning mixes
'seasoning', 'spice blend', 'borracho',
# other shelf-stable sauces
'yuzu', 'ponzu', 'lizano',
],
# ── Soy / fermented proteins ──────────────────────────────────────────
'tempeh': ['tempeh'],
'tofu': ['tofu', 'bean curd'],
# ── Dairy ─────────────────────────────────────────────────────────────
'milk': ['milk', 'whole milk', '2% milk', 'skim milk', 'almond milk', 'oat milk', 'soy milk'], 'milk': ['milk', 'whole milk', '2% milk', 'skim milk', 'almond milk', 'oat milk', 'soy milk'],
'cheese': ['cheese', 'cheddar', 'mozzarella', 'swiss', 'parmesan', 'feta', 'gouda'], 'cheese': ['cheese', 'cheddar', 'mozzarella', 'swiss', 'parmesan', 'feta', 'gouda', 'velveeta'],
'yogurt': ['yogurt', 'greek yogurt', 'yoghurt'], 'yogurt': ['yogurt', 'greek yogurt', 'yoghurt'],
'butter': ['butter', 'margarine'], 'butter': ['butter', 'margarine'],
'cream': ['cream', 'heavy cream', 'whipping cream', 'sour cream'], # Bare 'cream' removed — "cream of X" is canned_goods (matched above).
'cream': ['heavy cream', 'whipping cream', 'sour cream', 'crème fraîche',
'cream cheese', 'whipped topping', 'whipped cream'],
'eggs': ['eggs', 'egg'], 'eggs': ['eggs', 'egg'],
'beef': ['beef', 'steak', 'roast', 'brisket', 'ribeye', 'sirloin'], # ── Raw proteins ──────────────────────────────────────────────────────
'pork': ['pork', 'bacon', 'ham', 'sausage', 'pork chop'], # After canned/frozen so "canned chicken" is already resolved above.
'chicken': ['chicken', 'chicken breast', 'chicken thigh', 'chicken wings'],
'turkey': ['turkey', 'turkey breast', 'ground turkey'],
'ground_meat': ['ground beef', 'ground pork', 'ground chicken', 'hamburger'],
'fish': ['fish', 'cod', 'tilapia', 'halibut'],
'salmon': ['salmon'], 'salmon': ['salmon'],
'shrimp': ['shrimp', 'prawns'], 'shrimp': ['shrimp', 'prawns'],
'leafy_greens': ['lettuce', 'spinach', 'kale', 'arugula', 'mixed greens', 'salad'], 'fish': ['fish', 'cod', 'tilapia', 'halibut', 'pollock'],
# Specific chicken cuts only — bare 'chicken' handled in generic fallback
'chicken': ['chicken breast', 'chicken thigh', 'chicken wings', 'chicken leg',
'whole chicken', 'rotisserie chicken', 'raw chicken'],
'turkey': ['turkey breast', 'whole turkey'],
'ground_meat': ['ground beef', 'ground pork', 'ground chicken', 'ground turkey',
'ground lamb', 'ground bison'],
'pork': ['pork', 'bacon', 'ham', 'pork chop', 'pork loin'],
'beef': ['beef', 'steak', 'brisket', 'ribeye', 'sirloin', 'roast beef'],
'deli_meat': ['deli', 'sliced turkey', 'sliced ham', 'lunch meat', 'cold cuts',
'prosciutto', 'salami', 'pepperoni'],
# ── Produce ───────────────────────────────────────────────────────────
'leafy_greens': ['lettuce', 'spinach', 'kale', 'arugula', 'mixed greens'],
'berries': ['strawberries', 'blueberries', 'raspberries', 'blackberries'], 'berries': ['strawberries', 'blueberries', 'raspberries', 'blackberries'],
'apples': ['apple', 'apples'], 'apples': ['apple', 'apples'],
'bananas': ['banana', 'bananas'], 'bananas': ['banana', 'bananas'],
'citrus': ['orange', 'lemon', 'lime', 'grapefruit', 'tangerine'], 'citrus': ['orange', 'lemon', 'lime', 'grapefruit', 'tangerine'],
'bread': ['bread', 'loaf', 'baguette', 'roll', 'bagel', 'bun'], # ── Bakery ────────────────────────────────────────────────────────────
'bakery': ['muffin', 'croissant', 'donut', 'danish', 'pastry'], 'bakery': [
'deli_meat': ['deli', 'sliced turkey', 'sliced ham', 'lunch meat', 'cold cuts'], 'muffin', 'croissant', 'donut', 'danish', 'puff pastry', 'pastry puff',
'frozen_vegetables': ['frozen veg', 'frozen corn', 'frozen peas', 'frozen broccoli'], 'cinnamon roll', 'dinner roll', 'parkerhouse roll', 'scone',
'frozen_fruit': ['frozen berries', 'frozen mango', 'frozen strawberries'], ],
'ice_cream': ['ice cream', 'gelato', 'frozen yogurt'], 'bread': ['bread', 'loaf', 'baguette', 'bagel', 'bun', 'pita', 'naan',
'pasta': ['pasta', 'spaghetti', 'penne', 'macaroni', 'noodles'], 'english muffin', 'sourdough'],
'rice': ['rice', 'brown rice', 'white rice', 'jasmine'], # ── Dry pantry staples ────────────────────────────────────────────────
'pasta': ['pasta', 'spaghetti', 'penne', 'macaroni', 'noodles', 'couscous', 'orzo'],
'rice': ['rice', 'brown rice', 'white rice', 'jasmine rice', 'basmati',
'spanish rice', 'rice mix'],
'cereal': ['cereal', 'granola', 'oatmeal'], 'cereal': ['cereal', 'granola', 'oatmeal'],
'chips': ['chips', 'crisps', 'tortilla chips'], 'chips': ['chips', 'crisps', 'tortilla chips', 'pretzel', 'popcorn'],
'cookies': ['cookies', 'biscuits', 'crackers'], 'cookies': ['cookies', 'biscuits', 'crackers', 'graham cracker', 'wafer'],
'ketchup': ['ketchup', 'catsup'], # ── Beverages ─────────────────────────────────────────────────────────
'mustard': ['mustard'], 'juice': ['juice', 'orange juice', 'apple juice', 'lemonade'],
'mayo': ['mayo', 'mayonnaise', 'miracle whip'], 'soda': ['soda', 'cola', 'sprite', 'pepsi', 'coke', 'carbonated soft drink'],
'salad_dressing': ['salad dressing', 'ranch', 'italian dressing', 'vinaigrette'],
'soy_sauce': ['soy sauce', 'tamari'],
'juice': ['juice', 'orange juice', 'apple juice'],
'soda': ['soda', 'pop', 'cola', 'sprite', 'pepsi', 'coke'],
} }
def __init__(self) -> None: def __init__(self) -> None:
@ -176,8 +284,13 @@ class ExpirationPredictor:
product_name: str, product_name: str,
product_category: Optional[str] = None, product_category: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
location: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
"""Determine category from product name, existing category, and tags.""" """Determine category from product name, existing category, and tags.
location is used as a last-resort hint: unknown items in the freezer
default to frozen_foods rather than dry_goods.
"""
if product_category: if product_category:
cat = product_category.lower().strip() cat = product_category.lower().strip()
if cat in self.SHELF_LIFE: if cat in self.SHELF_LIFE:
@ -197,21 +310,36 @@ class ExpirationPredictor:
if any(kw in name for kw in keywords): if any(kw in name for kw in keywords):
return category return category
# Generic single-word fallbacks — checked after the keyword dict so
# multi-word phrases (e.g. "canned chicken") already matched above.
for words, fallback in [ for words, fallback in [
(['meat', 'beef', 'pork', 'chicken'], 'meat'), (['frozen'], 'frozen_foods'),
(['canned', 'tinned'], 'canned_goods'),
# bare 'chicken' / 'sausage' / 'ham' kept here so raw-meat names
# that don't appear in the specific keyword lists still resolve.
(['chicken', 'turkey'], 'poultry'),
(['sausage', 'ham', 'bacon'], 'pork'),
(['beef', 'steak'], 'beef'),
(['meat', 'pork'], 'meat'),
(['vegetable', 'veggie', 'produce'], 'vegetables'), (['vegetable', 'veggie', 'produce'], 'vegetables'),
(['fruit'], 'fruits'), (['fruit'], 'fruits'),
(['dairy'], 'dairy'), (['dairy'], 'dairy'),
(['frozen'], 'frozen_foods'),
]: ]:
if any(w in name for w in words): if any(w in name for w in words):
return fallback return fallback
# Location-aware final fallback: unknown item in a freezer → frozen_foods.
# This handles unlabelled frozen products (e.g. "Birthday Littles",
# "Pulled BBQ Crumbles") without requiring every brand name to be listed.
canon_loc = self._normalize_location(location or '')
if canon_loc == 'freezer':
return 'frozen_foods'
return 'dry_goods' return 'dry_goods'
def get_shelf_life_info(self, category: str, location: str) -> Optional[int]: def get_shelf_life_info(self, category: str, location: str) -> Optional[int]:
"""Shelf life in days for a given category + location, or None.""" """Shelf life in days for a given category + location, or None."""
return self.SHELF_LIFE.get(category.lower().strip(), {}).get(location) return self._lookup_days(category, location)
def list_categories(self) -> List[str]: def list_categories(self) -> List[str]:
return list(self.SHELF_LIFE.keys()) return list(self.SHELF_LIFE.keys())
@ -224,8 +352,18 @@ class ExpirationPredictor:
# ── Private helpers ─────────────────────────────────────────────────────── # ── Private helpers ───────────────────────────────────────────────────────
def _normalize_location(self, location: str) -> str:
"""Resolve location aliases to canonical names."""
loc = location.lower().strip()
return self.LOCATION_ALIASES.get(loc, loc)
def _lookup_days(self, category: Optional[str], location: str) -> Optional[int]: def _lookup_days(self, category: Optional[str], location: str) -> Optional[int]:
"""Pure deterministic lookup — no I/O.""" """Pure deterministic lookup — no I/O.
Normalises location aliases (e.g. garage_freezer freezer) and uses
a context-aware fallback order so pantry items don't accidentally get
fridge shelf-life and vice versa.
"""
if not category: if not category:
return None return None
cat = category.lower().strip() cat = category.lower().strip()
@ -237,13 +375,19 @@ class ExpirationPredictor:
else: else:
return None return None
days = self.SHELF_LIFE[cat].get(location) canon_loc = self._normalize_location(location)
if days is None: shelf = self.SHELF_LIFE[cat]
for loc in ('fridge', 'pantry', 'freezer', 'cabinet'):
days = self.SHELF_LIFE[cat].get(loc) # Try the canonical location first, then work through the
if days is not None: # context-aware fallback chain for that location type.
break fallback_order = self.LOCATION_FALLBACK.get(
return days canon_loc, (canon_loc, 'pantry', 'fridge', 'cabinet', 'freezer')
)
for loc in fallback_order:
days = shelf.get(loc)
if days is not None:
return days
return None
def _llm_predict_days( def _llm_predict_days(
self, self,

View file

@ -0,0 +1,647 @@
"""
Assembly-dish template matcher for Level 1/2.
Assembly dishes (burritos, stir fry, fried rice, omelettes, sandwiches, etc.)
are defined by structural roles -- container + filler + sauce -- not by a fixed
ingredient list. The corpus can never fully cover them.
This module fires when the pantry covers all *required* roles of a template.
Results are injected at the top of the Level 1/2 suggestion list with negative
ids (client displays them identically to corpus recipes).
Templates define:
- required: list of role sets -- ALL must have at least one pantry match
- optional: role sets whose matched items are shown as extras
- directions: short cooking instructions
- notes: serving suggestions / variations
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from app.models.schemas.recipe import RecipeSuggestion
# IDs in range -100..-1 are reserved for assembly-generated suggestions
_ASSEMBLY_ID_START = -1
@dataclass
class AssemblyRole:
"""One role in a template (e.g. 'protein').
display: human-readable role label
keywords: substrings matched against pantry item (lowercased)
"""
display: str
keywords: list[str]
@dataclass
class AssemblyTemplate:
"""A template assembly dish."""
id: int
title: str
required: list[AssemblyRole]
optional: list[AssemblyRole]
directions: list[str]
notes: str = ""
def _matches_role(role: AssemblyRole, pantry_set: set[str]) -> list[str]:
"""Return pantry items that satisfy this role.
Single-word keywords use whole-word matching (word must appear as a
discrete token) so short words like 'pea', 'ham', 'egg' don't false-match
inside longer words like 'peanut', 'hamburger', 'eggnog'.
Multi-word keywords (e.g. 'burger patt') use substring matching.
"""
hits: list[str] = []
for item in pantry_set:
item_lower = item.lower()
item_words = set(item_lower.split())
for kw in role.keywords:
if " " in kw:
# Multi-word: substring match
if kw in item_lower:
hits.append(item)
break
else:
# Single-word: whole-word match only
if kw in item_words:
hits.append(item)
break
return hits
def _pick_one(items: list[str], seed: int) -> str:
"""Deterministically pick one item from a list using a seed."""
return sorted(items)[seed % len(items)]
def _pantry_hash(pantry_set: set[str]) -> int:
"""Stable integer derived from pantry contents — used for deterministic picks."""
key = ",".join(sorted(pantry_set))
return int(hashlib.md5(key.encode()).hexdigest(), 16) # noqa: S324 — non-crypto use
def _keyword_label(item: str, role: AssemblyRole) -> str:
"""Return a short, clean label derived from the keyword that matched.
Uses the longest matching keyword (most specific) as the base label,
then title-cases it. This avoids pasting full raw pantry names like
'Organic Extra Firm Tofu' into titles just 'Tofu' instead.
"""
lower = item.lower()
best_kw = ""
for kw in role.keywords:
if kw in lower and len(kw) > len(best_kw):
best_kw = kw
label = (best_kw or item).strip().title()
# Drop trailing 's' from keywords like "beans" → "Bean" when it reads better
return label
def _personalized_title(tmpl: AssemblyTemplate, pantry_set: set[str], seed: int) -> str:
"""Build a specific title using actual pantry items, e.g. 'Chicken & Broccoli Burrito'.
Uses the matched keyword as the label (not the full pantry item name) so
'Organic Extra Firm Tofu Block' 'Tofu' in the title.
Picks at most two roles; prefers protein then vegetable.
"""
priority_displays = ["protein", "vegetables", "sauce base", "cheese"]
picked: list[str] = []
for display in priority_displays:
for role in tmpl.optional:
if role.display != display:
continue
hits = _matches_role(role, pantry_set)
if hits:
item = _pick_one(hits, seed)
label = _keyword_label(item, role)
if label not in picked:
picked.append(label)
if len(picked) >= 2:
break
if not picked:
return tmpl.title
return f"{' & '.join(picked)} {tmpl.title}"
# ---------------------------------------------------------------------------
# Template definitions
# ---------------------------------------------------------------------------
ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
AssemblyTemplate(
id=-1,
title="Burrito / Taco",
required=[
AssemblyRole("tortilla or wrap", [
"tortilla", "wrap", "taco shell", "flatbread", "pita",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "steak", "pork", "sausage", "hamburger",
"burger patt", "shrimp", "egg", "tofu", "beans", "bean",
]),
AssemblyRole("rice or starch", ["rice", "quinoa", "potato"]),
AssemblyRole("cheese", [
"cheese", "cheddar", "mozzarella", "monterey", "queso",
]),
AssemblyRole("salsa or sauce", [
"salsa", "hot sauce", "taco sauce", "enchilada", "guacamole",
]),
AssemblyRole("sour cream or yogurt", ["sour cream", "greek yogurt", "crema"]),
AssemblyRole("vegetables", [
"pepper", "onion", "tomato", "lettuce", "corn", "avocado",
"spinach", "broccoli", "zucchini",
]),
],
directions=[
"Warm the tortilla in a dry skillet or microwave for 20 seconds.",
"Heat any proteins or vegetables in a pan until cooked through.",
"Layer ingredients down the center: rice first, then protein, then vegetables.",
"Add cheese, salsa, and sour cream last so they stay cool.",
"Fold in the sides and roll tightly. Optionally toast seam-side down 1-2 minutes.",
],
notes="Works as a burrito (rolled), taco (folded), or quesadilla (cheese only, pressed flat).",
),
AssemblyTemplate(
id=-2,
title="Fried Rice",
required=[
AssemblyRole("cooked rice", [
"rice", "leftover rice", "instant rice", "microwavable rice",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "shrimp", "egg", "tofu",
"sausage", "ham", "spam",
]),
AssemblyRole("soy sauce or seasoning", [
"soy sauce", "tamari", "teriyaki", "oyster sauce", "fish sauce",
]),
AssemblyRole("oil", ["oil", "butter", "sesame"]),
AssemblyRole("egg", ["egg"]),
AssemblyRole("vegetables", [
"carrot", "peas", "corn", "onion", "scallion", "green onion",
"broccoli", "bok choy", "bean sprout", "zucchini", "spinach",
]),
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
],
directions=[
"Use day-old cold rice if available -- it fries better than fresh.",
"Heat oil in a large skillet or wok over high heat.",
"Add garlic/ginger and any raw vegetables; stir fry 2-3 minutes.",
"Push to the side, scramble eggs in the same pan if using.",
"Add protein (pre-cooked or raw) and cook through.",
"Add rice, breaking up clumps. Stir fry until heated and lightly toasted.",
"Season with soy sauce and any other sauces. Toss to combine.",
],
notes="Add a fried egg on top. A drizzle of sesame oil at the end adds a lot.",
),
AssemblyTemplate(
id=-3,
title="Omelette / Scramble",
required=[
AssemblyRole("eggs", ["egg"]),
],
optional=[
AssemblyRole("cheese", [
"cheese", "cheddar", "mozzarella", "feta", "parmesan",
]),
AssemblyRole("vegetables", [
"pepper", "onion", "tomato", "spinach", "mushroom",
"broccoli", "zucchini", "scallion", "avocado",
]),
AssemblyRole("protein", [
"ham", "bacon", "sausage", "chicken", "turkey",
"smoked salmon",
]),
AssemblyRole("herbs or seasoning", [
"herb", "basil", "chive", "parsley", "salt", "pepper",
"hot sauce", "salsa",
]),
],
directions=[
"Beat eggs with a splash of water or milk and a pinch of salt.",
"Saute any vegetables and proteins in butter or oil over medium heat until softened.",
"Pour eggs over fillings (scramble) or pour into a clean buttered pan (omelette).",
"For omelette: cook until nearly set, add fillings to one side, fold over.",
"For scramble: stir gently over medium-low heat until just set.",
"Season and serve immediately.",
],
notes="Works for breakfast, lunch, or a quick dinner. Any leftover vegetables work well.",
),
AssemblyTemplate(
id=-4,
title="Stir Fry",
required=[
AssemblyRole("vegetables", [
"pepper", "broccoli", "carrot", "snap pea", "bok choy",
"zucchini", "mushroom", "corn", "onion", "bean sprout",
"cabbage", "spinach", "asparagus",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "shrimp", "tofu", "egg",
]),
AssemblyRole("sauce", [
"soy sauce", "teriyaki", "oyster sauce", "hoisin",
"stir fry sauce", "sesame",
]),
AssemblyRole("starch base", ["rice", "noodle", "pasta", "ramen"]),
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
AssemblyRole("oil", ["oil", "sesame"]),
],
directions=[
"Cut all proteins and vegetables into similar-sized pieces for even cooking.",
"Heat oil in a wok or large skillet over the highest heat your stove allows.",
"Cook protein first until nearly done; remove and set aside.",
"Add dense vegetables (carrots, broccoli) first; quick-cooking veg last.",
"Return protein, add sauce, and toss everything together for 1-2 minutes.",
"Serve over rice or noodles.",
],
notes="High heat is the key. Do not crowd the pan -- cook in batches if needed.",
),
AssemblyTemplate(
id=-5,
title="Pasta with Whatever You Have",
required=[
AssemblyRole("pasta", [
"pasta", "spaghetti", "penne", "fettuccine", "rigatoni",
"linguine", "rotini", "farfalle", "macaroni", "noodle",
]),
],
optional=[
AssemblyRole("sauce base", [
"tomato", "marinara", "pasta sauce", "cream", "butter",
"olive oil", "pesto",
]),
AssemblyRole("protein", [
"chicken", "beef", "pork", "shrimp", "sausage", "bacon",
"ham", "tuna", "canned fish",
]),
AssemblyRole("cheese", [
"parmesan", "romano", "mozzarella", "ricotta", "feta",
]),
AssemblyRole("vegetables", [
"tomato", "spinach", "mushroom", "pepper", "zucchini",
"broccoli", "artichoke", "olive", "onion",
]),
AssemblyRole("garlic", ["garlic"]),
],
directions=[
"Cook pasta in well-salted boiling water until al dente. Reserve 1 cup pasta water.",
"While pasta cooks, saute garlic in olive oil over medium heat.",
"Add proteins and cook through; add vegetables until tender.",
"Add sauce base and simmer 5 minutes. Add pasta water to loosen if needed.",
"Toss cooked pasta with sauce. Finish with cheese if using.",
],
notes="Pasta water is the secret -- the starch thickens and binds any sauce.",
),
AssemblyTemplate(
id=-6,
title="Sandwich / Wrap",
required=[
AssemblyRole("bread or wrap", [
"bread", "roll", "bun", "wrap", "tortilla", "pita",
"bagel", "english muffin", "croissant", "flatbread",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "turkey", "ham", "roast beef", "tuna", "egg",
"bacon", "salami", "pepperoni", "tofu", "tempeh",
]),
AssemblyRole("cheese", [
"cheese", "cheddar", "swiss", "provolone", "mozzarella",
]),
AssemblyRole("condiment", [
"mayo", "mustard", "ketchup", "hot sauce", "ranch",
"hummus", "pesto", "aioli",
]),
AssemblyRole("vegetables", [
"lettuce", "tomato", "onion", "cucumber", "avocado",
"pepper", "sprout", "arugula",
]),
],
directions=[
"Toast bread if desired.",
"Spread condiments on both inner surfaces.",
"Layer protein first, then cheese, then vegetables.",
"Press together and cut diagonally.",
],
notes="Leftovers, deli meat, canned fish -- nearly anything works between bread.",
),
AssemblyTemplate(
id=-7,
title="Grain Bowl",
required=[
AssemblyRole("grain base", [
"rice", "quinoa", "farro", "barley", "couscous",
"bulgur", "freekeh", "polenta",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "tofu", "egg", "shrimp",
"beans", "bean", "lentil", "chickpea",
]),
AssemblyRole("vegetables", [
"roasted", "broccoli", "carrot", "kale", "spinach",
"cucumber", "tomato", "corn", "edamame", "avocado",
"beet", "sweet potato",
]),
AssemblyRole("dressing or sauce", [
"dressing", "tahini", "vinaigrette", "sauce",
"olive oil", "lemon", "soy sauce",
]),
AssemblyRole("toppings", [
"nut", "seed", "feta", "parmesan", "herb",
]),
],
directions=[
"Cook grain base according to package directions; season with salt.",
"Roast or saute vegetables with oil, salt, and pepper until tender.",
"Cook or slice protein.",
"Arrange grain in a bowl, top with protein and vegetables.",
"Drizzle with dressing and add toppings.",
],
notes="Great for meal prep -- cook grains and proteins in bulk, assemble bowls all week.",
),
AssemblyTemplate(
id=-8,
title="Soup / Stew",
required=[
AssemblyRole("broth or liquid base", [
"broth", "stock", "bouillon",
"tomato sauce", "coconut milk", "cream of",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "sausage", "shrimp",
"beans", "bean", "lentil", "tofu",
]),
AssemblyRole("vegetables", [
"carrot", "celery", "onion", "potato", "tomato",
"spinach", "kale", "corn", "pea", "zucchini",
]),
AssemblyRole("starch thickener", [
"potato", "pasta", "noodle", "rice", "barley",
"flour", "cornstarch",
]),
AssemblyRole("seasoning", [
"garlic", "herb", "bay leaf", "thyme", "rosemary",
"cumin", "paprika", "chili",
]),
],
directions=[
"Saute onion, celery, and garlic in oil until softened, about 5 minutes.",
"Add any raw proteins and cook until browned.",
"Add broth or liquid base and bring to a simmer.",
"Add dense vegetables (carrots, potatoes) first; quick-cooking veg in the last 10 minutes.",
"Add starches and cook until tender.",
"Season to taste and simmer at least 20 minutes for flavors to develop.",
],
notes="Soups and stews improve overnight in the fridge. Almost any combination works.",
),
AssemblyTemplate(
id=-9,
title="Casserole / Bake",
required=[
AssemblyRole("starch or base", [
"pasta", "rice", "potato", "noodle", "bread",
"tortilla", "polenta", "grits", "macaroni",
]),
AssemblyRole("binder or sauce", [
"cream of", "cheese", "cream cheese", "sour cream",
"soup mix", "gravy", "tomato sauce", "marinara",
"broth", "stock", "milk", "cream",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "tuna", "ham", "sausage",
"ground", "shrimp", "beans", "bean", "lentil",
]),
AssemblyRole("vegetables", [
"broccoli", "corn", "pea", "onion", "mushroom",
"spinach", "zucchini", "tomato", "pepper", "carrot",
]),
AssemblyRole("cheese topping", [
"cheddar", "mozzarella", "parmesan", "swiss",
"cheese", "breadcrumb",
]),
AssemblyRole("seasoning", [
"garlic", "herb", "thyme", "rosemary", "paprika",
"onion powder", "salt", "pepper",
]),
],
directions=[
"Preheat oven to 375 F (190 C). Grease a 9x13 baking dish.",
"Cook starch base (pasta, rice, potato) until just underdone -- it finishes in the oven.",
"Mix cooked starch with sauce/binder, protein, and vegetables in the dish.",
"Season generously -- casseroles need salt.",
"Top with cheese or breadcrumbs if using.",
"Bake covered 25 minutes, then uncovered 15 minutes until golden and bubbly.",
],
notes="Classic pantry dump dinner. Cream of anything soup is the universal binder.",
),
AssemblyTemplate(
id=-10,
title="Pancakes / Waffles / Quick Bread",
required=[
AssemblyRole("flour or baking mix", [
"flour", "bisquick", "pancake mix", "waffle mix",
"baking mix", "cornmeal", "oats",
]),
AssemblyRole("leavening or egg", [
"egg", "baking powder", "baking soda", "yeast",
]),
],
optional=[
AssemblyRole("liquid", [
"milk", "buttermilk", "water", "juice",
"almond milk", "oat milk", "sour cream",
]),
AssemblyRole("fat", [
"butter", "oil", "margarine",
]),
AssemblyRole("sweetener", [
"sugar", "honey", "maple syrup", "brown sugar",
]),
AssemblyRole("mix-ins", [
"blueberr", "banana", "apple", "chocolate chip",
"nut", "berry", "cinnamon", "vanilla",
]),
],
directions=[
"Whisk dry ingredients (flour, leavening, sugar, salt) together in a bowl.",
"Whisk wet ingredients (egg, milk, melted butter) in a separate bowl.",
"Fold wet into dry until just combined -- lumps are fine, do not overmix.",
"For pancakes: cook on a buttered griddle over medium heat, flip when bubbles form.",
"For waffles: pour into preheated waffle iron according to manufacturer instructions.",
"For muffins or quick bread: pour into greased pan, bake at 375 F until a toothpick comes out clean.",
],
notes="Overmixing develops gluten and makes pancakes tough. Stop when just combined.",
),
AssemblyTemplate(
id=-11,
title="Porridge / Oatmeal",
required=[
AssemblyRole("oats or grain porridge", [
"oat", "porridge", "grits", "semolina", "cream of wheat",
"polenta", "congee", "rice porridge",
]),
],
optional=[
AssemblyRole("liquid", ["milk", "water", "almond milk", "oat milk", "coconut milk"]),
AssemblyRole("sweetener", ["sugar", "honey", "maple syrup", "brown sugar", "agave"]),
AssemblyRole("fruit", ["banana", "berry", "apple", "raisin", "date", "mango"]),
AssemblyRole("toppings", ["nut", "seed", "granola", "coconut", "chocolate"]),
AssemblyRole("spice", ["cinnamon", "nutmeg", "vanilla", "cardamom"]),
],
directions=[
"Combine oats with liquid in a pot — typically 1 part oats to 2 parts liquid.",
"Bring to a gentle simmer over medium heat, stirring occasionally.",
"Cook 5 minutes (rolled oats) or 2 minutes (quick oats) until thickened to your liking.",
"Stir in sweetener and spices.",
"Top with fruit, nuts, or seeds and serve immediately.",
],
notes="Overnight oats: skip cooking — soak oats in cold milk overnight in the fridge.",
),
AssemblyTemplate(
id=-12,
title="Pie / Pot Pie",
required=[
AssemblyRole("pastry or crust", [
"pastry", "puff pastry", "pie crust", "shortcrust",
"pie shell", "phyllo", "filo", "biscuit",
]),
],
optional=[
AssemblyRole("protein filling", [
"chicken", "beef", "pork", "lamb", "turkey", "tofu",
"mushroom", "beans", "bean", "lentil", "tuna", "salmon",
]),
AssemblyRole("vegetables", [
"carrot", "pea", "corn", "potato", "onion", "leek",
"broccoli", "spinach", "mushroom", "parsnip", "swede",
]),
AssemblyRole("sauce or binder", [
"gravy", "cream of", "stock", "broth", "cream",
"white sauce", "bechamel", "cheese sauce",
]),
AssemblyRole("seasoning", [
"thyme", "rosemary", "sage", "garlic", "herb",
"mustard", "worcestershire",
]),
AssemblyRole("sweet filling", [
"apple", "berry", "cherry", "pear", "peach",
"rhubarb", "plum", "custard",
]),
],
directions=[
"For pot pie: make a sauce by combining stock or cream-of-something with cooked vegetables and protein.",
"Season generously — fillings need more salt than you think.",
"Pour filling into a baking dish and top with pastry, pressing edges to seal.",
"Cut a few slits in the top to release steam. Brush with egg wash or milk if available.",
"Bake at 400 F (200 C) for 25-35 minutes until pastry is golden brown.",
"For sweet pie: fill unbaked crust with fruit filling, top with second crust or crumble, bake similarly.",
],
notes="Puff pastry from the freezer is the shortcut to impressive pot pies. Thaw in the fridge overnight.",
),
AssemblyTemplate(
id=-13,
title="Pudding / Custard",
required=[
AssemblyRole("dairy or dairy-free milk", [
"milk", "cream", "oat milk", "almond milk",
"soy milk", "coconut milk",
]),
AssemblyRole("thickener or set", [
"egg", "cornstarch", "custard powder", "gelatin",
"agar", "tapioca", "arrowroot",
]),
],
optional=[
AssemblyRole("sweetener", ["sugar", "honey", "maple syrup", "condensed milk"]),
AssemblyRole("flavouring", [
"vanilla", "chocolate", "cocoa", "caramel",
"lemon", "orange", "cinnamon", "nutmeg",
]),
AssemblyRole("starchy base", [
"rice", "bread", "sponge", "cake", "biscuit",
]),
AssemblyRole("fruit", ["raisin", "sultana", "berry", "banana", "apple"]),
],
directions=[
"For stovetop custard: whisk eggs and sugar together, heat milk until steaming.",
"Slowly pour hot milk into egg mixture while whisking constantly (tempering).",
"Return to low heat and stir until mixture coats the back of a spoon.",
"For cornstarch pudding: whisk cornstarch into cold milk first, then heat while stirring.",
"Add flavourings (vanilla, cocoa) once off heat.",
"Pour into dishes and refrigerate at least 2 hours to set.",
],
notes="UK-style pudding is broad — bread pudding, rice pudding, spotted dick, treacle sponge all count.",
),
]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def match_assembly_templates(
pantry_items: list[str],
pantry_set: set[str],
excluded_ids: list[int],
) -> list[RecipeSuggestion]:
"""Return assembly-dish suggestions whose required roles are all satisfied.
Titles are personalized with specific pantry items (deterministically chosen
from the pantry contents so the same pantry always produces the same title).
Skips templates whose id is in excluded_ids (dismiss/load-more support).
"""
excluded = set(excluded_ids)
seed = _pantry_hash(pantry_set)
results: list[RecipeSuggestion] = []
for tmpl in ASSEMBLY_TEMPLATES:
if tmpl.id in excluded:
continue
# All required roles must be satisfied
if any(not _matches_role(role, pantry_set) for role in tmpl.required):
continue
optional_hit_count = sum(
1 for role in tmpl.optional if _matches_role(role, pantry_set)
)
results.append(RecipeSuggestion(
id=tmpl.id,
title=_personalized_title(tmpl, pantry_set, seed + tmpl.id),
match_count=len(tmpl.required) + optional_hit_count,
element_coverage={},
swap_candidates=[],
missing_ingredients=[],
directions=tmpl.directions,
notes=tmpl.notes,
level=1,
is_wildcard=False,
nutrition=None,
))
# Sort by optional coverage descending — best-matched templates first
results.sort(key=lambda s: s.match_count, reverse=True)
return results

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
import os import os
import re
from contextlib import nullcontext from contextlib import nullcontext
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -54,6 +55,9 @@ class LLMRecipeGenerator:
lines: list[str] = [ lines: list[str] = [
"You are a creative chef. Generate a recipe using the ingredients below.", "You are a creative chef. Generate a recipe using the ingredients below.",
"IMPORTANT: When you use a pantry item, list it in Ingredients using its exact name from the pantry list. Do not add adjectives, quantities, or cooking states (e.g. use 'butter', not 'unsalted butter' or '2 tbsp butter').",
"IMPORTANT: Only use pantry items that make culinary sense for the dish. Do NOT force flavoured/sweetened items (vanilla yoghurt, fruit yoghurt, jam, dessert sauces, flavoured syrups) into savoury dishes. Plain yoghurt, plain cream, and plain dairy are fine in savoury cooking.",
"IMPORTANT: Do not default to the same ingredient repeatedly across dishes. If a pantry item does not genuinely improve this specific dish, leave it out.",
"", "",
f"Pantry items: {', '.join(safe_pantry)}", f"Pantry items: {', '.join(safe_pantry)}",
] ]
@ -82,10 +86,13 @@ class LLMRecipeGenerator:
lines += [ lines += [
"", "",
"Reply in this format:", "Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
"Title: <recipe name>", "Title: <name of the dish>",
"Ingredients: <comma-separated list>", "Ingredients: <comma-separated list>",
"Directions: <numbered steps>", "Directions:",
"1. <first step>",
"2. <second step>",
"3. <continue for each step>",
"Notes: <optional tips>", "Notes: <optional tips>",
] ]
@ -101,6 +108,7 @@ class LLMRecipeGenerator:
lines: list[str] = [ lines: list[str] = [
"Surprise me with a creative, unexpected recipe.", "Surprise me with a creative, unexpected recipe.",
"Only use ingredients that make culinary sense together. Do not force flavoured/sweetened items (vanilla yoghurt, flavoured syrups, jam) into savoury dishes.",
f"Ingredients available: {', '.join(safe_pantry)}", f"Ingredients available: {', '.join(safe_pantry)}",
] ]
@ -112,7 +120,13 @@ class LLMRecipeGenerator:
lines += [ lines += [
"Treat any mystery ingredient as a wildcard — use your imagination.", "Treat any mystery ingredient as a wildcard — use your imagination.",
"Title: <name> | Ingredients: <list> | Directions: <steps>", "Reply using EXACTLY this plain-text format — no markdown, no bold:",
"Title: <name of the dish>",
"Ingredients: <comma-separated list>",
"Directions:",
"1. <first step>",
"2. <second step>",
"Notes: <optional tips>",
] ]
return "\n".join(lines) return "\n".join(lines)
@ -169,8 +183,18 @@ class LLMRecipeGenerator:
logger.error("LLM call failed: %s", exc) logger.error("LLM call failed: %s", exc)
return "" return ""
# Strips markdown bold/italic markers so "**Directions:**" parses like "Directions:"
_MD_BOLD = re.compile(r"\*{1,2}([^*]+)\*{1,2}")
def _strip_md(self, text: str) -> str:
return self._MD_BOLD.sub(r"\1", text).strip()
def _parse_response(self, response: str) -> dict[str, str | list[str]]: def _parse_response(self, response: str) -> dict[str, str | list[str]]:
"""Parse LLM response text into structured recipe fields.""" """Parse LLM response text into structured recipe fields.
Handles both plain-text and markdown-formatted responses. Directions are
preserved as newline-separated text so the caller can split on step numbers.
"""
result: dict[str, str | list[str]] = { result: dict[str, str | list[str]] = {
"title": "", "title": "",
"ingredients": [], "ingredients": [],
@ -184,14 +208,17 @@ class LLMRecipeGenerator:
def _flush(key: str | None, buf: list[str]) -> None: def _flush(key: str | None, buf: list[str]) -> None:
if key is None or not buf: if key is None or not buf:
return return
text = " ".join(buf).strip() if key == "directions":
if key == "ingredients": result["directions"] = "\n".join(buf)
elif key == "ingredients":
text = " ".join(buf)
result["ingredients"] = [i.strip() for i in text.split(",") if i.strip()] result["ingredients"] = [i.strip() for i in text.split(",") if i.strip()]
else: else:
result[key] = text result[key] = " ".join(buf).strip()
for line in response.splitlines(): for raw_line in response.splitlines():
lower = line.lower().strip() line = self._strip_md(raw_line)
lower = line.lower()
if lower.startswith("title:"): if lower.startswith("title:"):
_flush(current_key, buffer) _flush(current_key, buffer)
current_key, buffer = "title", [line.split(":", 1)[1].strip()] current_key, buffer = "title", [line.split(":", 1)[1].strip()]
@ -200,12 +227,18 @@ class LLMRecipeGenerator:
current_key, buffer = "ingredients", [line.split(":", 1)[1].strip()] current_key, buffer = "ingredients", [line.split(":", 1)[1].strip()]
elif lower.startswith("directions:"): elif lower.startswith("directions:"):
_flush(current_key, buffer) _flush(current_key, buffer)
current_key, buffer = "directions", [line.split(":", 1)[1].strip()] rest = line.split(":", 1)[1].strip()
current_key, buffer = "directions", ([rest] if rest else [])
elif lower.startswith("notes:"): elif lower.startswith("notes:"):
_flush(current_key, buffer) _flush(current_key, buffer)
current_key, buffer = "notes", [line.split(":", 1)[1].strip()] current_key, buffer = "notes", [line.split(":", 1)[1].strip()]
elif current_key and line.strip(): elif current_key and line.strip():
buffer.append(line.strip()) buffer.append(line.strip())
elif current_key is None and line.strip() and ":" not in line:
# Before any section header: a 2-10 word colon-free line is the dish name
words = line.split()
if 2 <= len(words) <= 10 and not result["title"]:
result["title"] = line.strip()
_flush(current_key, buffer) _flush(current_key, buffer)
return result return result
@ -230,17 +263,37 @@ class LLMRecipeGenerator:
parsed = self._parse_response(response) parsed = self._parse_response(response)
raw_directions = parsed.get("directions", "") raw_directions = parsed.get("directions", "")
directions_list: list[str] = ( if isinstance(raw_directions, str):
[s.strip() for s in raw_directions.split(".") if s.strip()] # Split on newlines; strip leading step numbers ("1.", "2.", "- ", "* ")
if isinstance(raw_directions, str) _step_prefix = re.compile(r"^\s*(?:\d+[.)]\s*|[-*]\s+)")
else list(raw_directions) directions_list = [
) _step_prefix.sub("", s).strip()
for s in raw_directions.splitlines()
if s.strip()
]
else:
directions_list = list(raw_directions)
raw_notes = parsed.get("notes", "") raw_notes = parsed.get("notes", "")
notes_str: str = raw_notes if isinstance(raw_notes, str) else "" notes_str: str = raw_notes if isinstance(raw_notes, str) else ""
all_ingredients: list[str] = list(parsed.get("ingredients", [])) all_ingredients: list[str] = list(parsed.get("ingredients", []))
pantry_set = {item.lower() for item in (req.pantry_items or [])} pantry_set = {item.lower() for item in (req.pantry_items or [])}
missing = [i for i in all_ingredients if i.lower() not in pantry_set]
# Strip leading quantities/units (e.g. "2 cups rice" → "rice") before
# checking against pantry, since LLMs return formatted ingredient strings.
_qty_re = re.compile(
r"^\s*[\d½¼¾⅓⅔]+[\s/\-]*" # leading digits or fractions
r"(?:cup|cups|tbsp|tsp|tablespoon|teaspoon|oz|lb|lbs|g|kg|"
r"can|cans|clove|cloves|bunch|package|pkg|slice|slices|"
r"piece|pieces|pinch|dash|handful|head|heads|large|small|medium"
r")s?\b[,\s]*",
re.IGNORECASE,
)
missing = []
for ing in all_ingredients:
bare = _qty_re.sub("", ing).strip().lower()
if bare not in pantry_set and ing.lower() not in pantry_set:
missing.append(bare or ing)
suggestion = RecipeSuggestion( suggestion = RecipeSuggestion(
id=0, id=0,

View file

@ -20,13 +20,353 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from app.db.store import Store from app.db.store import Store
from app.models.schemas.recipe import GroceryLink, RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate
from app.services.recipe.assembly_recipes import match_assembly_templates
from app.services.recipe.element_classifier import ElementClassifier from app.services.recipe.element_classifier import ElementClassifier
from app.services.recipe.grocery_links import GroceryLinkBuilder from app.services.recipe.grocery_links import GroceryLinkBuilder
from app.services.recipe.substitution_engine import SubstitutionEngine from app.services.recipe.substitution_engine import SubstitutionEngine
_LEFTOVER_DAILY_MAX_FREE = 5 _LEFTOVER_DAILY_MAX_FREE = 5
# Words that carry no ingredient-identity signal — stripped before overlap scoring
_SWAP_STOPWORDS = frozenset({
"a", "an", "the", "of", "in", "for", "with", "and", "or",
"to", "from", "at", "by", "as", "on",
})
# Maps product-label substrings to recipe-corpus canonical terms.
# Kept in sync with Store._FTS_SYNONYMS — both must agree on canonical names.
# Used to expand pantry_set so single-word recipe ingredients can match
# multi-word product names (e.g. "hamburger" satisfied by "burger patties").
_PANTRY_LABEL_SYNONYMS: dict[str, str] = {
"burger patt": "hamburger",
"beef patt": "hamburger",
"ground beef": "hamburger",
"ground chuck": "hamburger",
"ground round": "hamburger",
"mince": "hamburger",
"veggie burger": "hamburger",
"beyond burger": "hamburger",
"impossible burger": "hamburger",
"plant burger": "hamburger",
"chicken patt": "chicken patty",
"kielbasa": "sausage",
"bratwurst": "sausage",
"frankfurter": "hotdog",
"wiener": "hotdog",
"chicken breast": "chicken",
"chicken thigh": "chicken",
"chicken drumstick": "chicken",
"chicken wing": "chicken",
"rotisserie chicken": "chicken",
"chicken tender": "chicken",
"chicken strip": "chicken",
"chicken piece": "chicken",
"fake chicken": "chicken",
"plant chicken": "chicken",
"vegan chicken": "chicken",
"daring": "chicken",
"gardein chick": "chicken",
"quorn chick": "chicken",
"chick'n": "chicken",
"chikn": "chicken",
"not-chicken": "chicken",
"no-chicken": "chicken",
# Plant-based beef subs → broad "beef" (strips ≠ ground; texture matters)
"not-beef": "beef",
"no-beef": "beef",
"plant beef": "beef",
"vegan beef": "beef",
# Plant-based pork subs
"not-pork": "pork",
"no-pork": "pork",
"plant pork": "pork",
"vegan pork": "pork",
"omnipork": "pork",
"omni pork": "pork",
# Generic alt-meat catch-alls → broad "beef"
"fake meat": "beef",
"plant meat": "beef",
"vegan meat": "beef",
"meat-free": "beef",
"meatless": "beef",
"pork chop": "pork",
"pork loin": "pork",
"pork tenderloin": "pork",
"marinara": "tomato sauce",
"pasta sauce": "tomato sauce",
"spaghetti sauce": "tomato sauce",
"pizza sauce": "tomato sauce",
"macaroni": "pasta",
"noodles": "pasta",
"spaghetti": "pasta",
"penne": "pasta",
"fettuccine": "pasta",
"rigatoni": "pasta",
"linguine": "pasta",
"rotini": "pasta",
"farfalle": "pasta",
"shredded cheese": "cheese",
"sliced cheese": "cheese",
"american cheese": "cheese",
"cheddar": "cheese",
"mozzarella": "cheese",
"heavy cream": "cream",
"whipping cream": "cream",
"half and half": "cream",
"burger bun": "buns",
"hamburger bun": "buns",
"hot dog bun": "buns",
"bread roll": "buns",
"dinner roll": "buns",
# Tortillas / wraps — assembly dishes (burritos, tacos, quesadillas)
"flour tortilla": "tortillas",
"corn tortilla": "tortillas",
"tortilla wrap": "tortillas",
"soft taco shell": "tortillas",
"taco shell": "taco shells",
"pita bread": "pita",
"flatbread": "flatbread",
# Canned beans — extremely interchangeable in assembly dishes
"black bean": "beans",
"pinto bean": "beans",
"kidney bean": "beans",
"refried bean": "beans",
"chickpea": "beans",
"garbanzo": "beans",
# Rice variants
"white rice": "rice",
"brown rice": "rice",
"jasmine rice": "rice",
"basmati rice": "rice",
"instant rice": "rice",
"microwavable rice": "rice",
# Salsa / hot sauce
"hot sauce": "salsa",
"taco sauce": "salsa",
"enchilada sauce": "salsa",
# Sour cream / Greek yogurt — functional substitutes
"greek yogurt": "sour cream",
# Frozen/prepackaged meal token extraction — handled by individual token
# fallback in _normalize_for_fts; these are the most common single-serve meal types
"lean cuisine": "casserole",
"stouffer": "casserole",
"healthy choice": "casserole",
"marie callender": "casserole",
}
# Matches leading quantity/unit prefixes in recipe ingredient strings,
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
# "3 oz. butter" → "butter"
_QUANTITY_PREFIX = re.compile(
r"^\s*(?:\d+(?:[./]\d+)?\s*)?" # optional leading number (1, 1/2, 2.5)
r"(?:to\s+\d+\s*)?" # optional "to N" range
r"(?:c\.|cup|cups|tbsp|tsp|oz|lb|lbs|g|kg|ml|l|"
r"can|cans|pkg|pkg\.|package|slice|slices|clove|cloves|"
r"small|medium|large|bunch|head|piece|pieces|"
r"pinch|dash|handful|sprig|sprigs)\s*\b",
re.IGNORECASE,
)
# Preparation-state words that modify an ingredient without changing what it is.
# Stripped from both ends so "melted butter", "butter, melted" both → "butter".
_PREP_STATES = re.compile(
r"\b(melted|softened|cold|warm|hot|room.temperature|"
r"diced|sliced|chopped|minced|grated|shredded|shredded|beaten|whipped|"
r"cooked|raw|frozen|canned|dried|dehydrated|marinated|seasoned|"
r"roasted|toasted|ground|crushed|pressed|peeled|seeded|pitted|"
r"boneless|skinless|trimmed|halved|quartered|julienned|"
r"thinly|finely|roughly|coarsely|freshly|lightly|"
r"packed|heaping|level|sifted|divided|optional)\b",
re.IGNORECASE,
)
# Trailing comma + optional prep state (e.g. "butter, melted")
_TRAILING_PREP = re.compile(r",\s*\w+$")
# Maps prep-state words to human-readable instruction templates.
# {ingredient} is replaced with the actual ingredient name.
# None means the state is passive (frozen, canned) — no note needed.
_PREP_INSTRUCTIONS: dict[str, str | None] = {
"melted": "Melt the {ingredient} before starting.",
"softened": "Let the {ingredient} soften to room temperature before using.",
"room temperature": "Bring the {ingredient} to room temperature before using.",
"beaten": "Beat the {ingredient} lightly before adding.",
"whipped": "Whip the {ingredient} until soft peaks form.",
"sifted": "Sift the {ingredient} before measuring.",
"toasted": "Toast the {ingredient} in a dry pan until fragrant.",
"roasted": "Roast the {ingredient} before using.",
"pressed": "Press the {ingredient} to remove excess moisture.",
"diced": "Dice the {ingredient} into small pieces.",
"sliced": "Slice the {ingredient} thinly.",
"chopped": "Chop the {ingredient} roughly.",
"minced": "Mince the {ingredient} finely.",
"grated": "Grate the {ingredient}.",
"shredded": "Shred the {ingredient}.",
"ground": "Grind the {ingredient}.",
"crushed": "Crush the {ingredient}.",
"peeled": "Peel the {ingredient} before use.",
"seeded": "Remove seeds from the {ingredient}.",
"pitted": "Pit the {ingredient} before use.",
"trimmed": "Trim any excess from the {ingredient}.",
"julienned": "Cut the {ingredient} into thin matchstick strips.",
"cooked": "Pre-cook the {ingredient} before adding.",
# Passive states — ingredient is used as-is, no prep note needed
"cold": None,
"warm": None,
"hot": None,
"raw": None,
"frozen": None,
"canned": None,
"dried": None,
"dehydrated": None,
"marinated": None,
"seasoned": None,
"boneless": None,
"skinless": None,
"divided": None,
"optional": None,
"fresh": None,
"freshly": None,
"thinly": None,
"finely": None,
"roughly": None,
"coarsely": None,
"lightly": None,
"packed": None,
"heaping": None,
"level": None,
}
# Finds the first actionable prep state in an ingredient string
_PREP_STATE_SEARCH = re.compile(
r"\b(" + "|".join(re.escape(k) for k in _PREP_INSTRUCTIONS) + r")\b",
re.IGNORECASE,
)
def _strip_quantity(ingredient: str) -> str:
"""Remove leading quantity/unit and preparation-state words from a recipe ingredient.
e.g. "2 tbsp melted butter" "butter"
"butter, melted" "butter"
"1/4 cup flour, sifted" "flour"
"""
stripped = _QUANTITY_PREFIX.sub("", ingredient).strip()
# Strip any remaining leading number (e.g. "3 eggs" → "eggs")
stripped = re.sub(r"^\d+\s+", "", stripped)
# Strip trailing ", prep_state"
stripped = _TRAILING_PREP.sub("", stripped).strip()
# Strip prep-state words (may be leading or embedded)
stripped = _PREP_STATES.sub("", stripped).strip()
# Clean up any double spaces left behind
stripped = re.sub(r"\s{2,}", " ", stripped).strip()
return stripped or ingredient
def _prep_note_for(ingredient: str) -> str | None:
"""Return a human-readable prep instruction for this ingredient string, or None.
e.g. "2 tbsp melted butter" "Melt the butter before starting."
"onion, diced" "Dice the onion into small pieces."
"frozen peas" None (passive state, no action needed)
"""
match = _PREP_STATE_SEARCH.search(ingredient)
if not match:
return None
state = match.group(1).lower()
template = _PREP_INSTRUCTIONS.get(state)
if not template:
return None
# Use the stripped ingredient name as the subject
ingredient_name = _strip_quantity(ingredient)
return template.format(ingredient=ingredient_name)
def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
and adds the canonical form. This lets single-word recipe ingredients
("hamburger", "chicken") match product-label pantry entries
("burger patties", "rotisserie chicken").
"""
expanded: set[str] = set()
for item in pantry_items:
lower = item.lower().strip()
expanded.add(lower)
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
if pattern in lower:
expanded.add(canonical)
return expanded
def _ingredient_in_pantry(ingredient: str, pantry_set: set[str]) -> bool:
"""Return True if the recipe ingredient is satisfied by the pantry.
Checks three layers in order:
1. Exact match after quantity stripping
2. Synonym lookup: ingredient canonical in pantry_set
(handles "ground beef" matched by "burger patties" via shared canonical)
3. Token subset: all content tokens of the ingredient appear in pantry
(handles "diced onions" when "onions" is in pantry)
"""
clean = _strip_quantity(ingredient).lower()
if clean in pantry_set:
return True
# Check if this recipe ingredient maps to a canonical that's in pantry
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
if pattern in clean and canonical in pantry_set:
return True
# Single-token ingredient whose token appears in pantry (e.g. "ketchup" in "c. ketchup")
tokens = [t for t in clean.split() if t not in _SWAP_STOPWORDS and len(t) > 2]
if tokens and all(t in pantry_set for t in tokens):
return True
return False
def _content_tokens(text: str) -> frozenset[str]:
return frozenset(
w for w in text.lower().split()
if w not in _SWAP_STOPWORDS and len(w) > 1
)
def _pantry_creative_swap(required: str, pantry_items: set[str]) -> str | None:
"""Return a pantry item that's a plausible creative substitute, or None.
Requires 2 shared content tokens AND 50% bidirectional overlap so that
single-word differences (cream-of-mushroom vs cream-of-potato) qualify while
single-word ingredients (butter, flour) don't accidentally match supersets
(peanut butter, bread flour).
"""
req_tokens = _content_tokens(required)
if len(req_tokens) < 2:
return None # single-word ingredients must already be in pantry_set
best: str | None = None
best_score = 0.0
for item in pantry_items:
if item.lower() == required.lower():
continue
pan_tokens = _content_tokens(item)
if not pan_tokens:
continue
overlap = len(req_tokens & pan_tokens)
if overlap < 2:
continue
score = min(overlap / len(req_tokens), overlap / len(pan_tokens))
if score >= 0.5 and score > best_score:
best_score = score
best = item
return best
# Method complexity classification patterns # Method complexity classification patterns
_EASY_METHODS = re.compile( _EASY_METHODS = re.compile(
r"\b(microwave|mix|stir|blend|toast|assemble|heat)\b", re.IGNORECASE r"\b(microwave|mix|stir|blend|toast|assemble|heat)\b", re.IGNORECASE
@ -95,7 +435,7 @@ class RecipeEngine:
profiles = self._classifier.classify_batch(req.pantry_items) profiles = self._classifier.classify_batch(req.pantry_items)
gaps = self._classifier.identify_gaps(profiles) gaps = self._classifier.identify_gaps(profiles)
pantry_set = {item.lower().strip() for item in req.pantry_items} pantry_set = _expand_pantry_set(req.pantry_items)
if req.level >= 3: if req.level >= 3:
from app.services.recipe.llm_recipe import LLMRecipeGenerator from app.services.recipe.llm_recipe import LLMRecipeGenerator
@ -103,7 +443,17 @@ class RecipeEngine:
return gen.generate(req, profiles, gaps) return gen.generate(req, profiles, gaps)
# Level 1 & 2: deterministic path # Level 1 & 2: deterministic path
rows = self._store.search_recipes_by_ingredients(req.pantry_items, limit=20) nf = req.nutrition_filters
rows = self._store.search_recipes_by_ingredients(
req.pantry_items,
limit=20,
category=req.category or None,
max_calories=nf.max_calories,
max_sugar_g=nf.max_sugar_g,
max_carbs_g=nf.max_carbs_g,
max_sodium_mg=nf.max_sodium_mg,
excluded_ids=req.excluded_ids or [],
)
suggestions = [] suggestions = []
for row in rows: for row in rows:
@ -114,10 +464,31 @@ class RecipeEngine:
except Exception: except Exception:
ingredient_names = [] ingredient_names = []
# Compute missing ingredients # Compute missing ingredients, detecting pantry coverage first.
missing = [n for n in ingredient_names if n.lower() not in pantry_set] # When covered, collect any prep-state annotations (e.g. "melted butter"
# → note "Melt the butter before starting.") to surface separately.
swap_candidates: list[SwapCandidate] = []
missing: list[str] = []
prep_note_set: set[str] = set()
for n in ingredient_names:
if _ingredient_in_pantry(n, pantry_set):
note = _prep_note_for(n)
if note:
prep_note_set.add(note)
continue
swap_item = _pantry_creative_swap(n, pantry_set)
if swap_item:
swap_candidates.append(SwapCandidate(
original_name=n,
substitute_name=swap_item,
constraint_label="pantry_swap",
explanation=f"You have {swap_item} — use it in place of {n}.",
compensation_hints=[],
))
else:
missing.append(n)
# Filter by max_missing # Filter by max_missing (pantry swaps don't count as missing)
if req.max_missing is not None and len(missing) > req.max_missing: if req.max_missing is not None and len(missing) > req.max_missing:
continue continue
@ -133,8 +504,7 @@ class RecipeEngine:
if complexity == "involved": if complexity == "involved":
continue continue
# Build swap candidates for Level 2 # Level 2: also add dietary constraint swaps from substitution_pairs
swap_candidates: list[SwapCandidate] = []
if req.level == 2 and req.constraints: if req.level == 2 and req.constraints:
for ing in ingredient_names: for ing in ingredient_names:
for constraint in req.constraints: for constraint in req.constraints:
@ -155,6 +525,22 @@ class RecipeEngine:
except Exception: except Exception:
coverage_raw = {} coverage_raw = {}
servings = row.get("servings") or None
nutrition = NutritionPanel(
calories=row.get("calories"),
fat_g=row.get("fat_g"),
protein_g=row.get("protein_g"),
carbs_g=row.get("carbs_g"),
fiber_g=row.get("fiber_g"),
sugar_g=row.get("sugar_g"),
sodium_mg=row.get("sodium_mg"),
servings=servings,
estimated=bool(row.get("nutrition_estimated", 0)),
)
has_nutrition = any(
v is not None
for v in (nutrition.calories, nutrition.sugar_g, nutrition.carbs_g)
)
suggestions.append(RecipeSuggestion( suggestions.append(RecipeSuggestion(
id=row["id"], id=row["id"],
title=row["title"], title=row["title"],
@ -162,9 +548,20 @@ class RecipeEngine:
element_coverage=coverage_raw, element_coverage=coverage_raw,
swap_candidates=swap_candidates, swap_candidates=swap_candidates,
missing_ingredients=missing, missing_ingredients=missing,
prep_notes=sorted(prep_note_set),
level=req.level, level=req.level,
nutrition=nutrition if has_nutrition else None,
)) ))
# Prepend assembly-dish templates (burrito, stir fry, omelette, etc.)
# These fire regardless of corpus coverage — any pantry can make a burrito.
assembly = match_assembly_templates(
pantry_items=req.pantry_items,
pantry_set=pantry_set,
excluded_ids=req.excluded_ids or [],
)
suggestions = assembly + suggestions
# Build grocery list — deduplicated union of all missing ingredients # Build grocery list — deduplicated union of all missing ingredients
seen: set[str] = set() seen: set[str] = set()
grocery_list: list[str] = [] grocery_list: list[str] = []

View file

@ -43,7 +43,13 @@ KIWI_FEATURES: dict[str, str] = {
def can_use(feature: str, tier: str, has_byok: bool = False) -> bool: def can_use(feature: str, tier: str, has_byok: bool = False) -> bool:
"""Return True if the given tier can access the feature.""" """Return True if the given tier can access the feature.
The 'local' tier is assigned to dev-bypass and non-cloud sessions
it has unrestricted access to all features.
"""
if tier == "local":
return True
return _can_use( return _can_use(
feature, feature,
tier, tier,

View file

@ -4,7 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>Kiwi — Pantry Tracker</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -844,7 +844,6 @@
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -1557,7 +1556,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -1721,7 +1719,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -1743,7 +1740,6 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -1825,7 +1821,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.22", "@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22", "@vue/compiler-sfc": "3.5.22",

View file

@ -1,66 +1,149 @@
<template> <template>
<div id="app"> <div id="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<header class="app-header">
<div class="container"> <!-- Desktop sidebar (hidden on mobile) -->
<h1>🥝 Kiwi</h1> <aside class="sidebar" role="navigation" aria-label="Main navigation">
<p class="tagline">Smart Pantry Tracking & Recipe Suggestions</p> <!-- Wordmark + collapse toggle -->
<div class="sidebar-header">
<span class="wordmark-kiwi" @click="onWordmarkClick" style="cursor:pointer">Kiwi</span>
<button class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed" :aria-label="sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div> </div>
</header>
<main class="app-main"> <nav class="sidebar-nav">
<div class="container"> <button :class="['sidebar-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')">
<!-- Tabs --> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<div class="tabs"> <rect x="3" y="4" width="18" height="4" rx="1"/>
<button <rect x="3" y="11" width="18" height="4" rx="1"/>
:class="['tab', { active: currentTab === 'inventory' }]" <rect x="3" y="18" width="18" height="3" rx="1"/>
@click="switchTab('inventory')" </svg>
> <span class="sidebar-label">Pantry</span>
🏪 Inventory </button>
</button>
<button <button :class="['sidebar-item', { active: currentTab === 'receipts' }]" @click="switchTab('receipts')">
:class="['tab', { active: currentTab === 'receipts' }]" <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
@click="switchTab('receipts')" <path d="M4 4v16l2-1.5 2 1.5 2-1.5 2 1.5 2-1.5 2 1.5 2-1.5V4"/>
> <line x1="8" y1="9" x2="16" y2="9"/>
🧾 Receipts <line x1="8" y1="13" x2="14" y2="13"/>
</button> </svg>
<button <span class="sidebar-label">Receipts</span>
:class="['tab', { active: currentTab === 'recipes' }]" </button>
@click="switchTab('recipes')"
> <button :class="['sidebar-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')">
🍳 Recipes <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
</button> <path d="M12 2C9 2 7 5 7 8c0 2.5 1 4.5 3 5.5V20h4v-6.5c2-1 3-3 3-5.5 0-3-2-6-5-6z"/>
<button <line x1="9" y1="12" x2="15" y2="12"/>
:class="['tab', { active: currentTab === 'settings' }]" </svg>
@click="switchTab('settings')" <span class="sidebar-label">Recipes</span>
> </button>
Settings
</button> <button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
</svg>
<span class="sidebar-label">Settings</span>
</button>
</nav>
</aside>
<!-- Main area: header + content -->
<div class="app-body">
<!-- Mobile-only header -->
<header class="app-header">
<div class="header-inner">
<span class="wordmark-kiwi">Kiwi</span>
</div> </div>
</header>
<!-- Tab Content --> <main class="app-main">
<div v-show="currentTab === 'inventory'" class="tab-content"> <div class="container">
<InventoryList /> <div v-show="currentTab === 'inventory'" class="tab-content fade-in">
<InventoryList />
</div>
<div v-show="currentTab === 'receipts'" class="tab-content fade-in">
<ReceiptsView />
</div>
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
<RecipesView />
</div>
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
<SettingsView />
</div>
</div> </div>
</main>
</div>
<div v-show="currentTab === 'receipts'" class="tab-content"> <!-- Mobile bottom nav only -->
<ReceiptsView /> <nav class="bottom-nav" role="navigation" aria-label="Main navigation">
</div> <button :class="['nav-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')" aria-label="Pantry">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="4" rx="1"/>
<rect x="3" y="11" width="18" height="4" rx="1"/>
<rect x="3" y="18" width="18" height="3" rx="1"/>
</svg>
<span class="nav-label">Pantry</span>
</button>
<button :class="['nav-item', { active: currentTab === 'receipts' }]" @click="switchTab('receipts')" aria-label="Receipts">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4v16l2-1.5 2 1.5 2-1.5 2 1.5 2-1.5 2 1.5 2-1.5V4"/>
<line x1="8" y1="9" x2="16" y2="9"/>
<line x1="8" y1="13" x2="14" y2="13"/>
</svg>
<span class="nav-label">Receipts</span>
</button>
<button :class="['nav-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')" aria-label="Recipes">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2C9 2 7 5 7 8c0 2.5 1 4.5 3 5.5V20h4v-6.5c2-1 3-3 3-5.5 0-3-2-6-5-6z"/>
<line x1="9" y1="12" x2="15" y2="12"/>
</svg>
<span class="nav-label">Recipes</span>
</button>
<button :class="['nav-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')" aria-label="Settings">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
</svg>
<span class="nav-label">Settings</span>
</button>
</nav>
<div v-show="currentTab === 'recipes'" class="tab-content"> <!-- Easter egg: Kiwi bird sprite triggered by typing "kiwi" -->
<RecipesView /> <Transition name="kiwi-fade">
</div> <div v-if="kiwiVisible" class="kiwi-bird-stage" aria-hidden="true">
<div class="kiwi-bird" :class="kiwiDirection">
<div v-show="currentTab === 'settings'" class="tab-content"> <!-- Kiwi bird SVG side profile, facing left by default (rtl walk) -->
<SettingsView /> <svg class="kiwi-svg" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Body plump oval -->
<ellipse cx="30" cy="38" rx="18" ry="15" fill="#8B6914" />
<!-- Head -->
<ellipse cx="46" cy="26" rx="10" ry="9" fill="#6B4F10" />
<!-- Long beak -->
<path d="M54 25 Q66 24 70 25 Q66 27 54 27Z" fill="#C8A96E" />
<!-- Eye -->
<circle cx="49" cy="23" r="2" fill="#1a1a1a" />
<circle cx="49.7" cy="22.3" r="0.6" fill="white" />
<!-- Wing texture lines -->
<path d="M18 32 Q24 28 34 30" stroke="#6B4F10" stroke-width="1.2" stroke-linecap="round" />
<path d="M16 37 Q22 33 32 35" stroke="#6B4F10" stroke-width="1.2" stroke-linecap="round" />
<!-- Legs -->
<line x1="24" y1="52" x2="22" y2="60" stroke="#A07820" stroke-width="2.5" stroke-linecap="round" />
<line x1="34" y1="52" x2="36" y2="60" stroke="#A07820" stroke-width="2.5" stroke-linecap="round" />
<!-- Feet -->
<path d="M18 60 L22 60 L24 57" stroke="#A07820" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path d="M32 60 L36 60 L38 57" stroke="#A07820" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<!-- Feather texture -->
<path d="M22 38 Q28 34 36 36" stroke="#A07820" stroke-width="0.8" stroke-linecap="round" />
<path d="M20 43 Q26 39 34 41" stroke="#A07820" stroke-width="0.8" stroke-linecap="round" />
</svg>
</div> </div>
</div> </div>
</main> </Transition>
<footer class="app-footer">
<div class="container">
<p>&copy; 2026 CircuitForge LLC</p>
</div>
</footer>
</div> </div>
</template> </template>
@ -71,11 +154,29 @@ import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue' import RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue' import SettingsView from './components/SettingsView.vue'
import { useInventoryStore } from './stores/inventory' import { useInventoryStore } from './stores/inventory'
import { useEasterEggs } from './composables/useEasterEggs'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
const currentTab = ref<Tab>('inventory') const currentTab = ref<Tab>('inventory')
const sidebarCollapsed = ref(false)
const inventoryStore = useInventoryStore() const inventoryStore = useInventoryStore()
const { kiwiVisible, kiwiDirection } = useEasterEggs()
// Wordmark click counter for chef mode easter egg
const wordmarkClicks = ref(0)
let wordmarkTimer: ReturnType<typeof setTimeout> | null = null
function onWordmarkClick() {
wordmarkClicks.value++
if (wordmarkTimer) clearTimeout(wordmarkTimer)
if (wordmarkClicks.value >= 5) {
wordmarkClicks.value = 0
document.querySelector('.wordmark-kiwi')?.classList.add('chef-spin')
setTimeout(() => document.querySelector('.wordmark-kiwi')?.classList.remove('chef-spin'), 800)
} else {
wordmarkTimer = setTimeout(() => { wordmarkClicks.value = 0 }, 1200)
}
}
async function switchTab(tab: Tab) { async function switchTab(tab: Tab) {
currentTab.value = tab currentTab.value = tab
@ -93,136 +194,322 @@ async function switchTab(tab: Tab) {
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: var(--font-body);
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background: var(--color-bg-primary); background: var(--color-bg-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.wordmark-kiwi {
font-family: var(--font-display);
font-style: italic;
font-weight: 700;
color: var(--color-primary);
letter-spacing: -0.01em;
line-height: 1;
white-space: nowrap;
overflow: hidden;
}
/* ============================================
MOBILE LAYOUT (< 769px)
sidebar hidden, bottom nav visible
============================================ */
#app { #app {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: 68px; /* bottom nav clearance */
} }
.container { .sidebar { display: none; }
max-width: 1400px; .app-body { display: contents; }
margin: 0 auto;
padding: 0 20px;
}
.app-header { .app-header {
background: var(--gradient-primary); background: var(--gradient-header);
color: white; border-bottom: 1px solid var(--color-border);
padding: var(--spacing-xl) 0; padding: var(--spacing-sm) var(--spacing-md);
box-shadow: var(--shadow-md); position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
} }
.app-header h1 { .header-inner {
font-size: 32px; display: flex;
margin-bottom: 5px; align-items: center;
min-height: 44px;
} }
.app-header .tagline { .header-inner .wordmark-kiwi { font-size: 24px; }
font-size: 16px;
opacity: 0.9;
}
.app-main { .app-main {
flex: 1; flex: 1;
padding: 20px 0; padding: var(--spacing-md) 0 var(--spacing-xl);
} }
.app-footer { .container {
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.tab-content { min-height: 0; }
/* ---- Bottom nav ---- */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 200;
background: var(--color-bg-elevated); background: var(--color-bg-elevated);
color: var(--color-text-secondary);
padding: var(--spacing-lg) 0;
text-align: center;
margin-top: var(--spacing-xl);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
}
.app-footer p {
font-size: var(--font-size-sm);
opacity: 0.8;
}
/* Tabs */
.tabs {
display: flex; display: flex;
gap: 10px; align-items: stretch;
margin-bottom: 20px; padding-bottom: env(safe-area-inset-bottom, 0);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25);
} }
.tab { .nav-item {
background: rgba(255, 255, 255, 0.2); flex: 1;
color: white; display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 8px 4px 10px;
border: none; border: none;
padding: 15px 30px; background: transparent;
font-size: 16px; color: var(--color-text-muted);
border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: color 0.18s ease, background 0.18s ease;
border-radius: 0;
position: relative;
} }
.tab:hover { .nav-item::before {
background: rgba(255, 255, 255, 0.3); content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 2px;
background: var(--color-primary);
border-radius: 0 0 2px 2px;
transform: scaleX(0);
transition: transform 0.18s ease;
} }
.tab.active { .nav-item:hover {
background: var(--color-bg-card); color: var(--color-text-secondary);
color: var(--color-primary); background: rgba(232, 168, 32, 0.06);
transform: none;
border-color: transparent;
}
.nav-item.active { color: var(--color-primary); }
.nav-item.active::before { transform: scaleX(1); }
.nav-icon { width: 22px; height: 22px; flex-shrink: 0; }
.nav-label {
font-family: var(--font-body);
font-size: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1;
} }
.tab-content {
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Mobile Responsive Breakpoints */
@media (max-width: 480px) { @media (max-width: 480px) {
.container { .container { padding: 0 var(--spacing-sm); }
padding: 0 12px; .app-main { padding: var(--spacing-sm) 0 var(--spacing-lg); }
}
/* ============================================
DESKTOP LAYOUT ( 769px)
sidebar visible, bottom nav hidden
============================================ */
@media (min-width: 769px) {
.bottom-nav { display: none; }
#app {
flex-direction: row;
padding-bottom: 0;
min-height: 100vh;
} }
.app-header h1 { /* ---- Sidebar ---- */
font-size: 24px; .sidebar {
display: flex;
flex-direction: column;
width: 200px;
min-height: 100vh;
background: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
position: sticky;
top: 0;
flex-shrink: 0;
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
z-index: 100;
} }
.app-header .tagline { .sidebar-collapsed .sidebar {
font-size: 14px; width: 56px;
} }
.tabs { .sidebar-header {
gap: 8px; display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
min-height: 56px;
gap: var(--spacing-sm);
} }
.tab { .sidebar-header .wordmark-kiwi {
padding: 12px 20px; font-size: 22px;
font-size: 14px; opacity: 1;
transition: opacity 0.15s ease, width 0.22s ease;
flex-shrink: 0;
}
.sidebar-collapsed .sidebar-header .wordmark-kiwi {
opacity: 0;
width: 0;
pointer-events: none;
}
.sidebar-toggle {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 6px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: color 0.15s, background 0.15s;
}
.sidebar-toggle:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
transform: none;
border-color: transparent;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-sm);
}
.sidebar-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: 10px var(--spacing-sm);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s, background 0.15s;
white-space: nowrap;
width: 100%;
text-align: left;
}
.sidebar-item:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
transform: none;
border-color: transparent;
}
.sidebar-item.active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.sidebar-item .nav-icon { width: 20px; height: 20px; flex-shrink: 0; }
.sidebar-label {
font-size: var(--font-size-sm);
font-weight: 600;
opacity: 1;
transition: opacity 0.12s ease;
overflow: hidden;
}
.sidebar-collapsed .sidebar-label {
opacity: 0;
width: 0;
pointer-events: none;
}
/* ---- Main body ---- */
.app-body {
display: flex;
flex-direction: column;
flex: 1; flex: 1;
min-width: 0; /* prevent overflow */
contents: unset;
}
.app-header { display: none; } /* wordmark lives in sidebar on desktop */
/* Override style.css #app max-width so sidebar spans full viewport */
#app {
max-width: none;
margin: 0;
}
.app-main {
flex: 1;
padding: var(--spacing-xl) 0;
}
.container {
max-width: 860px;
padding: 0 var(--spacing-lg);
} }
} }
@media (min-width: 481px) and (max-width: 768px) { @media (min-width: 1200px) {
.container { .container {
padding: 0 16px; max-width: 960px;
padding: 0 var(--spacing-xl);
} }
}
.app-header h1 { /* Easter egg: wordmark spin on 5× click */
font-size: 28px; @keyframes chefSpin {
} 0% { transform: rotate(0deg) scale(1); }
30% { transform: rotate(180deg) scale(1.3); }
60% { transform: rotate(340deg) scale(1.1); }
100% { transform: rotate(360deg) scale(1); }
}
.tab { .wordmark-kiwi.chef-spin {
padding: 14px 25px; display: inline-block;
} animation: chefSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* Kiwi bird transition */
.kiwi-fade-enter-active,
.kiwi-fade-leave-active {
transition: opacity 0.4s ease;
}
.kiwi-fade-enter-from,
.kiwi-fade-leave-to {
opacity: 0;
} }
</style> </style>

View file

@ -281,15 +281,25 @@
<p class="text-muted text-sm" style="margin-top: var(--spacing-md)">Loading pantry</p> <p class="text-muted text-sm" style="margin-top: var(--spacing-md)">Loading pantry</p>
</div> </div>
<!-- Empty State --> <!-- Empty State: clean slate (no items at all) -->
<div v-else-if="!loading && filteredItems.length === 0" class="empty-state"> <div v-else-if="!loading && filteredItems.length === 0 && store.items.length === 0" class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon"> <svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<rect x="6" y="10" width="36" height="6" rx="2"/> <rect x="6" y="10" width="36" height="6" rx="2"/>
<rect x="6" y="21" width="36" height="6" rx="2"/> <rect x="6" y="21" width="36" height="6" rx="2"/>
<rect x="6" y="32" width="36" height="6" rx="2"/> <rect x="6" y="32" width="36" height="6" rx="2"/>
</svg> </svg>
<p class="text-secondary">No items found.</p> <p class="text-secondary">Clean slate.</p>
<p class="text-muted text-sm">Scan a barcode or add manually above.</p> <p class="text-muted text-sm">Your pantry is ready for anything scan a barcode or add an item above.</p>
</div>
<!-- Empty State: filter has no matches -->
<div v-else-if="!loading && filteredItems.length === 0" class="empty-state">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
<circle cx="20" cy="20" r="12"/>
<line x1="29" y1="29" x2="42" y2="42"/>
</svg>
<p class="text-secondary">Nothing matches that filter.</p>
<p class="text-muted text-sm">Try a different location or status.</p>
</div> </div>
<!-- Inventory shelf list --> <!-- Inventory shelf list -->

View file

@ -154,17 +154,50 @@
</p> </p>
</details> </details>
<!-- Cuisine Style (Level 3+ only) -->
<div v-if="recipesStore.level >= 3" class="form-group">
<label class="form-label">Cuisine Style <span class="text-muted text-xs">(Level 3+)</span></label>
<div class="flex flex-wrap gap-xs">
<button
v-for="style in cuisineStyles"
:key="style.id"
:class="['btn', 'btn-secondary', 'btn-sm', { active: recipesStore.styleId === style.id }]"
@click="recipesStore.styleId = recipesStore.styleId === style.id ? null : style.id"
>{{ style.label }}</button>
</div>
</div>
<!-- Category Filter (Level 12 only) -->
<div v-if="recipesStore.level <= 2" class="form-group">
<label class="form-label">Category <span class="text-muted text-xs">(optional)</span></label>
<input
class="form-input"
v-model="categoryInput"
placeholder="e.g. Breakfast, Asian, Chicken, &lt; 30 Mins"
@blur="recipesStore.category = categoryInput.trim() || null"
@keydown.enter="recipesStore.category = categoryInput.trim() || null"
/>
</div>
<!-- Suggest Button --> <!-- Suggest Button -->
<button <div class="suggest-row">
class="btn btn-primary btn-lg w-full" <button
:disabled="recipesStore.loading || pantryItems.length === 0 || (recipesStore.level === 4 && !recipesStore.wildcardConfirmed)" class="btn btn-primary btn-lg flex-1"
@click="handleSuggest" :disabled="recipesStore.loading || pantryItems.length === 0 || (recipesStore.level === 4 && !recipesStore.wildcardConfirmed)"
> @click="handleSuggest"
<span v-if="recipesStore.loading"> >
<span class="spinner spinner-sm inline-spinner"></span> Finding recipes <span v-if="recipesStore.loading && !isLoadingMore">
</span> <span class="spinner spinner-sm inline-spinner"></span> Finding recipes
<span v-else>Suggest Recipes</span> </span>
</button> <span v-else>Suggest Recipes</span>
</button>
<button
v-if="recipesStore.dismissedCount > 0"
class="btn btn-ghost btn-sm"
@click="recipesStore.clearDismissed()"
title="Show all dismissed recipes again"
>Clear dismissed ({{ recipesStore.dismissedCount }})</button>
</div>
<!-- Empty pantry nudge --> <!-- Empty pantry nudge -->
<p v-if="pantryItems.length === 0 && !recipesStore.loading" class="text-sm text-muted text-center mt-xs"> <p v-if="pantryItems.length === 0 && !recipesStore.loading" class="text-sm text-muted text-center mt-xs">
@ -218,10 +251,17 @@
<!-- Header row --> <!-- Header row -->
<div class="flex-between mb-sm"> <div class="flex-between mb-sm">
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3> <h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
<div class="flex flex-wrap gap-xs"> <div class="flex flex-wrap gap-xs" style="align-items:center">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span> <span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span> <span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard</span> <span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard</span>
<button
v-if="recipe.id"
class="btn-dismiss"
@click="recipesStore.dismiss(recipe.id)"
title="Hide this recipe"
aria-label="Dismiss recipe"
></button>
</div> </div>
</div> </div>
@ -308,6 +348,16 @@
</div> </div>
</details> </details>
<!-- Prep notes -->
<div v-if="recipe.prep_notes && recipe.prep_notes.length > 0" class="prep-notes mb-sm">
<p class="text-sm font-semibold">Before you start:</p>
<ul class="prep-notes-list mt-xs">
<li v-for="note in recipe.prep_notes" :key="note" class="text-sm prep-note-item">
{{ note }}
</li>
</ul>
</div>
<!-- Directions collapsible --> <!-- Directions collapsible -->
<details v-if="recipe.directions.length > 0" class="collapsible"> <details v-if="recipe.directions.length > 0" class="collapsible">
<summary class="text-sm font-semibold collapsible-summary"> <summary class="text-sm font-semibold collapsible-summary">
@ -322,6 +372,20 @@
</div> </div>
</div> </div>
<!-- Load More -->
<div v-if="recipesStore.result.suggestions.length > 0" class="load-more-row">
<button
class="btn btn-secondary"
:disabled="recipesStore.loading"
@click="handleLoadMore"
>
<span v-if="recipesStore.loading && isLoadingMore">
<span class="spinner spinner-sm inline-spinner"></span> Loading
</span>
<span v-else>Load more recipes</span>
</button>
</div>
<!-- Grocery list summary --> <!-- Grocery list summary -->
<div v-if="recipesStore.result.grocery_list.length > 0" class="card card-info"> <div v-if="recipesStore.result.grocery_list.length > 0" class="card card-info">
<h3 class="text-lg font-bold mb-sm">Shopping List</h3> <h3 class="text-lg font-bold mb-sm">Shopping List</h3>
@ -364,6 +428,8 @@ const inventoryStore = useInventoryStore()
// Local input state for tags // Local input state for tags
const constraintInput = ref('') const constraintInput = ref('')
const allergyInput = ref('') const allergyInput = ref('')
const categoryInput = ref('')
const isLoadingMore = ref(false)
const levels = [ const levels = [
{ value: 1, label: '1 — From Pantry' }, { value: 1, label: '1 — From Pantry' },
@ -372,6 +438,14 @@ const levels = [
{ value: 4, label: '4 — Wildcard 🎲' }, { value: 4, label: '4 — Wildcard 🎲' },
] ]
const cuisineStyles = [
{ id: 'italian', label: 'Italian' },
{ id: 'mediterranean', label: 'Mediterranean' },
{ id: 'east_asian', label: 'East Asian' },
{ id: 'latin', label: 'Latin' },
{ id: 'eastern_european', label: 'Eastern European' },
]
// Pantry items sorted expiry-first (available items only) // Pantry items sorted expiry-first (available items only)
const pantryItems = computed(() => { const pantryItems = computed(() => {
const sorted = [...inventoryStore.items] const sorted = [...inventoryStore.items]
@ -462,9 +536,16 @@ function onNutritionInput(key: NutritionKey, e: Event) {
// Suggest handler // Suggest handler
async function handleSuggest() { async function handleSuggest() {
isLoadingMore.value = false
await recipesStore.suggest(pantryItems.value) await recipesStore.suggest(pantryItems.value)
} }
async function handleLoadMore() {
isLoadingMore.value = true
await recipesStore.loadMore(pantryItems.value)
isLoadingMore.value = false
}
onMounted(async () => { onMounted(async () => {
if (inventoryStore.items.length === 0) { if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems() await inventoryStore.fetchItems()
@ -543,6 +624,58 @@ onMounted(async () => {
margin-right: var(--spacing-sm); margin-right: var(--spacing-sm);
} }
.btn-dismiss {
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
font-size: 12px;
line-height: 1;
color: var(--color-text-muted);
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.btn-dismiss:hover {
color: var(--color-error, #dc2626);
background: var(--color-error-bg, #fee2e2);
transform: none;
}
.suggest-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.btn-ghost {
background: transparent;
border: none;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
cursor: pointer;
padding: var(--spacing-xs) var(--spacing-sm);
white-space: nowrap;
}
.btn-ghost:hover {
color: var(--color-primary);
background: transparent;
transform: none;
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.load-more-row {
display: flex;
justify-content: center;
margin-bottom: var(--spacing-md);
}
.collapsible { .collapsible {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm); padding-top: var(--spacing-sm);
@ -577,6 +710,17 @@ details[open] .collapsible-summary::before {
border-bottom: none; border-bottom: none;
} }
.prep-notes-list {
padding-left: var(--spacing-lg);
list-style-type: disc;
}
.prep-note-item {
margin-bottom: var(--spacing-xs);
line-height: 1.5;
color: var(--color-text-secondary);
}
.directions-list { .directions-list {
padding-left: var(--spacing-lg); padding-left: var(--spacing-lg);
} }

View file

@ -0,0 +1,132 @@
import { ref, onMounted, onUnmounted } from 'vue'
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
const KIWI_WORD = ['k','i','w','i']
// Module-level shared state — single instance across all component uses
const neonMode = ref(false)
const kiwiVisible = ref(false)
const kiwiDirection = ref<'ltr' | 'rtl'>('rtl') // bird enters from right by default
const NEON_VARS: Record<string, string> = {
'--color-bg-primary': '#070011',
'--color-bg-secondary': '#0f001f',
'--color-bg-elevated': '#160028',
'--color-bg-card': '#160028',
'--color-bg-input': '#0f001f',
'--color-primary': '#ff006e',
'--color-text-primary': '#f0e6ff',
'--color-text-secondary': '#c090ff',
'--color-text-muted': '#7040a0',
'--color-border': 'rgba(255, 0, 110, 0.22)',
'--color-border-focus': '#ff006e',
'--color-info': '#00f5ff',
'--color-info-bg': 'rgba(0, 245, 255, 0.10)',
'--color-info-border': 'rgba(0, 245, 255, 0.30)',
'--color-info-light': '#00f5ff',
'--color-success': '#39ff14',
'--color-success-bg': 'rgba(57, 255, 20, 0.10)',
'--color-success-border': 'rgba(57, 255, 20, 0.30)',
'--color-success-light': '#39ff14',
'--color-warning': '#ffbe0b',
'--color-warning-bg': 'rgba(255, 190, 11, 0.10)',
'--color-warning-border': 'rgba(255, 190, 11, 0.30)',
'--color-warning-light': '#ffbe0b',
'--shadow-amber': '0 0 18px rgba(255, 0, 110, 0.55)',
'--shadow-md': '0 2px 16px rgba(255, 0, 110, 0.18)',
'--shadow-lg': '0 4px 28px rgba(255, 0, 110, 0.25)',
'--gradient-primary': 'linear-gradient(135deg, #ff006e 0%, #8338ec 100%)',
'--gradient-header': 'linear-gradient(135deg, #070011 0%, #160028 100%)',
'--color-loc-fridge': '#00f5ff',
'--color-loc-freezer': '#8338ec',
'--color-loc-pantry': '#ff006e',
'--color-loc-cabinet': '#ffbe0b',
'--color-loc-garage-freezer': '#39ff14',
}
function applyNeon() {
const root = document.documentElement
for (const [prop, val] of Object.entries(NEON_VARS)) {
root.style.setProperty(prop, val)
}
document.body.classList.add('neon-mode')
}
function removeNeon() {
const root = document.documentElement
for (const prop of Object.keys(NEON_VARS)) {
root.style.removeProperty(prop)
}
document.body.classList.remove('neon-mode')
}
function toggleNeon() {
neonMode.value = !neonMode.value
if (neonMode.value) {
applyNeon()
localStorage.setItem('kiwi-neon-mode', '1')
} else {
removeNeon()
localStorage.removeItem('kiwi-neon-mode')
}
}
function spawnKiwi() {
kiwiDirection.value = Math.random() > 0.5 ? 'ltr' : 'rtl'
kiwiVisible.value = true
setTimeout(() => { kiwiVisible.value = false }, 5500)
}
export function useEasterEggs() {
const konamiBuffer: string[] = []
const kiwiBuffer: string[] = []
function onKeyDown(e: KeyboardEvent) {
// Skip when user is typing in a form input
const tag = (e.target as HTMLElement)?.tagName
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
// Konami code — works even in inputs
konamiBuffer.push(e.key)
if (konamiBuffer.length > KONAMI.length) konamiBuffer.shift()
if (konamiBuffer.join(',') === KONAMI.join(',')) {
toggleNeon()
konamiBuffer.length = 0
}
// KIWI word — only when not in a form input
if (!isInput) {
const key = e.key.toLowerCase()
if ('kiwi'.includes(key) && key.length === 1) {
kiwiBuffer.push(key)
if (kiwiBuffer.length > KIWI_WORD.length) kiwiBuffer.shift()
if (kiwiBuffer.join('') === 'kiwi') {
spawnKiwi()
kiwiBuffer.length = 0
}
} else {
kiwiBuffer.length = 0
}
}
}
onMounted(() => {
if (localStorage.getItem('kiwi-neon-mode')) {
neonMode.value = true
applyNeon()
}
window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
})
return {
neonMode,
kiwiVisible,
kiwiDirection,
toggleNeon,
spawnKiwi,
}
}

View file

@ -415,6 +415,18 @@ export interface SwapCandidate {
compensation_hints: Record<string, string>[] compensation_hints: Record<string, string>[]
} }
export interface NutritionPanel {
calories: number | null
fat_g: number | null
protein_g: number | null
carbs_g: number | null
fiber_g: number | null
sugar_g: number | null
sodium_mg: number | null
servings: number | null
estimated: boolean
}
export interface RecipeSuggestion { export interface RecipeSuggestion {
id: number id: number
title: string title: string
@ -423,9 +435,18 @@ export interface RecipeSuggestion {
swap_candidates: SwapCandidate[] swap_candidates: SwapCandidate[]
missing_ingredients: string[] missing_ingredients: string[]
directions: string[] directions: string[]
prep_notes: string[]
notes: string notes: string
level: number level: number
is_wildcard: boolean is_wildcard: boolean
nutrition: NutritionPanel | null
}
export interface NutritionFilters {
max_calories: number | null
max_sugar_g: number | null
max_carbs_g: number | null
max_sodium_mg: number | null
} }
export interface GroceryLink { export interface GroceryLink {
@ -452,7 +473,10 @@ export interface RecipeRequest {
hard_day_mode: boolean hard_day_mode: boolean
max_missing: number | null max_missing: number | null
style_id: string | null style_id: string | null
category: string | null
wildcard_confirmed: boolean wildcard_confirmed: boolean
nutrition_filters: NutritionFilters
excluded_ids: number[]
} }
export interface Staple { export interface Staple {

View file

@ -2,31 +2,69 @@
* Recipes Store * Recipes Store
* *
* Manages recipe suggestion state and request parameters using Pinia. * Manages recipe suggestion state and request parameters using Pinia.
* Dismissed recipe IDs are persisted to localStorage with a 7-day TTL.
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref, computed } from 'vue'
import { recipesAPI, type RecipeResult, type RecipeRequest } from '../services/api' import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type NutritionFilters } from '../services/api'
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
// [id, dismissedAtMs]
type DismissEntry = [number, number]
function loadDismissed(): Set<number> {
try {
const raw = localStorage.getItem(DISMISSED_KEY)
if (!raw) return new Set()
const entries: DismissEntry[] = JSON.parse(raw)
const cutoff = Date.now() - DISMISS_TTL_MS
return new Set(entries.filter(([, ts]) => ts > cutoff).map(([id]) => id))
} catch {
return new Set()
}
}
function saveDismissed(ids: Set<number>) {
const now = Date.now()
const entries: DismissEntry[] = [...ids].map((id) => [id, now])
localStorage.setItem(DISMISSED_KEY, JSON.stringify(entries))
}
export const useRecipesStore = defineStore('recipes', () => { export const useRecipesStore = defineStore('recipes', () => {
// State // Suggestion result state
const result = ref<RecipeResult | null>(null) const result = ref<RecipeResult | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
// Request parameters
const level = ref(1) const level = ref(1)
const constraints = ref<string[]>([]) const constraints = ref<string[]>([])
const allergies = ref<string[]>([]) const allergies = ref<string[]>([])
const hardDayMode = ref(false) const hardDayMode = ref(false)
const maxMissing = ref<number | null>(null) const maxMissing = ref<number | null>(null)
const styleId = ref<string | null>(null) const styleId = ref<string | null>(null)
const category = ref<string | null>(null)
const wildcardConfirmed = ref(false) const wildcardConfirmed = ref(false)
const nutritionFilters = ref<NutritionFilters>({
max_calories: null,
max_sugar_g: null,
max_carbs_g: null,
max_sodium_mg: null,
})
// Actions // Dismissed IDs: persisted to localStorage, 7-day TTL
async function suggest(pantryItems: string[]) { const dismissedIds = ref<Set<number>>(loadDismissed())
loading.value = true // Seen IDs: session-only, used by Load More to avoid repeating results
error.value = null const seenIds = ref<Set<number>>(new Set())
const req: RecipeRequest = { const dismissedCount = computed(() => dismissedIds.value.size)
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
return {
pantry_items: pantryItems, pantry_items: pantryItems,
level: level.value, level: level.value,
constraints: constraints.value, constraints: constraints.value,
@ -35,23 +73,77 @@ export const useRecipesStore = defineStore('recipes', () => {
hard_day_mode: hardDayMode.value, hard_day_mode: hardDayMode.value,
max_missing: maxMissing.value, max_missing: maxMissing.value,
style_id: styleId.value, style_id: styleId.value,
category: category.value,
wildcard_confirmed: wildcardConfirmed.value, wildcard_confirmed: wildcardConfirmed.value,
nutrition_filters: nutritionFilters.value,
excluded_ids: [...excluded],
} }
}
function _trackSeen(suggestions: RecipeSuggestion[]) {
for (const s of suggestions) {
if (s.id) seenIds.value = new Set([...seenIds.value, s.id])
}
}
async function suggest(pantryItems: string[]) {
loading.value = true
error.value = null
seenIds.value = new Set()
try { try {
result.value = await recipesAPI.suggest(req) result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
_trackSeen(result.value.suggestions)
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
error.value = err.message
} else {
error.value = 'Failed to get recipe suggestions'
}
console.error('Error fetching recipe suggestions:', err)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function loadMore(pantryItems: string[]) {
if (!result.value || loading.value) return
loading.value = true
error.value = null
try {
// Exclude everything already shown (dismissed + all seen this session)
const more = await recipesAPI.suggest(_buildRequest(pantryItems, [...seenIds.value]))
if (more.suggestions.length === 0) {
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
} else {
result.value = {
...result.value,
suggestions: [...result.value.suggestions, ...more.suggestions],
grocery_list: [...new Set([...result.value.grocery_list, ...more.grocery_list])],
grocery_links: [...result.value.grocery_links, ...more.grocery_links],
}
_trackSeen(more.suggestions)
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load more recipes'
} finally {
loading.value = false
}
}
function dismiss(id: number) {
dismissedIds.value = new Set([...dismissedIds.value, id])
saveDismissed(dismissedIds.value)
// Remove from current results immediately
if (result.value) {
result.value = {
...result.value,
suggestions: result.value.suggestions.filter((s) => s.id !== id),
}
}
}
function clearDismissed() {
dismissedIds.value = new Set()
localStorage.removeItem(DISMISSED_KEY)
}
function clearResult() { function clearResult() {
result.value = null result.value = null
error.value = null error.value = null
@ -59,7 +151,6 @@ export const useRecipesStore = defineStore('recipes', () => {
} }
return { return {
// State
result, result,
loading, loading,
error, error,
@ -69,10 +160,15 @@ export const useRecipesStore = defineStore('recipes', () => {
hardDayMode, hardDayMode,
maxMissing, maxMissing,
styleId, styleId,
category,
wildcardConfirmed, wildcardConfirmed,
nutritionFilters,
// Actions dismissedIds,
dismissedCount,
suggest, suggest,
loadMore,
dismiss,
clearDismissed,
clearResult, clearResult,
} }
}) })

View file

@ -1,9 +1,14 @@
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; /* Typography */
--font-display: 'Fraunces', Georgia, serif;
--font-mono: 'DM Mono', 'Courier New', monospace;
--font-body: 'DM Sans', system-ui, sans-serif;
font-family: var(--font-body);
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark; color-scheme: dark;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@ -11,66 +16,79 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
/* Theme Colors - Dark Mode (Default) */ /* Theme Colors - Dark Mode (Default) */
--color-text-primary: rgba(255, 255, 255, 0.87); --color-text-primary: rgba(255, 248, 235, 0.92);
--color-text-secondary: rgba(255, 255, 255, 0.6); --color-text-secondary: rgba(255, 248, 235, 0.60);
--color-text-muted: rgba(255, 255, 255, 0.4); --color-text-muted: rgba(255, 248, 235, 0.38);
--color-bg-primary: #242424; --color-bg-primary: #1e1c1a;
--color-bg-secondary: #1a1a1a; --color-bg-secondary: #161412;
--color-bg-elevated: #2d2d2d; --color-bg-elevated: #2a2724;
--color-bg-card: #2d2d2d; --color-bg-card: #2a2724;
--color-bg-input: #1a1a1a; --color-bg-input: #161412;
--color-border: rgba(255, 255, 255, 0.1); --color-border: rgba(232, 168, 32, 0.12);
--color-border-focus: rgba(255, 255, 255, 0.2); --color-border-focus: rgba(232, 168, 32, 0.35);
/* Brand Colors */ /* Brand Colors — Saffron amber + forest green */
--color-primary: #667eea; --color-primary: #e8a820;
--color-primary-dark: #5568d3; --color-primary-dark: #c88c10;
--color-primary-light: #7d8ff0; --color-primary-light: #f0bc48;
--color-secondary: #764ba2; --color-secondary: #2d5a27;
--color-secondary-light: #3d7a35;
--color-secondary-dark: #1e3d1a;
/* Status Colors */ /* Status Colors */
--color-success: #4CAF50; --color-success: #4a8c40;
--color-success-dark: #45a049; --color-success-dark: #3a7030;
--color-success-light: #66bb6a; --color-success-light: #6aac60;
--color-success-bg: rgba(76, 175, 80, 0.1); --color-success-bg: rgba(74, 140, 64, 0.12);
--color-success-border: rgba(76, 175, 80, 0.3); --color-success-border: rgba(74, 140, 64, 0.30);
--color-warning: #ff9800; --color-warning: #e8a820;
--color-warning-dark: #f57c00; --color-warning-dark: #c88c10;
--color-warning-light: #ffb74d; --color-warning-light: #f0bc48;
--color-warning-bg: rgba(255, 152, 0, 0.1); --color-warning-bg: rgba(232, 168, 32, 0.12);
--color-warning-border: rgba(255, 152, 0, 0.3); --color-warning-border: rgba(232, 168, 32, 0.30);
--color-error: #f44336; --color-error: #c0392b;
--color-error-dark: #d32f2f; --color-error-dark: #96281b;
--color-error-light: #ff6b6b; --color-error-light: #e74c3c;
--color-error-bg: rgba(244, 67, 54, 0.1); --color-error-bg: rgba(192, 57, 43, 0.12);
--color-error-border: rgba(244, 67, 54, 0.3); --color-error-border: rgba(192, 57, 43, 0.30);
--color-info: #2196F3; --color-info: #2980b9;
--color-info-dark: #1976D2; --color-info-dark: #1a5f8a;
--color-info-light: #64b5f6; --color-info-light: #5dade2;
--color-info-bg: rgba(33, 150, 243, 0.1); --color-info-bg: rgba(41, 128, 185, 0.12);
--color-info-border: rgba(33, 150, 243, 0.3); --color-info-border: rgba(41, 128, 185, 0.30);
/* Location dot colors */
--color-loc-fridge: #5dade2;
--color-loc-freezer: #48d1cc;
--color-loc-garage-freezer: #7fb3d3;
--color-loc-pantry: #e8a820;
--color-loc-cabinet: #a0855b;
/* Gradient */ /* Gradient */
--gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%); --gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, #c88c10 100%);
--gradient-secondary: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-light) 100%);
--gradient-header: linear-gradient(160deg, #2a2724 0%, #1e1c1a 100%);
/* Shadows */ /* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.4); --shadow-lg: 0 10px 24px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.5); --shadow-xl: 0 20px 48px rgba(0, 0, 0, 0.6);
--shadow-amber: 0 4px 16px rgba(232, 168, 32, 0.20);
/* Typography */ /* Typography Scale */
--font-size-xs: 12px; --font-size-xs: 11px;
--font-size-sm: 14px; --font-size-sm: 13px;
--font-size-base: 16px; --font-size-base: 15px;
--font-size-lg: 18px; --font-size-lg: 17px;
--font-size-xl: 24px; --font-size-xl: 22px;
--font-size-2xl: 32px; --font-size-2xl: 30px;
--font-size-display: 28px;
/* Spacing */ /* Spacing */
--spacing-xs: 4px; --spacing-xs: 4px;
@ -80,176 +98,154 @@
--spacing-xl: 32px; --spacing-xl: 32px;
/* Border Radius */ /* Border Radius */
--radius-sm: 4px; --radius-sm: 6px;
--radius-md: 6px; --radius-md: 8px;
--radius-lg: 8px; --radius-lg: 12px;
--radius-xl: 12px; --radius-xl: 16px;
--radius-pill: 999px;
color: var(--color-text-primary); color: var(--color-text-primary);
background-color: var(--color-bg-primary); background-color: var(--color-bg-primary);
} }
/* Light mode overrides */
@media (prefers-color-scheme: light) {
:root {
--color-text-primary: #2c1a06;
--color-text-secondary: #6b4c1e;
--color-text-muted: #a0845a;
--color-bg-primary: #fdf8f0;
--color-bg-secondary: #ffffff;
--color-bg-elevated: #fff9ed;
--color-bg-card: #ffffff;
--color-bg-input: #fef9ef;
--color-border: rgba(168, 100, 20, 0.15);
--color-border-focus: rgba(168, 100, 20, 0.40);
--color-success-bg: #e8f5e2;
--color-success-border: #c3e0bb;
--color-warning-bg: #fff8e1;
--color-warning-border: #ffe08a;
--color-error-bg: #fdecea;
--color-error-border: #f5c6c2;
--color-info-bg: #e3f2fd;
--color-info-border: #b3d8f5;
--gradient-header: linear-gradient(160deg, #fff9ed 0%, #fdf8f0 100%);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.10);
--shadow-lg: 0 10px 24px rgba(0, 0, 0, 0.12);
--shadow-xl: 0 20px 48px rgba(0, 0, 0, 0.16);
--shadow-amber: 0 4px 16px rgba(168, 100, 20, 0.15);
}
}
a { a {
font-weight: 500; font-weight: 500;
color: #646cff; color: var(--color-primary);
text-decoration: inherit; text-decoration: inherit;
} }
a:hover { a:hover {
color: #535bf2; color: var(--color-primary-light);
} }
body { body {
margin: 0; margin: 0;
display: flex;
place-items: center;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
} }
h1 { h1, h2, h3 {
font-size: 3.2em; font-family: var(--font-display);
line-height: 1.1; font-weight: 600;
line-height: 1.2;
} }
button { button {
border-radius: 8px; border-radius: var(--radius-md);
border: 1px solid transparent; border: 1px solid transparent;
padding: 0.6em 1.2em; padding: 0.5em 1.1em;
font-size: 1em; font-size: var(--font-size-sm);
font-weight: 500; font-weight: 500;
font-family: inherit; font-family: var(--font-body);
background-color: #1a1a1a; background-color: var(--color-bg-elevated);
color: var(--color-text-primary);
cursor: pointer; cursor: pointer;
transition: border-color 0.25s; transition: all 0.2s ease;
} }
button:hover { button:hover {
border-color: #646cff; border-color: var(--color-primary);
} }
button:focus, button:focus,
button:focus-visible { button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; outline: 2px solid var(--color-primary);
outline-offset: 2px;
} }
.card { .card {
padding: 2em; padding: var(--spacing-lg);
} }
#app { #app {
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; text-align: left;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
/* Theme Colors - Light Mode */
--color-text-primary: #213547;
--color-text-secondary: #666;
--color-text-muted: #999;
--color-bg-primary: #f5f5f5;
--color-bg-secondary: #ffffff;
--color-bg-elevated: #ffffff;
--color-bg-card: #ffffff;
--color-bg-input: #ffffff;
--color-border: #ddd;
--color-border-focus: #ccc;
/* Status colors stay the same in light mode */
/* But we adjust backgrounds for better contrast */
--color-success-bg: #d4edda;
--color-success-border: #c3e6cb;
--color-warning-bg: #fff3cd;
--color-warning-border: #ffeaa7;
--color-error-bg: #f8d7da;
--color-error-border: #f5c6cb;
--color-info-bg: #d1ecf1;
--color-info-border: #bee5eb;
/* Shadows for light mode (lighter) */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15);
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.2);
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
} }
/* Mobile Responsive Typography and Spacing */ /* Mobile Responsive Typography and Spacing */
@media (max-width: 480px) { @media (max-width: 480px) {
:root { :root {
/* Reduce font sizes for mobile */
--font-size-xs: 11px; --font-size-xs: 11px;
--font-size-sm: 13px; --font-size-sm: 12px;
--font-size-base: 14px; --font-size-base: 14px;
--font-size-lg: 16px; --font-size-lg: 16px;
--font-size-xl: 20px; --font-size-xl: 19px;
--font-size-2xl: 24px; --font-size-2xl: 24px;
--font-size-display: 22px;
/* Reduce spacing for mobile */
--spacing-xs: 4px; --spacing-xs: 4px;
--spacing-sm: 6px; --spacing-sm: 6px;
--spacing-md: 12px; --spacing-md: 12px;
--spacing-lg: 16px; --spacing-lg: 16px;
--spacing-xl: 20px; --spacing-xl: 20px;
/* Reduce shadows for mobile */ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-md: 0 2px 6px rgba(0, 0, 0, 0.35);
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.2); --shadow-lg: 0 6px 12px rgba(0, 0, 0, 0.40);
--shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.3); --shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.50);
--shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.4);
}
h1 {
font-size: 2em;
} }
.card { .card {
padding: 1em; padding: var(--spacing-md);
} }
#app { #app {
padding: 1rem; padding: 0;
} }
} }
@media (min-width: 481px) and (max-width: 768px) { @media (min-width: 481px) and (max-width: 768px) {
:root { :root {
/* Slightly reduced sizes for tablets */ --font-size-base: 14px;
--font-size-base: 15px; --font-size-lg: 16px;
--font-size-lg: 17px; --font-size-xl: 20px;
--font-size-xl: 22px; --font-size-2xl: 26px;
--font-size-2xl: 28px;
--spacing-md: 14px; --spacing-md: 14px;
--spacing-lg: 20px; --spacing-lg: 20px;
--spacing-xl: 28px; --spacing-xl: 28px;
} }
h1 {
font-size: 2.5em;
}
.card { .card {
padding: 1.5em; padding: var(--spacing-md) var(--spacing-lg);
} }
#app { #app {
padding: 1.5rem; padding: 0;
} }
} }

View file

@ -1,5 +1,5 @@
/** /**
* Central Theme System for Project Thoth * Central Theme System for Kiwi
* *
* This file contains all reusable, theme-aware, responsive CSS classes. * This file contains all reusable, theme-aware, responsive CSS classes.
* Components should use these classes instead of custom styles where possible. * Components should use these classes instead of custom styles where possible.
@ -9,24 +9,42 @@
LAYOUT UTILITIES - RESPONSIVE GRIDS LAYOUT UTILITIES - RESPONSIVE GRIDS
============================================ */ ============================================ */
/* Responsive Grid - Automatically adjusts columns based on screen size */
.grid-responsive { .grid-responsive {
display: grid; display: grid;
gap: var(--spacing-md); gap: var(--spacing-md);
} }
/* Mobile: 1 column, Tablet: 2 columns, Desktop: 3+ columns */
.grid-auto { .grid-auto {
display: grid; display: grid;
gap: var(--spacing-md); gap: var(--spacing-md);
grid-template-columns: 1fr; /* Default to single column */ grid-template-columns: 1fr;
} }
/* Stats grid - always fills available space */ /* Stats grid — horizontal strip of compact stats */
.grid-stats { .grid-stats {
display: grid; display: grid;
gap: var(--spacing-md); gap: var(--spacing-md);
grid-template-columns: 1fr; /* Default to single column */ grid-template-columns: 1fr;
}
.grid-stats-strip {
display: flex;
gap: 0;
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
.grid-stats-strip .stat-strip-item {
flex: 1;
text-align: center;
padding: var(--spacing-sm) var(--spacing-xs);
border-right: 1px solid var(--color-border);
}
.grid-stats-strip .stat-strip-item:last-child {
border-right: none;
} }
/* Force specific column counts */ /* Force specific column counts */
@ -36,7 +54,7 @@
.grid-4 { grid-template-columns: repeat(4, 1fr); } .grid-4 { grid-template-columns: repeat(4, 1fr); }
/* ============================================ /* ============================================
FLEXBOX UTILITIES - RESPONSIVE FLEXBOX UTILITIES
============================================ */ ============================================ */
.flex { display: flex; } .flex { display: flex; }
@ -63,7 +81,6 @@
align-items: center; align-items: center;
} }
/* Stack on mobile, horizontal on desktop */
.flex-responsive { .flex-responsive {
display: flex; display: flex;
gap: var(--spacing-md); gap: var(--spacing-md);
@ -74,14 +91,12 @@
SPACING UTILITIES SPACING UTILITIES
============================================ */ ============================================ */
/* Gaps */
.gap-xs { gap: var(--spacing-xs); } .gap-xs { gap: var(--spacing-xs); }
.gap-sm { gap: var(--spacing-sm); } .gap-sm { gap: var(--spacing-sm); }
.gap-md { gap: var(--spacing-md); } .gap-md { gap: var(--spacing-md); }
.gap-lg { gap: var(--spacing-lg); } .gap-lg { gap: var(--spacing-lg); }
.gap-xl { gap: var(--spacing-xl); } .gap-xl { gap: var(--spacing-xl); }
/* Padding */
.p-0 { padding: 0; } .p-0 { padding: 0; }
.p-xs { padding: var(--spacing-xs); } .p-xs { padding: var(--spacing-xs); }
.p-sm { padding: var(--spacing-sm); } .p-sm { padding: var(--spacing-sm); }
@ -89,7 +104,6 @@
.p-lg { padding: var(--spacing-lg); } .p-lg { padding: var(--spacing-lg); }
.p-xl { padding: var(--spacing-xl); } .p-xl { padding: var(--spacing-xl); }
/* Margin */
.m-0 { margin: 0; } .m-0 { margin: 0; }
.m-xs { margin: var(--spacing-xs); } .m-xs { margin: var(--spacing-xs); }
.m-sm { margin: var(--spacing-sm); } .m-sm { margin: var(--spacing-sm); }
@ -97,9 +111,14 @@
.m-lg { margin: var(--spacing-lg); } .m-lg { margin: var(--spacing-lg); }
.m-xl { margin: var(--spacing-xl); } .m-xl { margin: var(--spacing-xl); }
/* Margin/Padding specific sides */ .mt-xs { margin-top: var(--spacing-xs); }
.mt-sm { margin-top: var(--spacing-sm); }
.mt-md { margin-top: var(--spacing-md); } .mt-md { margin-top: var(--spacing-md); }
.mb-xs { margin-bottom: var(--spacing-xs); }
.mb-sm { margin-bottom: var(--spacing-sm); }
.mb-md { margin-bottom: var(--spacing-md); } .mb-md { margin-bottom: var(--spacing-md); }
.mb-lg { margin-bottom: var(--spacing-lg); }
.ml-xs { margin-left: var(--spacing-xs); }
.ml-md { margin-left: var(--spacing-md); } .ml-md { margin-left: var(--spacing-md); }
.mr-md { margin-right: var(--spacing-md); } .mr-md { margin-right: var(--spacing-md); }
@ -115,8 +134,9 @@
.card { .card {
background: var(--color-bg-card); background: var(--color-bg-card);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
padding: var(--spacing-xl); padding: var(--spacing-lg);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
border: 1px solid var(--color-border);
transition: box-shadow 0.2s ease; transition: box-shadow 0.2s ease;
} }
@ -129,20 +149,22 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: var(--spacing-md); padding: var(--spacing-md);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
} }
.card-secondary { .card-secondary {
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: var(--spacing-lg); padding: var(--spacing-md);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
} }
/* Status border variants */ /* Status border variants */
.card-success { border-left: 4px solid var(--color-success); } .card-success { border-left: 3px solid var(--color-success); }
.card-warning { border-left: 4px solid var(--color-warning); } .card-warning { border-left: 3px solid var(--color-warning); }
.card-error { border-left: 4px solid var(--color-error); } .card-error { border-left: 3px solid var(--color-error); }
.card-info { border-left: 4px solid var(--color-info); } .card-info { border-left: 3px solid var(--color-info); }
/* ============================================ /* ============================================
BUTTON COMPONENTS - THEME AWARE BUTTON COMPONENTS - THEME AWARE
@ -150,13 +172,18 @@
.btn { .btn {
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
border: none; border: 1px solid transparent;
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 600; font-weight: 600;
font-family: var(--font-body);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.18s ease;
white-space: nowrap; white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
} }
.btn:hover { .btn:hover {
@ -168,7 +195,7 @@
} }
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
} }
@ -176,8 +203,14 @@
/* Button variants */ /* Button variants */
.btn-primary { .btn-primary {
background: var(--gradient-primary); background: var(--gradient-primary);
color: white; color: #1e1c1a;
border: none; border: none;
font-weight: 700;
box-shadow: var(--shadow-amber);
}
.btn-primary:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(232, 168, 32, 0.35);
} }
.btn-success { .btn-success {
@ -208,20 +241,49 @@
} }
.btn-secondary { .btn-secondary {
background: var(--color-bg-secondary); background: var(--color-bg-elevated);
color: var(--color-text-primary); color: var(--color-text-secondary);
border: 2px solid var(--color-border); border: 1px solid var(--color-border);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--color-bg-primary); background: var(--color-bg-primary);
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary);
} }
.btn-secondary.active { .btn-secondary.active {
background: var(--gradient-primary); background: var(--color-primary);
color: white; color: #1e1c1a;
border-color: var(--color-primary); border-color: var(--color-primary);
font-weight: 700;
}
/* Pill chip button — for filter chips */
.btn-chip {
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
font-size: var(--font-size-xs);
font-weight: 500;
font-family: var(--font-body);
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.18s ease;
white-space: nowrap;
}
.btn-chip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-chip.active {
background: var(--color-primary);
color: #1e1c1a;
border-color: var(--color-primary);
font-weight: 700;
} }
/* Button sizes */ /* Button sizes */
@ -232,7 +294,38 @@
.btn-lg { .btn-lg {
padding: var(--spacing-md) var(--spacing-xl); padding: var(--spacing-md) var(--spacing-xl);
font-size: var(--font-size-lg); font-size: var(--font-size-base);
}
/* Icon-only action button */
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.18s ease;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-icon:hover {
background: var(--color-bg-primary);
color: var(--color-text-primary);
transform: none;
}
.btn-icon.btn-icon-danger:hover {
color: var(--color-error);
}
.btn-icon.btn-icon-success:hover {
color: var(--color-success);
} }
/* ============================================ /* ============================================
@ -245,10 +338,13 @@
.form-label { .form-label {
display: block; display: block;
margin-bottom: var(--spacing-sm); margin-bottom: var(--spacing-xs);
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-secondary);
font-size: var(--font-size-sm); font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
font-family: var(--font-body);
} }
.form-input, .form-input,
@ -261,7 +357,9 @@
background: var(--color-bg-input); background: var(--color-bg-input);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
transition: border-color 0.2s ease, box-shadow 0.2s ease; font-family: var(--font-body);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
box-sizing: border-box;
} }
.form-input:focus, .form-input:focus,
@ -269,22 +367,34 @@
.form-textarea:focus { .form-textarea:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); box-shadow: 0 0 0 3px var(--color-warning-bg);
} }
.form-textarea { .form-textarea {
resize: vertical; resize: vertical;
min-height: 80px; min-height: 80px;
font-family: inherit; font-family: var(--font-body);
} }
/* Form layouts */
.form-row { .form-row {
display: grid; display: grid;
gap: var(--spacing-md); gap: var(--spacing-md);
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
/* Chip row filter bar — horizontal scroll */
.filter-chip-row {
display: flex;
gap: var(--spacing-xs);
overflow-x: auto;
padding-bottom: var(--spacing-xs);
scrollbar-width: none;
}
.filter-chip-row::-webkit-scrollbar {
display: none;
}
/* ============================================ /* ============================================
TEXT UTILITIES TEXT UTILITIES
============================================ */ ============================================ */
@ -296,6 +406,17 @@
.text-xl { font-size: var(--font-size-xl); } .text-xl { font-size: var(--font-size-xl); }
.text-2xl { font-size: var(--font-size-2xl); } .text-2xl { font-size: var(--font-size-2xl); }
/* Display font */
.text-display {
font-family: var(--font-display);
font-style: italic;
}
/* Mono font */
.text-mono {
font-family: var(--font-mono);
}
.text-primary { color: var(--color-text-primary); } .text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); } .text-secondary { color: var(--color-text-secondary); }
.text-muted { color: var(--color-text-muted); } .text-muted { color: var(--color-text-muted); }
@ -304,6 +425,7 @@
.text-warning { color: var(--color-warning); } .text-warning { color: var(--color-warning); }
.text-error { color: var(--color-error); } .text-error { color: var(--color-error); }
.text-info { color: var(--color-info); } .text-info { color: var(--color-info); }
.text-amber { color: var(--color-primary); }
.text-center { text-align: center; } .text-center { text-align: center; }
.text-left { text-align: left; } .text-left { text-align: left; }
@ -313,59 +435,76 @@
.font-semibold { font-weight: 600; } .font-semibold { font-weight: 600; }
.font-normal { font-weight: 400; } .font-normal { font-weight: 400; }
/* ============================================
LOCATION DOT INDICATORS
============================================ */
.loc-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
.loc-dot-fridge { background: var(--color-loc-fridge); }
.loc-dot-freezer { background: var(--color-loc-freezer); }
.loc-dot-garage_freezer { background: var(--color-loc-garage-freezer); }
.loc-dot-pantry { background: var(--color-loc-pantry); }
.loc-dot-cabinet { background: var(--color-loc-cabinet); }
/* Location left-border strip on inventory rows */
.inv-row-fridge { border-left-color: var(--color-loc-fridge) !important; }
.inv-row-freezer { border-left-color: var(--color-loc-freezer) !important; }
.inv-row-garage_freezer { border-left-color: var(--color-loc-garage-freezer) !important; }
.inv-row-pantry { border-left-color: var(--color-loc-pantry) !important; }
.inv-row-cabinet { border-left-color: var(--color-loc-cabinet) !important; }
/* ============================================ /* ============================================
RESPONSIVE UTILITIES RESPONSIVE UTILITIES
============================================ */ ============================================ */
/* Show/Hide based on screen size */
.mobile-only { display: none; } .mobile-only { display: none; }
.desktop-only { display: block; } .desktop-only { display: block; }
/* Width utilities */
.w-full { width: 100%; } .w-full { width: 100%; }
.w-auto { width: auto; } .w-auto { width: auto; }
/* Height utilities */
.h-full { height: 100%; } .h-full { height: 100%; }
.h-auto { height: auto; } .h-auto { height: auto; }
/* ============================================ /* ============================================
MOBILE BREAKPOINTS (480px) MOBILE BREAKPOINTS (<=480px)
============================================ */ ============================================ */
@media (max-width: 480px) { @media (max-width: 480px) {
/* Show/Hide */
.mobile-only { display: block; } .mobile-only { display: block; }
.desktop-only { display: none; } .desktop-only { display: none; }
/* Grids already default to 1fr, just ensure it stays that way */
.grid-2, .grid-2,
.grid-3, .grid-3,
.grid-4 { .grid-4 {
grid-template-columns: 1fr !important; grid-template-columns: 1fr !important;
} }
/* Stack flex items vertically */
.flex-responsive { .flex-responsive {
flex-direction: column; flex-direction: column;
} }
/* Buttons take full width */
.btn-mobile-full { .btn-mobile-full {
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;
} }
/* Reduce card padding on mobile */
.card { .card {
padding: var(--spacing-md); padding: var(--spacing-md);
border-radius: var(--radius-lg);
} }
.card-sm { .card-sm {
padding: var(--spacing-sm); padding: var(--spacing-sm);
} }
/* Allow text wrapping on mobile */
.btn { .btn {
white-space: normal; white-space: normal;
text-align: center; text-align: center;
@ -377,7 +516,6 @@
============================================ */ ============================================ */
@media (min-width: 481px) and (max-width: 768px) { @media (min-width: 481px) and (max-width: 768px) {
/* 2-column layouts on tablets */
.grid-3, .grid-3,
.grid-4 { .grid-4 {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@ -402,11 +540,11 @@
@media (min-width: 769px) and (max-width: 1024px) { @media (min-width: 769px) and (max-width: 1024px) {
.grid-auto { .grid-auto {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.grid-stats { .grid-stats {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
} }
.grid-4 { .grid-4 {
@ -415,16 +553,16 @@
} }
/* ============================================ /* ============================================
LARGE DESKTOP (1025px) LARGE DESKTOP (>=1025px)
============================================ */ ============================================ */
@media (min-width: 1025px) { @media (min-width: 1025px) {
.grid-auto { .grid-auto {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
} }
.grid-stats { .grid-stats {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
} }
.form-row { .form-row {
@ -437,34 +575,37 @@
============================================ */ ============================================ */
.status-badge { .status-badge {
display: inline-block; display: inline-flex;
padding: var(--spacing-xs) var(--spacing-sm); align-items: center;
border-radius: var(--radius-sm); padding: 3px var(--spacing-sm);
border-radius: var(--radius-pill);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: 600; font-weight: 600;
font-family: var(--font-mono);
letter-spacing: 0.02em;
} }
.status-success { .status-success {
background: var(--color-success-bg); background: var(--color-success-bg);
color: var(--color-success-dark); color: var(--color-success-light);
border: 1px solid var(--color-success-border); border: 1px solid var(--color-success-border);
} }
.status-warning { .status-warning {
background: var(--color-warning-bg); background: var(--color-warning-bg);
color: var(--color-warning-dark); color: var(--color-warning-light);
border: 1px solid var(--color-warning-border); border: 1px solid var(--color-warning-border);
} }
.status-error { .status-error {
background: var(--color-error-bg); background: var(--color-error-bg);
color: var(--color-error-dark); color: var(--color-error-light);
border: 1px solid var(--color-error-border); border: 1px solid var(--color-error-border);
} }
.status-info { .status-info {
background: var(--color-info-bg); background: var(--color-info-bg);
color: var(--color-info-dark); color: var(--color-info-light);
border: 1px solid var(--color-info-border); border: 1px solid var(--color-info-border);
} }
@ -488,7 +629,7 @@
@keyframes slideUp { @keyframes slideUp {
from { from {
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(16px);
} }
to { to {
opacity: 1; opacity: 1;
@ -496,23 +637,33 @@
} }
} }
/* Urgency pulse — for items expiring very soon */
@keyframes urgencyPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.pulse-urgent {
animation: urgencyPulse 1.8s ease-in-out infinite;
}
/* ============================================ /* ============================================
LOADING UTILITIES LOADING UTILITIES
============================================ */ ============================================ */
.spinner { .spinner {
border: 3px solid var(--color-border); border: 2px solid var(--color-border);
border-top: 3px solid var(--color-primary); border-top: 2px solid var(--color-primary);
border-radius: 50%; border-radius: 50%;
width: 40px; width: 36px;
height: 40px; height: 36px;
animation: spin 1s linear infinite; animation: spin 0.9s linear infinite;
margin: 0 auto; margin: 0 auto;
} }
.spinner-sm { .spinner-sm {
width: 20px; width: 18px;
height: 20px; height: 18px;
border-width: 2px; border-width: 2px;
} }
@ -534,3 +685,160 @@
.divider-md { .divider-md {
margin: var(--spacing-md) 0; margin: var(--spacing-md) 0;
} }
/* ============================================
SECTION HEADERS (display font)
============================================ */
.section-title {
font-family: var(--font-display);
font-style: italic;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
/* ============================================
EASTER EGG GRID KITCHEN NEON MODE
Activated via Konami code
============================================ */
body.neon-mode .card,
body.neon-mode .card-sm,
body.neon-mode .card-secondary {
box-shadow:
0 0 0 1px rgba(255, 0, 110, 0.35),
0 0 12px rgba(255, 0, 110, 0.18),
0 2px 20px rgba(131, 56, 236, 0.15);
}
body.neon-mode .btn-primary {
box-shadow: 0 0 18px rgba(255, 0, 110, 0.55), 0 0 36px rgba(131, 56, 236, 0.25);
color: #fff;
}
body.neon-mode .wordmark-kiwi {
text-shadow: 0 0 10px rgba(255, 0, 110, 0.7), 0 0 24px rgba(131, 56, 236, 0.5);
}
body.neon-mode .sidebar,
body.neon-mode .bottom-nav {
border-color: rgba(255, 0, 110, 0.3);
box-shadow: 4px 0 20px rgba(255, 0, 110, 0.12);
}
body.neon-mode .sidebar-item.active,
body.neon-mode .nav-item.active {
text-shadow: 0 0 8px currentColor;
}
/* Scanline overlay */
body.neon-mode::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 3px,
rgba(0, 0, 0, 0.08) 3px,
rgba(0, 0, 0, 0.08) 4px
);
pointer-events: none;
z-index: 9998;
animation: scanlineScroll 8s linear infinite;
}
@keyframes scanlineScroll {
0% { background-position: 0 0; }
100% { background-position: 0 80px; }
}
/* CRT flicker on wordmark */
body.neon-mode .wordmark-kiwi {
animation: crtFlicker 6s ease-in-out infinite;
}
@keyframes crtFlicker {
0%, 94%, 100% { opacity: 1; }
95% { opacity: 0.88; }
97% { opacity: 0.95; }
98% { opacity: 0.82; }
}
/* ============================================
EASTER EGG KIWI BIRD SPRITE
============================================ */
.kiwi-bird-stage {
position: fixed;
bottom: 72px; /* above bottom nav */
left: 0;
right: 0;
height: 72px;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
@media (min-width: 769px) {
.kiwi-bird-stage {
bottom: 0;
left: 200px; /* clear the sidebar */
}
}
.kiwi-bird {
position: absolute;
bottom: 8px;
width: 64px;
height: 64px;
will-change: transform;
}
/* Enters from right, walks left */
.kiwi-bird.rtl {
animation: kiwiWalkRtl 5.5s ease-in-out forwards;
}
.kiwi-bird.rtl .kiwi-svg {
transform: scaleX(1); /* faces left */
}
/* Enters from left, walks right */
.kiwi-bird.ltr {
animation: kiwiWalkLtr 5.5s ease-in-out forwards;
}
.kiwi-bird.ltr .kiwi-svg {
transform: scaleX(-1); /* faces right */
}
/* Bob on each step */
.kiwi-svg {
display: block;
animation: kiwiBob 0.38s steps(1) infinite;
}
@keyframes kiwiWalkRtl {
0% { right: -80px; }
15% { right: 35%; } /* enter and slow */
40% { right: 35%; } /* pause — sniffing */
55% { right: 38%; } /* tiny shuffle */
60% { right: 35%; }
85% { right: 35%; }
100% { right: calc(100% + 80px); } /* exit left */
}
@keyframes kiwiWalkLtr {
0% { left: -80px; }
15% { left: 35%; }
40% { left: 35%; }
55% { left: 38%; }
60% { left: 35%; }
85% { left: 35%; }
100% { left: calc(100% + 80px); }
}
@keyframes kiwiBob {
0% { transform: translateY(0) scaleX(var(--bird-flip, 1)); }
50% { transform: translateY(-4px) scaleX(var(--bird-flip, 1)); }
}

View file

@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Backfill texture_profile in ingredient_profiles from existing macro data.
Texture categories and their macro signatures (all values g/100g):
fatty - fat > 60 (oils, lard, pure butter)
creamy - fat 15-60 (cream, cheese, fatty meats, nut butter)
firm - protein > 15, fat < 15 (lean meats, fish, legumes, firm tofu)
starchy - carbs > 40, fat < 10 (flour, oats, rice, bread, potatoes)
fibrous - fiber > 4, carbs < 40 (brassicas, leafy greens, whole grains)
tender - protein 2-15, fat < 10, (soft veg, eggs, soft tofu, cooked beans)
carbs < 40
liquid - calories < 25, fat < 1, (broth, juice, dilute sauces)
protein < 3
neutral - fallthrough default
Rules are applied in priority order: fatty creamy firm starchy
fibrous tender liquid neutral.
Run:
python scripts/backfill_texture_profiles.py [path/to/kiwi.db]
Or inside the container:
docker exec kiwi-cloud-api-1 python /app/kiwi/scripts/backfill_texture_profiles.py
"""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
# Default DB paths to try
_DEFAULT_PATHS = [
"/devl/kiwi-cloud-data/local-dev/kiwi.db",
"/devl/kiwi-data/kiwi.db",
]
BATCH_SIZE = 5_000
def _classify(fat: float, protein: float, carbs: float,
fiber: float, calories: float) -> str:
# Cap runaway values — data quality issue in some branded entries
fat = min(fat or 0.0, 100.0)
protein = min(protein or 0.0, 100.0)
carbs = min(carbs or 0.0, 100.0)
fiber = min(fiber or 0.0, 50.0)
calories = min(calories or 0.0, 900.0)
if fat > 60:
return "fatty"
if fat > 15:
return "creamy"
# Starchy before firm: oats/legumes have high protein AND high carbs — carbs win
if carbs > 40 and fat < 10:
return "starchy"
# Firm: lean proteins with low carbs (meats, fish, hard tofu)
# Lower protein threshold (>7) catches tofu (9%) and similar plant proteins
if protein > 7 and fat < 12 and carbs < 20:
return "firm"
if fiber > 4 and carbs < 40:
return "fibrous"
if 2 < protein <= 15 and fat < 10 and carbs < 40:
return "tender"
if calories < 25 and fat < 1 and protein < 3:
return "liquid"
return "neutral"
def backfill(db_path: str) -> None:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
total = conn.execute("SELECT COUNT(*) FROM ingredient_profiles").fetchone()[0]
print(f"Total rows: {total:,}")
updated = 0
offset = 0
counts: dict[str, int] = {}
while True:
rows = conn.execute(
"""SELECT id, fat_pct, protein_pct, carbs_g_per_100g,
fiber_g_per_100g, calories_per_100g
FROM ingredient_profiles
LIMIT ? OFFSET ?""",
(BATCH_SIZE, offset),
).fetchall()
if not rows:
break
batch: list[tuple[str, int]] = []
for row in rows:
texture = _classify(
row["fat_pct"],
row["protein_pct"],
row["carbs_g_per_100g"],
row["fiber_g_per_100g"],
row["calories_per_100g"],
)
counts[texture] = counts.get(texture, 0) + 1
batch.append((texture, row["id"]))
conn.executemany(
"UPDATE ingredient_profiles SET texture_profile = ? WHERE id = ?",
batch,
)
conn.commit()
updated += len(batch)
offset += BATCH_SIZE
print(f" {updated:,} / {total:,} updated...", end="\r")
print(f"\nDone. {updated:,} rows updated.\n")
print("Texture distribution:")
for texture, count in sorted(counts.items(), key=lambda x: -x[1]):
pct = count / updated * 100
print(f" {texture:10s} {count:8,} ({pct:.1f}%)")
conn.close()
if __name__ == "__main__":
if len(sys.argv) > 1:
path = sys.argv[1]
else:
path = next((p for p in _DEFAULT_PATHS if Path(p).exists()), None)
if not path:
print(f"No DB found. Pass path as argument or create one of: {_DEFAULT_PATHS}")
sys.exit(1)
print(f"Backfilling texture profiles in: {path}")
backfill(path)

View file

@ -83,9 +83,30 @@ def build(db_path: Path, usda_fdc_path: Path, usda_branded_path: Path) -> None:
"Fiber, total dietary": "fiber_pct", "Fiber, total dietary": "fiber_pct",
"Sodium, Na": "sodium_mg_per_100g", "Sodium, Na": "sodium_mg_per_100g",
"Water": "moisture_pct", "Water": "moisture_pct",
"Energy": "calories_per_100g",
} }
df = df_fdc.rename(columns={k: v for k, v in fdc_col_map.items() if k in df_fdc.columns}) df = df_fdc.rename(columns={k: v for k, v in fdc_col_map.items() if k in df_fdc.columns})
# Build a sugar lookup from the branded parquet (keyed by normalized name).
# usda_branded has SUGARS, TOTAL (G) for processed/packaged foods.
branded_col_map = {
"FOOD_NAME": "name",
"SUGARS, TOTAL (G)": "sugar_g_per_100g",
}
df_branded_slim = df_branded.rename(
columns={k: v for k, v in branded_col_map.items() if k in df_branded.columns}
)[list(set(branded_col_map.values()) & set(df_branded.rename(columns=branded_col_map).columns))]
sugar_lookup: dict[str, float] = {}
for _, brow in df_branded_slim.iterrows():
bname = normalize_name(str(brow.get("name", "")))
val = brow.get("sugar_g_per_100g")
try:
fval = float(val) # type: ignore[arg-type]
if fval > 0 and bname not in sugar_lookup:
sugar_lookup[bname] = fval
except (TypeError, ValueError):
pass
inserted = 0 inserted = 0
for _, row in df.iterrows(): for _, row in df.iterrows():
name = normalize_name(str(row.get("name", ""))) name = normalize_name(str(row.get("name", "")))
@ -98,25 +119,40 @@ def build(db_path: Path, usda_fdc_path: Path, usda_branded_path: Path) -> None:
"moisture_pct": float(row.get("moisture_pct") or 0), "moisture_pct": float(row.get("moisture_pct") or 0),
"sodium_mg_per_100g": float(row.get("sodium_mg_per_100g") or 0), "sodium_mg_per_100g": float(row.get("sodium_mg_per_100g") or 0),
"starch_pct": 0.0, "starch_pct": 0.0,
"carbs_g_per_100g": float(row.get("carb_pct") or 0),
"fiber_g_per_100g": float(row.get("fiber_pct") or 0),
"calories_per_100g": float(row.get("calories_per_100g") or 0),
"sugar_g_per_100g": sugar_lookup.get(name, 0.0),
} }
r["binding_score"] = derive_binding_score(r) r["binding_score"] = derive_binding_score(r)
r["elements"] = derive_elements(r) r["elements"] = derive_elements(r)
r["is_fermented"] = int(any(k in name for k in _FERMENTED_KEYWORDS)) r["is_fermented"] = int(any(k in name for k in _FERMENTED_KEYWORDS))
try: try:
# Insert new profile or update macro columns on existing one.
conn.execute(""" conn.execute("""
INSERT OR IGNORE INTO ingredient_profiles INSERT INTO ingredient_profiles
(name, elements, fat_pct, fat_saturated_pct, moisture_pct, (name, elements, fat_pct, fat_saturated_pct, moisture_pct,
protein_pct, starch_pct, binding_score, sodium_mg_per_100g, protein_pct, starch_pct, binding_score, sodium_mg_per_100g,
is_fermented, source) is_fermented,
VALUES (?,?,?,?,?,?,?,?,?,?,?) carbs_g_per_100g, fiber_g_per_100g, calories_per_100g, sugar_g_per_100g,
source)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(name) DO UPDATE SET
carbs_g_per_100g = excluded.carbs_g_per_100g,
fiber_g_per_100g = excluded.fiber_g_per_100g,
calories_per_100g = excluded.calories_per_100g,
sugar_g_per_100g = excluded.sugar_g_per_100g
""", ( """, (
r["name"], json.dumps(r["elements"]), r["name"], json.dumps(r["elements"]),
r["fat_pct"], 0.0, r["moisture_pct"], r["fat_pct"], 0.0, r["moisture_pct"],
r["protein_pct"], r["starch_pct"], r["binding_score"], r["protein_pct"], r["starch_pct"], r["binding_score"],
r["sodium_mg_per_100g"], r["is_fermented"], "usda_fdc", r["sodium_mg_per_100g"], r["is_fermented"],
r["carbs_g_per_100g"], r["fiber_g_per_100g"],
r["calories_per_100g"], r["sugar_g_per_100g"],
"usda_fdc",
)) ))
inserted += conn.execute("SELECT changes()").fetchone()[0] inserted += 1
except Exception: except Exception:
continue continue

View file

@ -28,6 +28,30 @@ _TRAILING_QUALIFIER = re.compile(
_QUOTED = re.compile(r'"([^"]*)"') _QUOTED = re.compile(r'"([^"]*)"')
def _float_or_none(val: object) -> float | None:
"""Return float > 0, or None for missing / zero values."""
try:
v = float(val) # type: ignore[arg-type]
return v if v > 0 else None
except (TypeError, ValueError):
return None
def _safe_list(val: object) -> list:
"""Convert a value to a list, handling NaN/float/None gracefully."""
if val is None:
return []
try:
import math
if isinstance(val, float) and math.isnan(val):
return []
except Exception:
pass
if isinstance(val, list):
return val
return []
def _parse_r_vector(s: str) -> list[str]: def _parse_r_vector(s: str) -> list[str]:
"""Parse R character vector format: c("a", "b") -> ["a", "b"].""" """Parse R character vector format: c("a", "b") -> ["a", "b"]."""
return _QUOTED.findall(s) return _QUOTED.findall(s)
@ -93,14 +117,14 @@ def _row_to_fields(row: pd.Series) -> tuple[str, str, list[str], list[str]]:
if isinstance(raw_parts, str): if isinstance(raw_parts, str):
parsed = _parse_r_vector(raw_parts) parsed = _parse_r_vector(raw_parts)
raw_parts = parsed if parsed else [raw_parts] raw_parts = parsed if parsed else [raw_parts]
raw_ingredients = [str(i) for i in (raw_parts or [])] raw_ingredients = [str(i) for i in (_safe_list(raw_parts))]
raw_dirs = row.get("RecipeInstructions", []) raw_dirs = row.get("RecipeInstructions", [])
if isinstance(raw_dirs, str): if isinstance(raw_dirs, str):
parsed_dirs = _parse_r_vector(raw_dirs) parsed_dirs = _parse_r_vector(raw_dirs)
directions = parsed_dirs if parsed_dirs else [raw_dirs] directions = parsed_dirs if parsed_dirs else [raw_dirs]
else: else:
directions = [str(d) for d in (raw_dirs or [])] directions = [str(d) for d in (_safe_list(raw_dirs))]
title = str(row.get("Name", ""))[:500] title = str(row.get("Name", ""))[:500]
external_id = str(row.get("RecipeId", "")) external_id = str(row.get("RecipeId", ""))
@ -144,12 +168,18 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
json.dumps(ingredient_names), json.dumps(ingredient_names),
json.dumps(directions), json.dumps(directions),
str(row.get("RecipeCategory", "") or ""), str(row.get("RecipeCategory", "") or ""),
json.dumps(list(row.get("Keywords", []) or [])), json.dumps(_safe_list(row.get("Keywords"))),
float(row.get("Calories") or 0) or None, _float_or_none(row.get("Calories")),
float(row.get("FatContent") or 0) or None, _float_or_none(row.get("FatContent")),
float(row.get("ProteinContent") or 0) or None, _float_or_none(row.get("ProteinContent")),
float(row.get("SodiumContent") or 0) or None, _float_or_none(row.get("SodiumContent")),
json.dumps(coverage), json.dumps(coverage),
# New macro columns (migration 014)
_float_or_none(row.get("SugarContent")),
_float_or_none(row.get("CarbohydrateContent")),
_float_or_none(row.get("FiberContent")),
_float_or_none(row.get("RecipeServings")),
0, # nutrition_estimated — food.com direct data is authoritative
)) ))
if len(batch) >= batch_size: if len(batch) >= batch_size:
@ -157,8 +187,10 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
conn.executemany(""" conn.executemany("""
INSERT OR REPLACE INTO recipes INSERT OR REPLACE INTO recipes
(external_id, title, ingredients, ingredient_names, directions, (external_id, title, ingredients, ingredient_names, directions,
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage) category, keywords, calories, fat_g, protein_g, sodium_mg,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?) element_coverage,
sugar_g, carbs_g, fiber_g, servings, nutrition_estimated)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", batch) """, batch)
conn.commit() conn.commit()
inserted += conn.total_changes - before inserted += conn.total_changes - before
@ -170,8 +202,10 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
conn.executemany(""" conn.executemany("""
INSERT OR REPLACE INTO recipes INSERT OR REPLACE INTO recipes
(external_id, title, ingredients, ingredient_names, directions, (external_id, title, ingredients, ingredient_names, directions,
category, keywords, calories, fat_g, protein_g, sodium_mg, element_coverage) category, keywords, calories, fat_g, protein_g, sodium_mg,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?) element_coverage,
sugar_g, carbs_g, fiber_g, servings, nutrition_estimated)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", batch) """, batch)
conn.commit() conn.commit()
inserted += conn.total_changes - before inserted += conn.total_changes - before

View file

@ -0,0 +1,109 @@
"""
Estimate macro nutrition for recipes that have no direct data.
For each recipe where sugar_g / carbs_g / fiber_g / calories are NULL,
look up the matched ingredient_profiles and average their per-100g values,
then scale by a rough 150g-per-ingredient portion assumption.
Mark such rows with nutrition_estimated=1 so the UI can display a disclaimer.
Recipes with food.com direct data (nutrition_estimated=0 and values set) are untouched.
Usage:
conda run -n job-seeker python scripts/pipeline/estimate_recipe_nutrition.py \
--db /path/to/kiwi.db
"""
from __future__ import annotations
import argparse
import json
import sqlite3
from pathlib import Path
# Rough grams per ingredient when no quantity data is available.
_GRAMS_PER_INGREDIENT = 150.0
def estimate(db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
# Load ingredient_profiles macro data into memory for fast lookup.
profile_macros: dict[str, dict[str, float]] = {}
for row in conn.execute(
"SELECT name, calories_per_100g, carbs_g_per_100g, fiber_g_per_100g, sugar_g_per_100g "
"FROM ingredient_profiles"
):
name, cal, carbs, fiber, sugar = row
if name:
profile_macros[name] = {
"calories": float(cal or 0),
"carbs": float(carbs or 0),
"fiber": float(fiber or 0),
"sugar": float(sugar or 0),
}
# Select recipes with no direct nutrition data.
rows = conn.execute(
"SELECT id, ingredient_names FROM recipes "
"WHERE sugar_g IS NULL AND carbs_g IS NULL AND fiber_g IS NULL"
).fetchall()
updated = 0
batch: list[tuple] = []
for recipe_id, ingredient_names_json in rows:
try:
names: list[str] = json.loads(ingredient_names_json or "[]")
except Exception:
names = []
matched = [profile_macros[n] for n in names if n in profile_macros]
if not matched:
continue
# Average per-100g macros across matched ingredients,
# then multiply by assumed portion weight per ingredient.
n = len(matched)
portion_factor = _GRAMS_PER_INGREDIENT / 100.0
total_cal = sum(m["calories"] for m in matched) / n * portion_factor * n
total_carbs = sum(m["carbs"] for m in matched) / n * portion_factor * n
total_fiber = sum(m["fiber"] for m in matched) / n * portion_factor * n
total_sugar = sum(m["sugar"] for m in matched) / n * portion_factor * n
batch.append((
round(total_cal, 1) or None,
round(total_carbs, 2) or None,
round(total_fiber, 2) or None,
round(total_sugar, 2) or None,
recipe_id,
))
if len(batch) >= 5000:
conn.executemany(
"UPDATE recipes SET calories=?, carbs_g=?, fiber_g=?, sugar_g=?, "
"nutrition_estimated=1 WHERE id=?",
batch,
)
conn.commit()
updated += len(batch)
print(f" {updated} recipes estimated...")
batch = []
if batch:
conn.executemany(
"UPDATE recipes SET calories=?, carbs_g=?, fiber_g=?, sugar_g=?, "
"nutrition_estimated=1 WHERE id=?",
batch,
)
conn.commit()
updated += len(batch)
conn.close()
print(f"Total: {updated} recipes received estimated nutrition")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--db", required=True, type=Path)
args = parser.parse_args()
estimate(args.db)