chore: merge main into feature/meal-planner

Resolves three conflicts:
- app/api/routes.py: fixed saved_recipes-before-recipes ordering from main;
  meal_plans and community_router from feature branch
- app/db/store.py: meal plan/prep session methods (feature) + community
  pseudonym methods (main) -- both additive
- app/tiers.py: KIWI_BYOK_UNLOCKABLE includes meal_plan_llm,
  meal_plan_llm_timing (feature) and community_fork_adapt (main)
This commit is contained in:
pyr0ball 2026-04-14 14:53:52 -07:00
commit 9941227fae
57 changed files with 5576 additions and 123 deletions

View file

@ -83,3 +83,10 @@ DEMO_MODE=false
# INSTACART_AFFILIATE_ID=circuitforge
# Walmart Impact network affiliate ID (inline, path-based redirect)
# WALMART_AFFILIATE_ID=
# Community PostgreSQL — shared across CF products (cloud only; leave unset for local dev)
# Points at cf-orch's cf-community-postgres container (port 5434 on the orch host).
# When unset, community write paths fail soft with a plain-language message.
# COMMUNITY_DB_URL=postgresql://cf_community:changeme@cf-orch-host:5434/cf_community
# COMMUNITY_PSEUDONYM_SALT=change-this-to-a-random-32-char-string

View file

@ -16,6 +16,12 @@ COPY kiwi/environment.yml .
RUN conda env create -f environment.yml
COPY kiwi/ ./kiwi/
# Remove gitignored config files that may exist locally — defense-in-depth.
# The parent .dockerignore should exclude these, but an explicit rm guarantees
# they never end up in the cloud image regardless of .dockerignore placement.
RUN rm -f /app/kiwi/.env
# Install cf-core into the kiwi env BEFORE installing kiwi (kiwi lists it as a dep)
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-core
WORKDIR /app/kiwi

View file

@ -10,6 +10,8 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea
**Status:** Beta · CircuitForge LLC
**[Documentation](https://docs.circuitforge.tech/kiwi/)** · [circuitforge.tech](https://circuitforge.tech)
---
## What it does
@ -21,7 +23,7 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable)
- **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable)
- **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable)
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier)
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Free, 5/day; unlimited at Paid+)
- **LLM backend config** — configure inference via `circuitforge-core` env-var system; BYOK unlocks Paid AI features at any tier
- **Feedback FAB** — in-app feedback button; status probed on load, hidden if CF feedback endpoint unreachable
@ -68,7 +70,7 @@ cp .env.example .env
| LLM style auto-classifier | — | BYOK | ✓ |
| Meal planning | — | ✓ | ✓ |
| Multi-household | — | — | ✓ |
| Leftover mode | — | — | ✓ |
| Leftover mode (5/day) | ✓ | ✓ | ✓ |
BYOK = bring your own LLM backend (configure `~/.config/circuitforge/llm.yaml`)

View file

@ -0,0 +1,351 @@
# app/api/endpoints/community.py
# MIT License
from __future__ import annotations
import asyncio
import logging
import re
import sqlite3
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from app.cloud_session import CloudUser, get_session
from app.core.config import settings
from app.db.store import Store
from app.services.community.feed import posts_to_rss
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/community", tags=["community"])
_community_store = None
def _get_community_store():
return _community_store
def init_community_store(community_db_url: str | None) -> None:
global _community_store
if not community_db_url:
logger.info(
"COMMUNITY_DB_URL not set — community write features disabled. "
"Browse still works via cloud feed."
)
return
from circuitforge_core.community import CommunityDB
from app.services.community.community_store import KiwiCommunityStore
db = CommunityDB(dsn=community_db_url)
db.run_migrations()
_community_store = KiwiCommunityStore(db)
logger.info("Community store initialized.")
def _visible(post, session=None) -> bool:
"""Return False for premium-tier posts when the session is not paid/premium."""
tier = getattr(post, "tier", None)
if tier == "premium":
if session is None or getattr(session, "tier", None) not in ("paid", "premium"):
return False
return True
@router.get("/posts")
async def list_posts(
post_type: str | None = None,
dietary_tags: str | None = None,
allergen_exclude: str | None = None,
page: int = 1,
page_size: int = 20,
):
store = _get_community_store()
if store is None:
return {"posts": [], "total": 0, "note": "Community DB not available on this instance."}
dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None
allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None
offset = (page - 1) * min(page_size, 100)
posts = await asyncio.to_thread(
store.list_posts,
limit=min(page_size, 100),
offset=offset,
post_type=post_type,
dietary_tags=dietary,
allergen_exclude=allergen_ex,
)
return {"posts": [_post_to_dict(p) for p in posts if _visible(p)], "page": page, "page_size": page_size}
@router.get("/posts/{slug}")
async def get_post(slug: str, request: Request):
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available on this instance.")
post = await asyncio.to_thread(store.get_post_by_slug, slug)
if post is None:
raise HTTPException(status_code=404, detail="Post not found.")
accept = request.headers.get("accept", "")
if "application/activity+json" in accept or "application/ld+json" in accept:
from app.services.community.ap_compat import post_to_ap_json_ld
base_url = str(request.base_url).rstrip("/")
return post_to_ap_json_ld(_post_to_dict(post), base_url=base_url)
return _post_to_dict(post)
@router.get("/feed.rss")
async def get_rss_feed(request: Request):
store = _get_community_store()
posts_data: list[dict] = []
if store is not None:
posts = await asyncio.to_thread(store.list_posts, limit=50)
posts_data = [_post_to_dict(p) for p in posts]
base_url = str(request.base_url).rstrip("/")
rss = posts_to_rss(posts_data, base_url=base_url)
return Response(content=rss, media_type="application/rss+xml; charset=utf-8")
@router.get("/local-feed")
async def local_feed():
store = _get_community_store()
if store is None:
return []
posts = await asyncio.to_thread(store.list_posts, limit=50)
return [_post_to_dict(p) for p in posts]
@router.get("/hall-of-chaos")
async def hall_of_chaos():
"""Hidden easter egg endpoint -- returns the 10 most chaotic bloopers."""
store = _get_community_store()
if store is None:
return {"posts": [], "chaos_level": 0}
posts = await asyncio.to_thread(
store.list_posts, limit=10, post_type="recipe_blooper"
)
return {
"posts": [_post_to_dict(p) for p in posts],
"chaos_level": len(posts),
}
_VALID_POST_TYPES = {"plan", "recipe_success", "recipe_blooper"}
_MAX_TITLE_LEN = 200
_MAX_TEXT_LEN = 2000
def _validate_publish_body(body: dict) -> None:
"""Raise HTTPException(422) for any invalid fields in a publish request."""
post_type = body.get("post_type", "plan")
if post_type not in _VALID_POST_TYPES:
raise HTTPException(
status_code=422,
detail=f"post_type must be one of: {', '.join(sorted(_VALID_POST_TYPES))}",
)
title = body.get("title") or ""
if len(title) > _MAX_TITLE_LEN:
raise HTTPException(status_code=422, detail=f"title exceeds {_MAX_TITLE_LEN} character limit.")
for field in ("description", "outcome_notes", "recipe_name"):
value = body.get(field)
if value and len(str(value)) > _MAX_TEXT_LEN:
raise HTTPException(status_code=422, detail=f"{field} exceeds {_MAX_TEXT_LEN} character limit.")
photo_url = body.get("photo_url")
if photo_url and not str(photo_url).startswith("https://"):
raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.")
@router.post("/posts", status_code=201)
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
from app.tiers import can_use
if not can_use("community_publish", session.tier, session.has_byok):
raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.")
_validate_publish_body(body)
store = _get_community_store()
if store is None:
raise HTTPException(
status_code=503,
detail="This Kiwi instance is not connected to a community database. "
"Publishing is only available on cloud instances.",
)
from app.services.community.community_store import get_or_create_pseudonym
def _get_pseudonym():
s = Store(session.db)
try:
return get_or_create_pseudonym(
store=s,
directus_user_id=session.user_id,
requested_name=body.get("pseudonym_name"),
)
finally:
s.close()
try:
pseudonym = await asyncio.to_thread(_get_pseudonym)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")]
from app.services.community.element_snapshot import compute_snapshot
def _snapshot():
s = Store(session.db)
try:
return compute_snapshot(recipe_ids=recipe_ids, store=s)
finally:
s.close()
snapshot = await asyncio.to_thread(_snapshot)
post_type = body.get("post_type", "plan")
slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-")
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
from circuitforge_core.community.models import CommunityPost
post = CommunityPost(
slug=slug,
pseudonym=pseudonym,
post_type=post_type,
published=datetime.now(timezone.utc),
title=(body.get("title") or "Untitled")[:_MAX_TITLE_LEN],
description=body.get("description"),
photo_url=body.get("photo_url"),
slots=body.get("slots", []),
recipe_id=body.get("recipe_id"),
recipe_name=body.get("recipe_name"),
level=body.get("level"),
outcome_notes=body.get("outcome_notes"),
seasoning_score=snapshot.seasoning_score,
richness_score=snapshot.richness_score,
brightness_score=snapshot.brightness_score,
depth_score=snapshot.depth_score,
aroma_score=snapshot.aroma_score,
structure_score=snapshot.structure_score,
texture_profile=snapshot.texture_profile,
dietary_tags=list(snapshot.dietary_tags),
allergen_flags=list(snapshot.allergen_flags),
flavor_molecules=list(snapshot.flavor_molecules),
fat_pct=snapshot.fat_pct,
protein_pct=snapshot.protein_pct,
moisture_pct=snapshot.moisture_pct,
)
try:
inserted = await asyncio.to_thread(store.insert_post, post)
except sqlite3.IntegrityError as exc:
raise HTTPException(
status_code=409,
detail="A post with this title already exists today. Try a different title.",
) from exc
return _post_to_dict(inserted)
@router.delete("/posts/{slug}", status_code=204)
async def delete_post(slug: str, session: CloudUser = Depends(get_session)):
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available.")
def _get_pseudonym():
s = Store(session.db)
try:
return s.get_current_pseudonym(session.user_id)
finally:
s.close()
pseudonym = await asyncio.to_thread(_get_pseudonym)
if not pseudonym:
raise HTTPException(status_code=400, detail="No pseudonym set. Cannot delete posts.")
deleted = await asyncio.to_thread(store.delete_post, slug=slug, pseudonym=pseudonym)
if not deleted:
raise HTTPException(status_code=404, detail="Post not found or you are not the author.")
@router.post("/posts/{slug}/fork", status_code=201)
async def fork_post(slug: str, session: CloudUser = Depends(get_session)):
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available.")
post = await asyncio.to_thread(store.get_post_by_slug, slug)
if post is None:
raise HTTPException(status_code=404, detail="Post not found.")
if post.post_type != "plan":
raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.")
required_slot_keys = {"day", "meal_type", "recipe_id"}
if any(not required_slot_keys.issubset(slot) for slot in post.slots):
raise HTTPException(status_code=400, detail="Post contains malformed slots and cannot be forked.")
from datetime import date
week_start = date.today().strftime("%Y-%m-%d")
def _create_plan():
s = Store(session.db)
try:
meal_types = list({slot["meal_type"] for slot in post.slots})
plan = s.create_meal_plan(week_start=week_start, meal_types=meal_types or ["dinner"])
for slot in post.slots:
s.assign_recipe_to_slot(
plan_id=plan["id"],
day_of_week=slot["day"],
meal_type=slot["meal_type"],
recipe_id=slot["recipe_id"],
)
return plan
finally:
s.close()
plan = await asyncio.to_thread(_create_plan)
return {"plan_id": plan["id"], "week_start": plan["week_start"], "forked_from": slug}
@router.post("/posts/{slug}/fork-adapt", status_code=201)
async def fork_adapt_post(slug: str, session: CloudUser = Depends(get_session)):
from app.tiers import can_use
if not can_use("community_fork_adapt", session.tier, session.has_byok):
raise HTTPException(status_code=402, detail="Fork with adaptation requires Paid tier or BYOK.")
# Stub: full LLM adaptation deferred
raise HTTPException(status_code=501, detail="Fork-adapt not yet implemented.")
def _post_to_dict(post) -> dict:
return {
"slug": post.slug,
"pseudonym": post.pseudonym,
"post_type": post.post_type,
"published": post.published.isoformat() if hasattr(post.published, "isoformat") else str(post.published),
"title": post.title,
"description": post.description,
"photo_url": post.photo_url,
"slots": list(post.slots),
"recipe_id": post.recipe_id,
"recipe_name": post.recipe_name,
"level": post.level,
"outcome_notes": post.outcome_notes,
"element_profiles": {
"seasoning_score": post.seasoning_score,
"richness_score": post.richness_score,
"brightness_score": post.brightness_score,
"depth_score": post.depth_score,
"aroma_score": post.aroma_score,
"structure_score": post.structure_score,
"texture_profile": post.texture_profile,
},
"dietary_tags": list(post.dietary_tags),
"allergen_flags": list(post.allergen_flags),
"flavor_molecules": list(post.flavor_molecules),
"fat_pct": post.fat_pct,
"protein_pct": post.protein_pct,
"moisture_pct": post.moisture_pct,
}
def _post_type_prefix(post_type: str) -> str:
return {"plan": "plan", "recipe_success": "success", "recipe_blooper": "blooper"}.get(post_type, "post")

View file

@ -0,0 +1,185 @@
"""Kiwi — /api/v1/imitate/samples endpoint for Avocet Imitate tab.
Returns the actual assembled prompt Kiwi sends to its LLM for recipe generation,
including the full pantry context (expiry-first ordering), dietary constraints
(from user_settings if present), and the Level 3 format instructions.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.cloud_session import get_session, CloudUser
from app.db.store import Store
router = APIRouter()
_LEVEL3_FORMAT = [
"",
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
"Title: <name of the dish>",
"Ingredients: <comma-separated list>",
"Directions:",
"1. <first step>",
"2. <second step>",
"3. <continue for each step>",
"Notes: <optional tips>",
]
_LEVEL4_FORMAT = [
"",
"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>",
]
def _read_user_settings(store: Store) -> dict:
"""Read all key/value pairs from user_settings table."""
try:
rows = store.conn.execute("SELECT key, value FROM user_settings").fetchall()
return {r["key"]: r["value"] for r in rows}
except Exception:
return {}
def _build_recipe_prompt(
pantry_names: list[str],
expiring_names: list[str],
constraints: list[str],
allergies: list[str],
level: int = 3,
) -> str:
"""Assemble the recipe generation prompt matching Kiwi's Level 3/4 format."""
# Expiring items first, then remaining pantry items (deduped)
expiring_set = set(expiring_names)
ordered = list(expiring_names) + [n for n in pantry_names if n not in expiring_set]
if not ordered:
ordered = pantry_names
if level == 4:
lines = [
"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(ordered)}",
]
if constraints:
lines.append(f"Constraints: {', '.join(constraints)}")
if allergies:
lines.append(f"Must NOT contain: {', '.join(allergies)}")
lines.append("Treat any mystery ingredient as a wildcard — use your imagination.")
lines += _LEVEL4_FORMAT
else:
lines = [
"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.",
"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(ordered)}",
]
if expiring_names:
lines.append(
f"Priority — use these soon (expiring): {', '.join(expiring_names)}"
)
if constraints:
lines.append(f"Dietary constraints: {', '.join(constraints)}")
if allergies:
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergies)}")
lines += _LEVEL3_FORMAT
return "\n".join(lines)
@router.get("/samples")
async def imitate_samples(
limit: int = 5,
level: int = 3,
session: CloudUser = Depends(get_session),
):
"""Return assembled recipe generation prompts for Avocet's Imitate tab.
Each sample includes:
system_prompt empty (Kiwi uses no system context)
input_text full Level 3/4 prompt with pantry items, expiring items,
dietary constraints, and format instructions
output_text empty (no prior LLM output stored per-request)
level: 3 (structured with element biasing context) or 4 (wildcard creative)
limit: max number of distinct prompt variants to return (varies by pantry state)
"""
limit = max(1, min(limit, 10))
store = Store(session.db)
# Full pantry for context
all_items = store.list_inventory()
pantry_names = [r["product_name"] for r in all_items if r.get("product_name")]
# Expiring items as priority ingredients
expiring = store.expiring_soon(days=14)
expiring_names = [r["product_name"] for r in expiring if r.get("product_name")]
# Dietary constraints from user_settings (keys: constraints, allergies)
settings = _read_user_settings(store)
import json as _json
try:
constraints = _json.loads(settings.get("dietary_constraints", "[]")) or []
except Exception:
constraints = []
try:
allergies = _json.loads(settings.get("dietary_allergies", "[]")) or []
except Exception:
allergies = []
if not pantry_names:
return {"samples": [], "total": 0, "type": f"recipe_level{level}"}
# Build prompt variants: one per expiring item as the "anchor" ingredient,
# plus one general pantry prompt. Cap at limit.
samples = []
seen_anchors: set[str] = set()
for item in (expiring[:limit - 1] if expiring else []):
anchor = item.get("product_name", "")
if not anchor or anchor in seen_anchors:
continue
seen_anchors.add(anchor)
# Put this item first in the list for the prompt
ordered_expiring = [anchor] + [n for n in expiring_names if n != anchor]
prompt = _build_recipe_prompt(pantry_names, ordered_expiring, constraints, allergies, level)
samples.append({
"id": item.get("id", 0),
"anchor_item": anchor,
"expiring_count": len(expiring_names),
"pantry_count": len(pantry_names),
"system_prompt": "",
"input_text": prompt,
"output_text": "",
})
# One general prompt using all expiring as priority
if len(samples) < limit:
prompt = _build_recipe_prompt(pantry_names, expiring_names, constraints, allergies, level)
samples.append({
"id": 0,
"anchor_item": "full pantry",
"expiring_count": len(expiring_names),
"pantry_count": len(pantry_names),
"system_prompt": "",
"input_text": prompt,
"output_text": "",
})
return {"samples": samples, "total": len(samples), "type": f"recipe_level{level}"}

View file

@ -9,7 +9,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from app.cloud_session import CloudUser, get_session
from app.db.store import Store
from app.models.schemas.recipe import RecipeRequest, RecipeResult
from app.models.schemas.recipe import (
AssemblyTemplateOut,
BuildRequest,
RecipeRequest,
RecipeResult,
RecipeSuggestion,
RoleCandidatesResponse,
)
from app.services.recipe.assembly_recipes import (
build_from_selection,
get_role_candidates,
get_templates_for_api,
)
from app.services.recipe.browser_domains import (
DOMAINS,
get_category_names,
@ -143,6 +155,96 @@ async def browse_recipes(
return await asyncio.to_thread(_browse, session.db)
@router.get("/templates", response_model=list[AssemblyTemplateOut])
async def list_assembly_templates() -> list[dict]:
"""Return all 13 assembly templates with ordered role sequences.
Cache-friendly: static data, no per-user state.
"""
return get_templates_for_api()
@router.get("/template-candidates", response_model=RoleCandidatesResponse)
async def get_template_role_candidates(
template_id: str = Query(..., description="Template slug, e.g. 'burrito_taco'"),
role: str = Query(..., description="Role display name, e.g. 'protein'"),
prior_picks: str = Query(default="", description="Comma-separated prior selections"),
session: CloudUser = Depends(get_session),
) -> dict:
"""Return pantry-matched candidates for one wizard step."""
def _get(db_path: Path) -> dict:
store = Store(db_path)
try:
items = store.list_inventory(status="available")
pantry_set = {
item["product_name"]
for item in items
if item.get("product_name")
}
pantry_list = list(pantry_set)
prior = [p.strip() for p in prior_picks.split(",") if p.strip()]
profile_index = store.get_element_profiles(pantry_list + prior)
return get_role_candidates(
template_slug=template_id,
role_display=role,
pantry_set=pantry_set,
prior_picks=prior,
profile_index=profile_index,
)
finally:
store.close()
return await asyncio.to_thread(_get, session.db)
@router.post("/build", response_model=RecipeSuggestion)
async def build_recipe(
req: BuildRequest,
session: CloudUser = Depends(get_session),
) -> RecipeSuggestion:
"""Build a recipe from explicit role selections."""
def _build(db_path: Path) -> RecipeSuggestion | None:
store = Store(db_path)
try:
items = store.list_inventory(status="available")
pantry_set = {
item["product_name"]
for item in items
if item.get("product_name")
}
suggestion = build_from_selection(
template_slug=req.template_id,
role_overrides=req.role_overrides,
pantry_set=pantry_set,
)
if suggestion is None:
return None
# Persist to recipes table so the result can be saved/bookmarked.
# external_id encodes template + selections for stable dedup.
import hashlib as _hl, json as _js
sel_hash = _hl.md5(
_js.dumps(req.role_overrides, sort_keys=True).encode()
).hexdigest()[:8]
external_id = f"assembly:{req.template_id}:{sel_hash}"
real_id = store.upsert_built_recipe(
external_id=external_id,
title=suggestion.title,
ingredients=suggestion.matched_ingredients,
directions=suggestion.directions,
)
return suggestion.model_copy(update={"id": real_id})
finally:
store.close()
result = await asyncio.to_thread(_build, session.db)
if result is None:
raise HTTPException(
status_code=404,
detail="Template not found or required ingredient missing.",
)
return result
@router.get("/{recipe_id}")
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
def _get(db_path: Path, rid: int) -> dict | None:

View file

@ -1,17 +1,20 @@
from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, meal_plans
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate, meal_plans
from app.api.endpoints.community import router as community_router
api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
api_router.include_router(export.router, tags=["export"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(household.router, prefix="/household", tags=["household"])
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
api_router.include_router(export.router, tags=["export"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(household.router, prefix="/household", tags=["household"])
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
api_router.include_router(community_router)

View file

@ -92,6 +92,7 @@ class CloudUser:
has_byok: bool # True if a configured LLM backend is present in llm.yaml
household_id: str | None = None
is_household_owner: bool = False
license_key: str | None = None # key_display for lifetime/founders keys; None for subscription/free
# ── JWT validation ─────────────────────────────────────────────────────────────
@ -132,16 +133,16 @@ def _ensure_provisioned(user_id: str) -> None:
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]:
"""Returns (tier, household_id | None, is_household_owner)."""
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool, str | None]:
"""Returns (tier, household_id | None, is_household_owner, license_key | None)."""
now = time.monotonic()
cached = _TIER_CACHE.get(user_id)
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
entry = cached[0]
return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False)
return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False), entry.get("license_key")
if not HEIMDALL_ADMIN_TOKEN:
return "free", None, False
return "free", None, False, None
try:
resp = requests.post(
f"{HEIMDALL_URL}/admin/cloud/resolve",
@ -153,12 +154,13 @@ def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]:
tier = data.get("tier", "free")
household_id = data.get("household_id")
is_owner = data.get("is_household_owner", False)
license_key = data.get("key_display")
except Exception as exc:
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
tier, household_id, is_owner = "free", None, False
tier, household_id, is_owner, license_key = "free", None, False, None
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner}, now)
return tier, household_id, is_owner
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner, "license_key": license_key}, now)
return tier, household_id, is_owner, license_key
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
@ -170,6 +172,13 @@ def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
return path
def _anon_db_path() -> Path:
"""Ephemeral DB for unauthenticated guest visitors (Free tier, no persistence)."""
path = CLOUD_DATA_ROOT / "anonymous" / "kiwi.db"
path.parent.mkdir(parents=True, exist_ok=True)
return path
# ── BYOK detection ────────────────────────────────────────────────────────────
_LLM_CONFIG_PATH = Path.home() / ".config" / "circuitforge" / "llm.yaml"
@ -225,15 +234,25 @@ def get_session(request: Request) -> CloudUser:
or request.headers.get("cookie", "")
)
if not raw_header:
raise HTTPException(status_code=401, detail="Not authenticated")
return CloudUser(
user_id="anonymous",
tier="free",
db=_anon_db_path(),
has_byok=has_byok,
)
token = _extract_session_token(raw_header) # gitleaks:allow — function name, not a secret
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
return CloudUser(
user_id="anonymous",
tier="free",
db=_anon_db_path(),
has_byok=has_byok,
)
user_id = validate_session_jwt(token)
_ensure_provisioned(user_id)
tier, household_id, is_household_owner = _fetch_cloud_tier(user_id)
tier, household_id, is_household_owner, license_key = _fetch_cloud_tier(user_id)
return CloudUser(
user_id=user_id,
tier=tier,
@ -241,6 +260,7 @@ def get_session(request: Request) -> CloudUser:
has_byok=has_byok,
household_id=household_id,
is_household_owner=is_household_owner,
license_key=license_key,
)

View file

@ -35,6 +35,16 @@ class Settings:
# Database
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
# Community feature settings
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
"COMMUNITY_PSEUDONYM_SALT", "kiwi-default-salt-change-in-prod"
)
COMMUNITY_CLOUD_FEED_URL: str = os.environ.get(
"COMMUNITY_CLOUD_FEED_URL",
"https://menagerie.circuitforge.tech/kiwi/api/v1/community/posts",
)
# Processing
MAX_CONCURRENT_JOBS: int = int(os.environ.get("MAX_CONCURRENT_JOBS", "4"))
USE_GPU: bool = os.environ.get("USE_GPU", "true").lower() in ("1", "true", "yes")

View file

@ -0,0 +1,5 @@
-- Migration 022: Add is_generic flag to recipes
-- Generic recipes are catch-all/dump recipes with loose ingredient lists
-- that should not appear in Level 1 (deterministic "use what I have") results.
-- Admins can mark recipes via the recipe editor or a bulk backfill script.
ALTER TABLE recipes ADD COLUMN is_generic INTEGER NOT NULL DEFAULT 0;

View file

@ -0,0 +1,21 @@
-- 028_community_pseudonyms.sql
-- Per-user pseudonym store: maps the user's chosen community display name
-- to their Directus user ID. This table lives in per-user kiwi.db only.
-- It is NEVER replicated to the community PostgreSQL — pseudonym isolation is by design.
--
-- A user may have one active pseudonym. Old pseudonyms are retained for reference
-- (posts published under them keep their pseudonym attribution) but only one is
-- flagged as current (is_current = 1).
CREATE TABLE IF NOT EXISTS community_pseudonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pseudonym TEXT NOT NULL,
directus_user_id TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Only one pseudonym can be current at a time per user
CREATE UNIQUE INDEX IF NOT EXISTS idx_community_pseudonyms_current
ON community_pseudonyms (directus_user_id)
WHERE is_current = 1;

View file

@ -0,0 +1,49 @@
-- Migration 029: Add inferred_tags column and update FTS index to include it.
--
-- inferred_tags holds a JSON array of normalized tag strings derived by
-- scripts/pipeline/infer_recipe_tags.py (e.g. ["cuisine:Italian",
-- "dietary:Low-Carb", "flavor:Umami", "can_be:Gluten-Free"]).
--
-- The FTS5 browser table is rebuilt to index inferred_tags alongside
-- category and keywords so browse domain queries match against all signals.
-- 1. Add inferred_tags column (empty array default; populated by pipeline run)
ALTER TABLE recipes ADD COLUMN inferred_tags TEXT NOT NULL DEFAULT '[]';
-- 2. Drop old FTS table and triggers that only covered category + keywords
DROP TRIGGER IF EXISTS recipes_ai;
DROP TRIGGER IF EXISTS recipes_ad;
DROP TRIGGER IF EXISTS recipes_au;
DROP TABLE IF EXISTS recipe_browser_fts;
-- 3. Recreate FTS5 table: now indexes category, keywords, AND inferred_tags
CREATE VIRTUAL TABLE recipe_browser_fts USING fts5(
category,
keywords,
inferred_tags,
content=recipes,
content_rowid=id
);
-- 4. Triggers to keep FTS in sync with recipes table changes
CREATE TRIGGER recipes_ai AFTER INSERT ON recipes BEGIN
INSERT INTO recipe_browser_fts(rowid, category, keywords, inferred_tags)
VALUES (new.id, new.category, new.keywords, new.inferred_tags);
END;
CREATE TRIGGER recipes_ad AFTER DELETE ON recipes BEGIN
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords, inferred_tags)
VALUES ('delete', old.id, old.category, old.keywords, old.inferred_tags);
END;
CREATE TRIGGER recipes_au AFTER UPDATE ON recipes BEGIN
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords, inferred_tags)
VALUES ('delete', old.id, old.category, old.keywords, old.inferred_tags);
INSERT INTO recipe_browser_fts(rowid, category, keywords, inferred_tags)
VALUES (new.id, new.category, new.keywords, new.inferred_tags);
END;
-- 5. Populate FTS from current table state
-- (inferred_tags is '[]' for all rows at this point; run infer_recipe_tags.py
-- to populate, then the FTS will be rebuilt as part of that script.)
INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild');

View file

@ -573,6 +573,7 @@ class Store:
max_carbs_g: float | None = None,
max_sodium_mg: float | None = None,
excluded_ids: list[int] | None = None,
exclude_generic: bool = False,
) -> list[dict]:
"""Find recipes containing any of the given ingredient names.
Scores by match count and returns highest-scoring first.
@ -582,6 +583,9 @@ class Store:
Nutrition filters use NULL-passthrough: rows without nutrition data
always pass (they may be estimated or absent entirely).
exclude_generic: when True, skips recipes marked is_generic=1.
Pass True for Level 1 ("Use What I Have") to suppress catch-all recipes.
"""
if not ingredient_names:
return []
@ -607,6 +611,8 @@ class Store:
placeholders = ",".join("?" * len(excluded_ids))
extra_clauses.append(f"r.id NOT IN ({placeholders})")
extra_params.extend(excluded_ids)
if exclude_generic:
extra_clauses.append("r.is_generic = 0")
where_extra = (" AND " + " AND ".join(extra_clauses)) if extra_clauses else ""
if self._fts_ready():
@ -682,6 +688,67 @@ class Store:
def get_recipe(self, recipe_id: int) -> dict | None:
return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
def upsert_built_recipe(
self,
external_id: str,
title: str,
ingredients: list[str],
directions: list[str],
) -> int:
"""Persist an assembly-built recipe and return its DB id.
Uses external_id as a stable dedup key so the same build slug doesn't
accumulate duplicate rows across multiple user sessions.
"""
import json as _json
self.conn.execute(
"""
INSERT OR IGNORE INTO recipes
(external_id, title, ingredients, ingredient_names, directions, source)
VALUES (?, ?, ?, ?, ?, 'assembly')
""",
(
external_id,
title,
_json.dumps(ingredients),
_json.dumps(ingredients),
_json.dumps(directions),
),
)
# Update title in case the build was re-run with tweaked selections
self.conn.execute(
"UPDATE recipes SET title = ? WHERE external_id = ?",
(title, external_id),
)
self.conn.commit()
row = self._fetch_one(
"SELECT id FROM recipes WHERE external_id = ?", (external_id,)
)
return row["id"] # type: ignore[index]
def get_element_profiles(self, names: list[str]) -> dict[str, list[str]]:
"""Return {ingredient_name: [element_tag, ...]} for the given names.
Only names present in ingredient_profiles are returned -- missing names
are silently omitted so callers can distinguish "no profile" from "empty
elements list".
"""
if not names:
return {}
placeholders = ",".join("?" * len(names))
rows = self._fetch_all(
f"SELECT name, elements FROM ingredient_profiles WHERE name IN ({placeholders})",
tuple(names),
)
result: dict[str, list[str]] = {}
for row in rows:
try:
elements = json.loads(row["elements"]) if row["elements"] else []
except (json.JSONDecodeError, TypeError):
elements = []
result[row["name"]] = elements
return result
# ── rate limits ───────────────────────────────────────────────────────
def check_and_increment_rate_limit(
@ -1128,3 +1195,31 @@ class Store:
)
self.conn.commit()
return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,))
# ── community ─────────────────────────────────────────────────────────
def get_current_pseudonym(self, directus_user_id: str) -> str | None:
"""Return the current community pseudonym for this user, or None if not set."""
cur = self.conn.execute(
"SELECT pseudonym FROM community_pseudonyms "
"WHERE directus_user_id = ? AND is_current = 1 LIMIT 1",
(directus_user_id,),
)
row = cur.fetchone()
return row["pseudonym"] if row else None
def set_pseudonym(self, directus_user_id: str, pseudonym: str) -> None:
"""Set the current community pseudonym for this user.
Marks any previous pseudonym as non-current (retains history for attribution).
"""
self.conn.execute(
"UPDATE community_pseudonyms SET is_current = 0 WHERE directus_user_id = ?",
(directus_user_id,),
)
self.conn.execute(
"INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) "
"VALUES (?, ?, 1)",
(pseudonym, directus_user_id),
)
self.conn.commit()

View file

@ -25,6 +25,10 @@ async def lifespan(app: FastAPI):
get_scheduler(settings.DB_PATH)
logger.info("Task scheduler started.")
# Initialize community store (no-op if COMMUNITY_DB_URL is not set)
from app.api.endpoints.community import init_community_store
init_community_store(settings.COMMUNITY_DB_URL)
yield
# Graceful scheduler shutdown

View file

@ -56,6 +56,7 @@ class RecipeResult(BaseModel):
grocery_links: list[GroceryLink] = Field(default_factory=list)
rate_limited: bool = False
rate_limit_count: int = 0
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
class NutritionFilters(BaseModel):
@ -82,3 +83,48 @@ class RecipeRequest(BaseModel):
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
excluded_ids: list[int] = Field(default_factory=list)
shopping_mode: bool = False
# ── Build Your Own schemas ──────────────────────────────────────────────────
class AssemblyRoleOut(BaseModel):
"""One role slot in a template, as returned by GET /api/recipes/templates."""
display: str
required: bool
keywords: list[str]
hint: str = ""
class AssemblyTemplateOut(BaseModel):
"""One assembly template, as returned by GET /api/recipes/templates."""
id: str # slug, e.g. "burrito_taco"
title: str
icon: str
descriptor: str
role_sequence: list[AssemblyRoleOut]
class RoleCandidateItem(BaseModel):
"""One candidate ingredient for a wizard picker step."""
name: str
in_pantry: bool
tags: list[str] = Field(default_factory=list)
class RoleCandidatesResponse(BaseModel):
"""Response from GET /api/recipes/template-candidates."""
compatible: list[RoleCandidateItem] = Field(default_factory=list)
other: list[RoleCandidateItem] = Field(default_factory=list)
available_tags: list[str] = Field(default_factory=list)
class BuildRequest(BaseModel):
"""Request body for POST /api/recipes/build."""
template_id: str
role_overrides: dict[str, str] = Field(default_factory=dict)

View file

View file

@ -0,0 +1,44 @@
# app/services/community/ap_compat.py
# MIT License — AP scaffold only (no actor, inbox, outbox)
from __future__ import annotations
from datetime import datetime, timezone
def post_to_ap_json_ld(post: dict, base_url: str) -> dict:
"""Serialize a community post dict to an ActivityPub-compatible JSON-LD Note.
This is a read-only scaffold. No AP actor, inbox, or outbox.
The slug URI is stable so a future full AP implementation can reuse posts
without a DB migration.
"""
slug = post["slug"]
published = post.get("published")
if isinstance(published, datetime):
published_str = published.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
else:
published_str = str(published)
dietary_tags: list[str] = post.get("dietary_tags") or []
tags = [{"type": "Hashtag", "name": "#kiwi"}]
for tag in dietary_tags:
tags.append({"type": "Hashtag", "name": f"#{tag.replace('-', '').replace(' ', '')}"})
return {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"id": f"{base_url}/api/v1/community/posts/{slug}",
"attributedTo": post.get("pseudonym", "anonymous"),
"content": _build_content(post),
"published": published_str,
"tag": tags,
}
def _build_content(post: dict) -> str:
title = post.get("title") or "Untitled"
desc = post.get("description")
if desc:
return f"{title}{desc}"
return title

View file

@ -0,0 +1,90 @@
# app/services/community/community_store.py
# MIT License
from __future__ import annotations
import logging
from circuitforge_core.community import CommunityPost, SharedStore
logger = logging.getLogger(__name__)
class KiwiCommunityStore(SharedStore):
"""Kiwi-specific community store: adds kiwi-domain query methods on top of SharedStore."""
def list_meal_plans(
self,
limit: int = 20,
offset: int = 0,
dietary_tags: list[str] | None = None,
allergen_exclude: list[str] | None = None,
) -> list[CommunityPost]:
return self.list_posts(
limit=limit,
offset=offset,
post_type="plan",
dietary_tags=dietary_tags,
allergen_exclude=allergen_exclude,
source_product="kiwi",
)
def list_outcomes(
self,
limit: int = 20,
offset: int = 0,
post_type: str | None = None,
) -> list[CommunityPost]:
if post_type in ("recipe_success", "recipe_blooper"):
return self.list_posts(
limit=limit,
offset=offset,
post_type=post_type,
source_product="kiwi",
)
success = self.list_posts(
limit=limit,
offset=0,
post_type="recipe_success",
source_product="kiwi",
)
bloopers = self.list_posts(
limit=limit,
offset=0,
post_type="recipe_blooper",
source_product="kiwi",
)
merged = sorted(success + bloopers, key=lambda p: p.published, reverse=True)
return merged[:limit]
def get_or_create_pseudonym(
store,
directus_user_id: str,
requested_name: str | None,
) -> str:
"""Return the user's current pseudonym, creating it if it doesn't exist.
If the user has an existing pseudonym, return it (ignore requested_name).
If not, create using requested_name (must be provided for first-time setup).
Raises ValueError if no existing pseudonym and requested_name is None or blank.
"""
existing = store.get_current_pseudonym(directus_user_id)
if existing:
return existing
if not requested_name or not requested_name.strip():
raise ValueError(
"A pseudonym is required for first publish. "
"Pass requested_name with the user's chosen display name."
)
name = requested_name.strip()
if "@" in name:
raise ValueError(
"Pseudonym must not contain '@' — use a display name, not an email address."
)
store.set_pseudonym(directus_user_id, name)
return name

View file

@ -0,0 +1,138 @@
# app/services/community/element_snapshot.py
# MIT License
from __future__ import annotations
from dataclasses import dataclass
# Ingredient name substrings → allergen flag
_ALLERGEN_MAP: dict[str, str] = {
"milk": "dairy", "cream": "dairy", "cheese": "dairy", "butter": "dairy",
"yogurt": "dairy", "whey": "dairy",
"egg": "eggs",
"wheat": "gluten", "pasta": "gluten", "flour": "gluten", "bread": "gluten",
"barley": "gluten", "rye": "gluten",
"peanut": "nuts", "almond": "nuts", "cashew": "nuts", "walnut": "nuts",
"pecan": "nuts", "hazelnut": "nuts", "pistachio": "nuts", "macadamia": "nuts",
"soy": "soy", "tofu": "soy", "edamame": "soy", "miso": "soy", "tempeh": "soy",
"shrimp": "shellfish", "crab": "shellfish", "lobster": "shellfish",
"clam": "shellfish", "mussel": "shellfish", "scallop": "shellfish",
"fish": "fish", "salmon": "fish", "tuna": "fish", "cod": "fish",
"tilapia": "fish", "halibut": "fish",
"sesame": "sesame",
}
_MEAT_KEYWORDS = frozenset([
"chicken", "beef", "pork", "lamb", "turkey", "bacon", "ham", "sausage",
"salami", "prosciutto", "guanciale", "pancetta", "steak", "ground meat",
"mince", "veal", "duck", "venison", "bison", "lard",
])
_SEAFOOD_KEYWORDS = frozenset([
"fish", "shrimp", "crab", "lobster", "tuna", "salmon", "clam", "mussel",
"scallop", "anchovy", "sardine", "cod", "tilapia",
])
_ANIMAL_PRODUCT_KEYWORDS = frozenset([
"milk", "cream", "cheese", "butter", "egg", "honey", "yogurt", "whey",
])
def _detect_allergens(ingredient_names: list[str]) -> list[str]:
found: set[str] = set()
lowered = [n.lower() for n in ingredient_names]
for ingredient in lowered:
for keyword, flag in _ALLERGEN_MAP.items():
if keyword in ingredient:
found.add(flag)
return sorted(found)
def _detect_dietary_tags(ingredient_names: list[str]) -> list[str]:
lowered = [n.lower() for n in ingredient_names]
all_text = " ".join(lowered)
has_meat = any(k in all_text for k in _MEAT_KEYWORDS)
has_seafood = any(k in all_text for k in _SEAFOOD_KEYWORDS)
has_animal_products = any(k in all_text for k in _ANIMAL_PRODUCT_KEYWORDS)
tags: list[str] = []
if not has_meat and not has_seafood:
tags.append("vegetarian")
if not has_meat and not has_seafood and not has_animal_products:
tags.append("vegan")
return tags
@dataclass(frozen=True)
class ElementSnapshot:
seasoning_score: float
richness_score: float
brightness_score: float
depth_score: float
aroma_score: float
structure_score: float
texture_profile: str
dietary_tags: tuple
allergen_flags: tuple
flavor_molecules: tuple
fat_pct: float | None
protein_pct: float | None
moisture_pct: float | None
def compute_snapshot(recipe_ids: list[int], store) -> ElementSnapshot:
"""Compute an element snapshot from a list of recipe IDs.
Pulls SFAH scores, ingredient lists, and USDA FDC macros from the corpus.
Averages numeric scores across all recipes. Unions allergen flags and dietary tags.
Call at publish time only snapshot is stored denormalized in community_posts.
"""
if not recipe_ids:
return ElementSnapshot(
seasoning_score=0.0, richness_score=0.0, brightness_score=0.0,
depth_score=0.0, aroma_score=0.0, structure_score=0.0,
texture_profile="", dietary_tags=(), allergen_flags=(),
flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None,
)
rows = store.get_recipes_by_ids(recipe_ids)
if not rows:
return ElementSnapshot(
seasoning_score=0.0, richness_score=0.0, brightness_score=0.0,
depth_score=0.0, aroma_score=0.0, structure_score=0.0,
texture_profile="", dietary_tags=(), allergen_flags=(),
flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None,
)
def _avg(field: str) -> float:
vals = [r.get(field) or 0.0 for r in rows]
return sum(vals) / len(vals)
all_ingredients: list[str] = []
for r in rows:
names = r.get("ingredient_names") or []
all_ingredients.extend(names if isinstance(names, list) else [])
allergens = _detect_allergens(all_ingredients)
dietary = _detect_dietary_tags(all_ingredients)
texture = rows[0].get("texture_profile") or ""
fat_vals = [r.get("fat") for r in rows if r.get("fat") is not None]
prot_vals = [r.get("protein") for r in rows if r.get("protein") is not None]
moist_vals = [r.get("moisture") for r in rows if r.get("moisture") is not None]
return ElementSnapshot(
seasoning_score=_avg("seasoning_score"),
richness_score=_avg("richness_score"),
brightness_score=_avg("brightness_score"),
depth_score=_avg("depth_score"),
aroma_score=_avg("aroma_score"),
structure_score=_avg("structure_score"),
texture_profile=texture,
dietary_tags=tuple(dietary),
allergen_flags=tuple(allergens),
flavor_molecules=(),
fat_pct=(sum(fat_vals) / len(fat_vals)) if fat_vals else None,
protein_pct=(sum(prot_vals) / len(prot_vals)) if prot_vals else None,
moisture_pct=(sum(moist_vals) / len(moist_vals)) if moist_vals else None,
)

View file

@ -0,0 +1,43 @@
# app/services/community/feed.py
# MIT License
from __future__ import annotations
from datetime import datetime, timezone
from email.utils import format_datetime
from xml.etree.ElementTree import Element, SubElement, tostring
def posts_to_rss(posts: list[dict], base_url: str) -> str:
"""Generate an RSS 2.0 feed from a list of community post dicts.
base_url: the root URL of this Kiwi instance (no trailing slash).
Returns UTF-8 XML string.
"""
rss = Element("rss", version="2.0")
channel = SubElement(rss, "channel")
_sub(channel, "title", "Kiwi Community Feed")
_sub(channel, "link", f"{base_url}/community")
_sub(channel, "description", "Meal plans and recipe outcomes from the Kiwi community")
_sub(channel, "language", "en")
_sub(channel, "lastBuildDate", format_datetime(datetime.now(timezone.utc)))
for post in posts:
item = SubElement(channel, "item")
_sub(item, "title", post.get("title") or "Untitled")
_sub(item, "link", f"{base_url}/api/v1/community/posts/{post['slug']}")
_sub(item, "guid", f"{base_url}/api/v1/community/posts/{post['slug']}")
if post.get("description"):
_sub(item, "description", post["description"])
published = post.get("published")
if isinstance(published, datetime):
_sub(item, "pubDate", format_datetime(published))
return '<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(rss, encoding="unicode")
def _sub(parent: Element, tag: str, text: str) -> Element:
el = SubElement(parent, tag)
el.text = text
return el

View file

@ -0,0 +1,72 @@
# app/services/community/mdns.py
# MIT License
from __future__ import annotations
import logging
import socket
logger = logging.getLogger(__name__)
# Import deferred to avoid hard failure when zeroconf is not installed
try:
from zeroconf import ServiceInfo, Zeroconf
_ZEROCONF_AVAILABLE = True
except ImportError:
_ZEROCONF_AVAILABLE = False
class KiwiMDNS:
"""Advertise this Kiwi instance on the LAN via mDNS (_kiwi._tcp.local).
Defaults to disabled (enabled=False). User must explicitly opt in via the
Settings page. This matches the CF a11y requirement: no surprise broadcasting.
Usage:
mdns = KiwiMDNS(enabled=settings.MDNS_ENABLED, port=settings.PORT,
feed_url=f"http://{hostname}:{settings.PORT}/api/v1/community/local-feed")
mdns.start() # in lifespan startup
mdns.stop() # in lifespan shutdown
"""
SERVICE_TYPE = "_kiwi._tcp.local."
def __init__(self, enabled: bool, port: int, feed_url: str) -> None:
self._enabled = enabled
self._port = port
self._feed_url = feed_url
self._zc: "Zeroconf | None" = None
self._info: "ServiceInfo | None" = None
def start(self) -> None:
if not self._enabled:
logger.debug("mDNS advertisement disabled (user has not opted in)")
return
if not _ZEROCONF_AVAILABLE:
logger.warning("zeroconf package not installed — mDNS advertisement unavailable")
return
hostname = socket.gethostname()
service_name = f"kiwi-{hostname}.{self.SERVICE_TYPE}"
self._info = ServiceInfo(
type_=self.SERVICE_TYPE,
name=service_name,
port=self._port,
properties={
b"feed_url": self._feed_url.encode(),
b"version": b"1",
},
addresses=[socket.inet_aton("127.0.0.1")],
)
self._zc = Zeroconf()
self._zc.register_service(self._info)
logger.info("mDNS: advertising %s on port %d", service_name, self._port)
def stop(self) -> None:
if self._zc is None or self._info is None:
return
self._zc.unregister_service(self._info)
self._zc.close()
self._zc = None
self._info = None
logger.info("mDNS: advertisement stopped")

View file

@ -42,11 +42,21 @@ class AssemblyRole:
class AssemblyTemplate:
"""A template assembly dish."""
id: int
slug: str # URL-safe identifier, e.g. "burrito_taco"
icon: str # emoji
descriptor: str # one-line description shown in template grid
title: str
required: list[AssemblyRole]
optional: list[AssemblyRole]
directions: list[str]
notes: str = ""
# Per-role hints shown in the wizard picker header
# keys match role.display values; missing keys fall back to ""
role_hints: dict[str, str] = None # type: ignore[assignment]
def __post_init__(self) -> None:
if self.role_hints is None:
self.role_hints = {}
def _matches_role(role: AssemblyRole, pantry_set: set[str]) -> list[str]:
@ -138,6 +148,9 @@ def _personalized_title(tmpl: AssemblyTemplate, pantry_set: set[str], seed: int)
ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
AssemblyTemplate(
id=-1,
slug="burrito_taco",
icon="🌯",
descriptor="Protein, veg, and sauce in a tortilla or over rice",
title="Burrito / Taco",
required=[
AssemblyRole("tortilla or wrap", [
@ -170,9 +183,21 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"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).",
role_hints={
"tortilla or wrap": "The foundation -- what holds everything",
"protein": "The main filling",
"rice or starch": "Optional base layer",
"cheese": "Optional -- melts into the filling",
"salsa or sauce": "Optional -- adds moisture and heat",
"sour cream or yogurt": "Optional -- cool contrast to heat",
"vegetables": "Optional -- adds texture and colour",
},
),
AssemblyTemplate(
id=-2,
slug="fried_rice",
icon="🍳",
descriptor="Rice + egg + whatever's in the fridge",
title="Fried Rice",
required=[
AssemblyRole("cooked rice", [
@ -205,9 +230,21 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"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.",
role_hints={
"cooked rice": "Day-old cold rice works best",
"protein": "Pre-cooked or raw -- cook before adding rice",
"soy sauce or seasoning": "The primary flavour driver",
"oil": "High smoke-point oil for high heat",
"egg": "Scrambled in the same pan",
"vegetables": "Add crunch and colour",
"garlic or ginger": "Aromatic base -- add first",
},
),
AssemblyTemplate(
id=-3,
slug="omelette_scramble",
icon="🥚",
descriptor="Eggs with fillings, pan-cooked",
title="Omelette / Scramble",
required=[
AssemblyRole("eggs", ["egg"]),
@ -238,9 +275,19 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Season and serve immediately.",
],
notes="Works for breakfast, lunch, or a quick dinner. Any leftover vegetables work well.",
role_hints={
"eggs": "The base -- beat with a splash of water",
"cheese": "Fold in just before serving",
"vegetables": "Saute first, then add eggs",
"protein": "Cook through before adding eggs",
"herbs or seasoning": "Season at the end",
},
),
AssemblyTemplate(
id=-4,
slug="stir_fry",
icon="🥢",
descriptor="High-heat protein + veg in sauce",
title="Stir Fry",
required=[
AssemblyRole("vegetables", [
@ -271,9 +318,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Serve over rice or noodles.",
],
notes="High heat is the key. Do not crowd the pan -- cook in batches if needed.",
role_hints={
"vegetables": "Cut to similar size for even cooking",
"starch base": "Serve under or toss with the stir fry",
"protein": "Cook first, remove, add back at end",
"sauce": "Add last -- toss for 1-2 minutes only",
"garlic or ginger": "Add early for aromatic base",
"oil": "High smoke-point oil only",
},
),
AssemblyTemplate(
id=-5,
slug="pasta",
icon="🍝",
descriptor="Pantry pasta with flexible sauce",
title="Pasta with Whatever You Have",
required=[
AssemblyRole("pasta", [
@ -307,9 +365,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Toss cooked pasta with sauce. Finish with cheese if using.",
],
notes="Pasta water is the secret -- the starch thickens and binds any sauce.",
role_hints={
"pasta": "The base -- cook al dente, reserve pasta water",
"sauce base": "Simmer 5 min; pasta water loosens it",
"protein": "Cook through before adding sauce",
"cheese": "Finish off heat to avoid graininess",
"vegetables": "Saute until tender before adding sauce",
"garlic": "Saute in oil first -- the flavour foundation",
},
),
AssemblyTemplate(
id=-6,
slug="sandwich_wrap",
icon="🥪",
descriptor="Protein + veg between bread or in a wrap",
title="Sandwich / Wrap",
required=[
AssemblyRole("bread or wrap", [
@ -341,9 +410,19 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Press together and cut diagonally.",
],
notes="Leftovers, deli meat, canned fish -- nearly anything works between bread.",
role_hints={
"bread or wrap": "Toast for better texture",
"protein": "Layer on first after condiments",
"cheese": "Goes on top of protein",
"condiment": "Spread on both inner surfaces",
"vegetables": "Top layer -- keeps bread from getting soggy",
},
),
AssemblyTemplate(
id=-7,
slug="grain_bowl",
icon="🥗",
descriptor="Grain base + protein + toppings + dressing",
title="Grain Bowl",
required=[
AssemblyRole("grain base", [
@ -377,9 +456,19 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Drizzle with dressing and add toppings.",
],
notes="Great for meal prep -- cook grains and proteins in bulk, assemble bowls all week.",
role_hints={
"grain base": "Season while cooking -- bland grains sink the bowl",
"protein": "Slice or shred; arrange on top",
"vegetables": "Roast or saute for best flavour",
"dressing or sauce": "Drizzle last -- ties everything together",
"toppings": "Add crunch and contrast",
},
),
AssemblyTemplate(
id=-8,
slug="soup_stew",
icon="🥣",
descriptor="Liquid-based, flexible ingredients",
title="Soup / Stew",
required=[
# Narrow to dedicated soup bases — tomato sauce and coconut milk are
@ -415,9 +504,19 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"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.",
role_hints={
"broth or stock": "The liquid base -- determines overall flavour",
"protein": "Brown first for deeper flavour",
"vegetables": "Dense veg first; quick-cooking veg last",
"starch thickener": "Adds body and turns soup into stew",
"seasoning": "Taste and adjust after 20 min simmer",
},
),
AssemblyTemplate(
id=-9,
slug="casserole_bake",
icon="🫙",
descriptor="Oven bake with protein, veg, starch",
title="Casserole / Bake",
required=[
AssemblyRole("starch or base", [
@ -457,9 +556,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"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.",
role_hints={
"starch or base": "Cook slightly underdone -- finishes in oven",
"binder or sauce": "Coats everything and holds the bake together",
"protein": "Pre-cook before mixing in",
"vegetables": "Chop small for even distribution",
"cheese topping": "Goes on last -- browns in the final 15 min",
"seasoning": "Casseroles need more salt than you think",
},
),
AssemblyTemplate(
id=-10,
slug="pancakes_quickbread",
icon="🥞",
descriptor="Batter-based; sweet or savory",
title="Pancakes / Waffles / Quick Bread",
required=[
AssemblyRole("flour or baking mix", [
@ -495,9 +605,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"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.",
role_hints={
"flour or baking mix": "Whisk dry ingredients together first",
"leavening or egg": "Activates rise -- don't skip",
"liquid": "Add to dry ingredients; lumps are fine",
"fat": "Adds richness and prevents sticking",
"sweetener": "Mix into wet ingredients",
"mix-ins": "Fold in last -- gently",
},
),
AssemblyTemplate(
id=-11,
slug="porridge_oatmeal",
icon="🌾",
descriptor="Oat or grain base with toppings",
title="Porridge / Oatmeal",
required=[
AssemblyRole("oats or grain porridge", [
@ -520,9 +641,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Top with fruit, nuts, or seeds and serve immediately.",
],
notes="Overnight oats: skip cooking — soak oats in cold milk overnight in the fridge.",
role_hints={
"oats or grain porridge": "1 part oats to 2 parts liquid",
"liquid": "Use milk for creamier result",
"sweetener": "Stir in after cooking",
"fruit": "Add fresh on top or simmer dried fruit in",
"toppings": "Add last for crunch",
"spice": "Stir in with sweetener",
},
),
AssemblyTemplate(
id=-12,
slug="pie_pot_pie",
icon="🥧",
descriptor="Pastry or biscuit crust with filling",
title="Pie / Pot Pie",
required=[
AssemblyRole("pastry or crust", [
@ -561,9 +693,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"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.",
role_hints={
"pastry or crust": "Thaw puff pastry overnight in fridge",
"protein filling": "Cook through before adding to filling",
"vegetables": "Chop small; cook until just tender",
"sauce or binder": "Holds the filling together in the crust",
"seasoning": "Fillings need generous seasoning",
"sweet filling": "For dessert pies -- fruit + sugar",
},
),
AssemblyTemplate(
id=-13,
slug="pudding_custard",
icon="🍮",
descriptor="Dairy-based set dessert",
title="Pudding / Custard",
required=[
AssemblyRole("dairy or dairy-free milk", [
@ -601,10 +744,58 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"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.",
role_hints={
"dairy or dairy-free milk": "Heat until steaming before adding to eggs",
"thickener or set": "Cornstarch for stovetop; eggs for baked custard",
"sweetener or flavouring": "Signals dessert intent -- required",
"sweetener": "Adjust to taste",
"flavouring": "Add off-heat to preserve aroma",
"starchy base": "For bread pudding or rice pudding",
"fruit": "Layer in or fold through before setting",
},
),
]
# Slug to template lookup (built once at import time)
_TEMPLATE_BY_SLUG: dict[str, AssemblyTemplate] = {
t.slug: t for t in ASSEMBLY_TEMPLATES
}
def get_templates_for_api() -> list[dict]:
"""Serialise all 13 templates for GET /api/recipes/templates.
Combines required and optional roles into a single ordered role_sequence
with required roles first.
"""
out = []
for tmpl in ASSEMBLY_TEMPLATES:
roles = []
for role in tmpl.required:
roles.append({
"display": role.display,
"required": True,
"keywords": role.keywords,
"hint": tmpl.role_hints.get(role.display, ""),
})
for role in tmpl.optional:
roles.append({
"display": role.display,
"required": False,
"keywords": role.keywords,
"hint": tmpl.role_hints.get(role.display, ""),
})
out.append({
"id": tmpl.slug,
"title": tmpl.title,
"icon": tmpl.icon,
"descriptor": tmpl.descriptor,
"role_sequence": roles,
})
return out
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@ -679,3 +870,148 @@ def match_assembly_templates(
# Sort by optional coverage descending — best-matched templates first
results.sort(key=lambda s: s.match_count, reverse=True)
return results
def get_role_candidates(
template_slug: str,
role_display: str,
pantry_set: set[str],
prior_picks: list[str],
profile_index: dict[str, list[str]],
) -> dict:
"""Return ingredient candidates for one wizard step.
Splits candidates into 'compatible' (element overlap with prior picks)
and 'other' (valid for role but no overlap).
profile_index: {ingredient_name: [element_tag, ...]} -- pre-loaded from
Store.get_element_profiles() by the caller so this function stays DB-free.
Returns {"compatible": [...], "other": [...], "available_tags": [...]}
where each item is {"name": str, "in_pantry": bool, "tags": [str]}.
"""
tmpl = _TEMPLATE_BY_SLUG.get(template_slug)
if tmpl is None:
return {"compatible": [], "other": [], "available_tags": []}
# Find the AssemblyRole for this display name
target_role: AssemblyRole | None = None
for role in tmpl.required + tmpl.optional:
if role.display == role_display:
target_role = role
break
if target_role is None:
return {"compatible": [], "other": [], "available_tags": []}
# Build prior-pick element set for compatibility scoring
prior_elements: set[str] = set()
for pick in prior_picks:
prior_elements.update(profile_index.get(pick, []))
# Find pantry items that match this role
pantry_matches = _matches_role(target_role, pantry_set)
# Build keyword-based "other" candidates from role keywords not in pantry
pantry_lower = {p.lower() for p in pantry_set}
other_names: list[str] = []
for kw in target_role.keywords:
if not any(kw in item.lower() for item in pantry_lower):
if len(kw) >= 4:
other_names.append(kw.title())
def _make_item(name: str, in_pantry: bool) -> dict:
tags = profile_index.get(name, profile_index.get(name.lower(), []))
return {"name": name, "in_pantry": in_pantry, "tags": tags}
# Score: compatible if shares any element with prior picks (or no prior picks yet)
compatible: list[dict] = []
other: list[dict] = []
for name in pantry_matches:
item_elements = set(profile_index.get(name, []))
item = _make_item(name, in_pantry=True)
if not prior_elements or item_elements & prior_elements:
compatible.append(item)
else:
other.append(item)
for name in other_names:
other.append(_make_item(name, in_pantry=False))
# available_tags: union of all tags in the full candidate set
all_tags: set[str] = set()
for item in compatible + other:
all_tags.update(item["tags"])
return {
"compatible": compatible,
"other": other,
"available_tags": sorted(all_tags),
}
def build_from_selection(
template_slug: str,
role_overrides: dict[str, str],
pantry_set: set[str],
) -> "RecipeSuggestion | None":
"""Build a RecipeSuggestion from explicit role selections.
role_overrides: {role.display -> chosen pantry item name}
Returns None if template not found or any required role is uncovered.
"""
tmpl = _TEMPLATE_BY_SLUG.get(template_slug)
if tmpl is None:
return None
seed = _pantry_hash(pantry_set)
# Validate required roles: covered by override OR pantry match
matched_required: list[str] = []
for role in tmpl.required:
chosen = role_overrides.get(role.display)
if chosen:
matched_required.append(chosen)
else:
hits = _matches_role(role, pantry_set)
if not hits:
return None
matched_required.append(_pick_one(hits, seed + tmpl.id))
# Collect optional matches (override preferred, then pantry match)
matched_optional: list[str] = []
for role in tmpl.optional:
chosen = role_overrides.get(role.display)
if chosen:
matched_optional.append(chosen)
else:
hits = _matches_role(role, pantry_set)
if hits:
matched_optional.append(_pick_one(hits, seed + tmpl.id))
all_matched = matched_required + matched_optional
# Build title: prefer override items for personalisation
effective_pantry = pantry_set | set(role_overrides.values())
title = _personalized_title(tmpl, effective_pantry, seed + tmpl.id)
# Items in role_overrides that aren't in the user's pantry = shopping list
missing = [
item for item in role_overrides.values()
if item and item not in pantry_set
]
return RecipeSuggestion(
id=tmpl.id,
title=title,
match_count=len(all_matched),
element_coverage={},
swap_candidates=[],
matched_ingredients=all_matched,
missing_ingredients=missing,
directions=tmpl.directions,
notes=tmpl.notes,
level=1,
is_wildcard=False,
nutrition=None,
)

View file

@ -21,7 +21,6 @@ if TYPE_CHECKING:
from app.db.store import Store
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.grocery_links import GroceryLinkBuilder
from app.services.recipe.substitution_engine import SubstitutionEngine
@ -517,13 +516,6 @@ def _build_source_url(row: dict) -> str | None:
return None
_ASSEMBLY_TIER_LIMITS: dict[str, int] = {
"free": 2,
"paid": 4,
"premium": 6,
}
# Method complexity classification patterns
_EASY_METHODS = re.compile(
r"\b(microwave|mix|stir|blend|toast|assemble|heat)\b", re.IGNORECASE
@ -637,6 +629,11 @@ class RecipeEngine:
return gen.generate(req, profiles, gaps)
# Level 1 & 2: deterministic path
# L1 ("Use What I Have") applies strict quality gates:
# - exclude_generic: filter catch-all recipes at the DB level
# - effective_max_missing: default to 2 when user hasn't set a cap
# - match ratio: require ≥60% ingredient coverage to avoid low-signal results
_l1 = req.level == 1 and not req.shopping_mode
nf = req.nutrition_filters
rows = self._store.search_recipes_by_ingredients(
req.pantry_items,
@ -647,7 +644,16 @@ class RecipeEngine:
max_carbs_g=nf.max_carbs_g,
max_sodium_mg=nf.max_sodium_mg,
excluded_ids=req.excluded_ids or [],
exclude_generic=_l1,
)
# L1 strict defaults: cap missing ingredients and require a minimum ratio.
_L1_MAX_MISSING_DEFAULT = 2
_L1_MIN_MATCH_RATIO = 0.6
effective_max_missing = req.max_missing
if _l1 and effective_max_missing is None:
effective_max_missing = _L1_MAX_MISSING_DEFAULT
suggestions = []
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
@ -690,9 +696,17 @@ class RecipeEngine:
missing.append(n)
# Filter by max_missing — skipped in shopping mode (user is willing to buy)
if not req.shopping_mode and req.max_missing is not None and len(missing) > req.max_missing:
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
continue
# L1 match ratio gate: drop results where less than 60% of the recipe's
# ingredients are in the pantry. Prevents low-signal results like a
# 10-ingredient recipe matching on only one common item.
if _l1 and ingredient_names:
match_ratio = len(matched) / len(ingredient_names)
if match_ratio < _L1_MIN_MATCH_RATIO:
continue
# Filter and tier-rank by hard_day_mode
if req.hard_day_mode:
directions: list[str] = row.get("directions") or []
@ -761,39 +775,17 @@ class RecipeEngine:
source_url=_build_source_url(row),
))
# Assembly-dish templates (burrito, fried rice, pasta, etc.)
# Expiry boost: when expiry_first, the pantry_items list is already sorted
# by expiry urgency — treat the first slice as the "expiring" set so templates
# that use those items bubble up in the merged ranking.
expiring_set: set[str] = set()
if req.expiry_first:
expiring_set = _expand_pantry_set(req.pantry_items[:10])
assembly = match_assembly_templates(
pantry_items=req.pantry_items,
pantry_set=pantry_set,
excluded_ids=req.excluded_ids or [],
expiring_set=expiring_set,
)
# Cap by tier — lifted in shopping mode since missing-ingredient templates
# are desirable there (each fires an affiliate link opportunity).
if not req.shopping_mode:
assembly_limit = _ASSEMBLY_TIER_LIMITS.get(req.tier, 3)
assembly = assembly[:assembly_limit]
# Interleave: sort templates and corpus recipes together.
# Sort corpus results — assembly templates are now served from a dedicated tab.
# Hard day mode: primary sort by tier (0=premade, 1=simple, 2=moderate),
# then by match_count within each tier. Assembly templates are inherently
# simple so they default to tier 1 when not in the tier map.
# Normal mode: sort by match_count only.
# then by match_count within each tier.
# Normal mode: sort by match_count descending.
if req.hard_day_mode and hard_day_tier_map:
suggestions = sorted(
assembly + suggestions,
suggestions,
key=lambda s: (hard_day_tier_map.get(s.id, 1), -s.match_count),
)
else:
suggestions = sorted(assembly + suggestions, key=lambda s: s.match_count, reverse=True)
suggestions = sorted(suggestions, key=lambda s: -s.match_count)
# Build grocery list — deduplicated union of all missing ingredients
seen: set[str] = set()

View file

@ -0,0 +1,300 @@
"""
Recipe tag inference engine.
Derives normalized tags from a recipe's title, ingredient names, existing corpus
tags (category + keywords), enriched ingredient profile data, and optional
nutrition data.
Tags are organized into five namespaces:
cuisine:* -- cuisine/region classification
dietary:* -- dietary restriction / nutrition profile
flavor:* -- flavor profile (spicy, smoky, sweet, etc.)
time:* -- effort / time signals
meal:* -- meal type
can_be:* -- achievable with substitutions (e.g. can_be:Gluten-Free)
Output is a flat sorted list of strings, e.g.:
["can_be:Gluten-Free", "cuisine:Italian", "dietary:Low-Carb",
"flavor:Savory", "flavor:Umami", "time:Quick"]
These populate recipes.inferred_tags and are FTS5-indexed so browse domain
queries find recipes the food.com corpus tags alone would miss.
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Text-signal tables
# (tag, [case-insensitive substrings to search in combined title+ingredient text])
# ---------------------------------------------------------------------------
_CUISINE_SIGNALS: list[tuple[str, list[str]]] = [
("cuisine:Japanese", ["miso", "dashi", "ramen", "sushi", "teriyaki", "sake", "mirin",
"wasabi", "panko", "edamame", "tonkatsu", "yakitori", "ponzu"]),
("cuisine:Korean", ["gochujang", "kimchi", "doenjang", "gochugaru",
"bulgogi", "bibimbap", "japchae"]),
("cuisine:Thai", ["fish sauce", "lemongrass", "galangal", "pad thai", "thai basil",
"kaffir lime", "tom yum", "green curry", "red curry", "nam pla"]),
("cuisine:Chinese", ["hoisin", "oyster sauce", "five spice", "bok choy", "chow mein",
"dumpling", "wonton", "mapo", "char siu", "sichuan"]),
("cuisine:Vietnamese", ["pho", "banh mi", "nuoc cham", "rice paper", "vietnamese"]),
("cuisine:Indian", ["garam masala", "turmeric", "cardamom", "fenugreek", "paneer",
"tikka", "masala", "biryani", "dal", "naan", "tandoori",
"curry leaf", "tamarind", "chutney"]),
("cuisine:Middle Eastern", ["tahini", "harissa", "za'atar", "sumac", "baharat", "rose water",
"pomegranate molasses", "freekeh", "fattoush", "shakshuka"]),
("cuisine:Greek", ["feta", "tzatziki", "moussaka", "spanakopita", "orzo",
"kalamata", "gyro", "souvlaki", "dolma"]),
("cuisine:Mediterranean", ["hummus", "pita", "couscous", "preserved lemon"]),
("cuisine:Italian", ["pasta", "pizza", "risotto", "lasagna", "carbonara", "gnocchi",
"parmesan", "mozzarella", "ricotta", "prosciutto", "pancetta",
"arancini", "osso buco", "tiramisu", "pesto", "bolognese",
"cannoli", "polenta", "bruschetta", "focaccia"]),
("cuisine:French", ["croissant", "quiche", "crepe", "coq au vin",
"ratatouille", "bearnaise", "hollandaise", "bouillabaisse",
"herbes de provence", "dijon", "gruyere", "brie", "cassoulet"]),
("cuisine:Spanish", ["paella", "chorizo", "gazpacho", "tapas", "patatas bravas",
"sofrito", "manchego", "albondigas"]),
("cuisine:German", ["sauerkraut", "bratwurst", "schnitzel", "pretzel", "strudel",
"spaetzle", "sauerbraten"]),
("cuisine:Mexican", ["taco", "burrito", "enchilada", "salsa", "guacamole", "chipotle",
"queso", "tamale", "mole", "jalapeno", "tortilla", "carnitas",
"chile verde", "posole", "tostada", "quesadilla"]),
("cuisine:Latin American", ["plantain", "yuca", "chimichurri", "ceviche", "adobo", "empanada"]),
("cuisine:American", ["bbq sauce", "buffalo sauce", "ranch dressing", "coleslaw",
"cornbread", "mac and cheese", "brisket", "cheeseburger"]),
("cuisine:Southern", ["collard greens", "black-eyed peas", "okra", "grits", "catfish",
"hush puppies", "pecan pie"]),
("cuisine:Cajun", ["cajun", "creole", "gumbo", "jambalaya", "andouille", "etouffee"]),
("cuisine:African", ["injera", "berbere", "jollof", "suya", "egusi", "fufu", "tagine"]),
("cuisine:Caribbean", ["jerk", "scotch bonnet", "callaloo", "ackee"]),
]
_DIETARY_SIGNALS: list[tuple[str, list[str]]] = [
("dietary:Vegan", ["vegan", "plant-based", "plant based"]),
("dietary:Vegetarian", ["vegetarian", "meatless"]),
("dietary:Gluten-Free", ["gluten-free", "gluten free", "celiac"]),
("dietary:Dairy-Free", ["dairy-free", "dairy free", "lactose free", "non-dairy"]),
("dietary:Low-Carb", ["low-carb", "low carb", "keto", "ketogenic", "very low carbs"]),
("dietary:High-Protein", ["high protein", "high-protein"]),
("dietary:Low-Fat", ["low-fat", "low fat", "fat-free", "reduced fat"]),
("dietary:Paleo", ["paleo", "whole30"]),
("dietary:Nut-Free", ["nut-free", "nut free", "peanut free"]),
("dietary:Egg-Free", ["egg-free", "egg free"]),
("dietary:Low-Sodium", ["low sodium", "no salt"]),
("dietary:Healthy", ["healthy", "low cholesterol", "heart healthy", "wholesome"]),
]
_FLAVOR_SIGNALS: list[tuple[str, list[str]]] = [
("flavor:Spicy", ["jalapeno", "habanero", "ghost pepper", "sriracha",
"chili flake", "red pepper flake", "cayenne", "hot sauce",
"gochujang", "harissa", "scotch bonnet", "szechuan pepper", "spicy"]),
("flavor:Smoky", ["smoked", "liquid smoke", "smoked paprika",
"bbq sauce", "barbecue", "hickory", "mesquite"]),
("flavor:Sweet", ["honey", "maple syrup", "brown sugar", "caramel", "chocolate",
"vanilla", "condensed milk", "molasses", "agave"]),
("flavor:Savory", ["soy sauce", "fish sauce", "miso", "worcestershire", "anchovy",
"parmesan", "blue cheese", "bone broth"]),
("flavor:Tangy", ["lemon juice", "lime juice", "vinegar", "balsamic", "buttermilk",
"sour cream", "fermented", "pickled", "tamarind", "sumac"]),
("flavor:Herby", ["fresh basil", "fresh cilantro", "fresh dill", "fresh mint",
"fresh tarragon", "fresh thyme", "herbes de provence"]),
("flavor:Rich", ["heavy cream", "creme fraiche", "mascarpone", "double cream",
"ghee", "coconut cream", "cream cheese"]),
("flavor:Umami", ["mushroom", "nutritional yeast", "tomato paste",
"parmesan rind", "bonito", "kombu"]),
]
_TIME_SIGNALS: list[tuple[str, list[str]]] = [
("time:Quick", ["< 15 mins", "< 30 mins", "weeknight", "easy"]),
("time:Under 1 Hour", ["< 60 mins"]),
("time:Make-Ahead", ["freezer", "overnight", "refrigerator", "make-ahead", "make ahead"]),
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
]
# food.com corpus tag -> normalized tags
_CORPUS_TAG_MAP: dict[str, list[str]] = {
"european": ["cuisine:Italian", "cuisine:French", "cuisine:German",
"cuisine:Spanish"],
"asian": ["cuisine:Chinese", "cuisine:Japanese", "cuisine:Thai",
"cuisine:Korean", "cuisine:Vietnamese"],
"chinese": ["cuisine:Chinese"],
"japanese": ["cuisine:Japanese"],
"thai": ["cuisine:Thai"],
"vietnamese": ["cuisine:Vietnamese"],
"indian": ["cuisine:Indian"],
"greek": ["cuisine:Greek"],
"mexican": ["cuisine:Mexican"],
"african": ["cuisine:African"],
"caribbean": ["cuisine:Caribbean"],
"vegan": ["dietary:Vegan", "dietary:Vegetarian"],
"vegetarian": ["dietary:Vegetarian"],
"healthy": ["dietary:Healthy"],
"low cholesterol": ["dietary:Healthy"],
"very low carbs": ["dietary:Low-Carb"],
"high in...": ["dietary:High-Protein"],
"lactose free": ["dietary:Dairy-Free"],
"egg free": ["dietary:Egg-Free"],
"< 15 mins": ["time:Quick"],
"< 30 mins": ["time:Quick"],
"< 60 mins": ["time:Under 1 Hour"],
"< 4 hours": ["time:Slow Cook"],
"weeknight": ["time:Quick"],
"freezer": ["time:Make-Ahead"],
"dessert": ["meal:Dessert"],
"breakfast": ["meal:Breakfast"],
"lunch/snacks": ["meal:Lunch", "meal:Snack"],
"beverages": ["meal:Beverage"],
"cookie & brownie": ["meal:Dessert"],
"breads": ["meal:Bread"],
}
# ingredient_profiles.elements value -> flavor tag
_ELEMENT_TO_FLAVOR: dict[str, str] = {
"Aroma": "flavor:Herby",
"Richness": "flavor:Rich",
"Structure": "", # no flavor tag
"Binding": "",
"Crust": "flavor:Smoky",
"Lift": "",
"Emulsion": "flavor:Rich",
"Acid": "flavor:Tangy",
}
def _build_text(title: str, ingredient_names: list[str]) -> str:
parts = [title.lower()]
parts.extend(i.lower() for i in ingredient_names)
return " ".join(parts)
def _match_signals(text: str, table: list[tuple[str, list[str]]]) -> list[str]:
return [tag for tag, pats in table if any(p in text for p in pats)]
def infer_tags(
title: str,
ingredient_names: list[str],
corpus_keywords: list[str],
corpus_category: str = "",
# Enriched ingredient profile signals (from ingredient_profiles cross-ref)
element_coverage: dict[str, float] | None = None,
fermented_count: int = 0,
glutamate_total: float = 0.0,
ph_min: float | None = None,
available_sub_constraints: list[str] | None = None,
# Nutrition data for macro-based tags
calories: float | None = None,
protein_g: float | None = None,
fat_g: float | None = None,
carbs_g: float | None = None,
servings: float | None = None,
) -> list[str]:
"""
Derive normalized tags for a recipe.
Parameters
----------
title, ingredient_names, corpus_keywords, corpus_category
: Primary recipe data.
element_coverage
: Dict from recipes.element_coverage -- element name to coverage ratio
(e.g. {"Aroma": 0.6, "Richness": 0.4}). Derived from ingredient_profiles.
fermented_count
: Number of fermented ingredients (from ingredient_profiles.is_fermented).
glutamate_total
: Sum of glutamate_mg across all profiled ingredients. High values signal umami.
ph_min
: Minimum ph_estimate across profiled ingredients. Low values signal acidity.
available_sub_constraints
: Substitution constraint labels achievable for this recipe
(e.g. ["gluten_free", "low_carb"]). From substitution_pairs cross-ref.
These become can_be:* tags.
calories, protein_g, fat_g, carbs_g, servings
: Nutrition data for macro-based dietary tags.
Returns
-------
Sorted list of unique normalized tag strings.
"""
tags: set[str] = set()
# 1. Map corpus tags to normalized vocabulary
for kw in corpus_keywords:
for t in _CORPUS_TAG_MAP.get(kw.lower(), []):
tags.add(t)
if corpus_category:
for t in _CORPUS_TAG_MAP.get(corpus_category.lower(), []):
tags.add(t)
# 2. Text-signal matching
text = _build_text(title, ingredient_names)
tags.update(_match_signals(text, _CUISINE_SIGNALS))
tags.update(_match_signals(text, _DIETARY_SIGNALS))
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
# 3. Time signals from corpus keywords + text
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
tags.update(_match_signals(corpus_text, _TIME_SIGNALS))
tags.update(_match_signals(text, _TIME_SIGNALS))
# 4. Enriched profile signals
if element_coverage:
for element, coverage in element_coverage.items():
if coverage > 0.2: # >20% of ingredients carry this element
flavor_tag = _ELEMENT_TO_FLAVOR.get(element, "")
if flavor_tag:
tags.add(flavor_tag)
if glutamate_total > 50:
tags.add("flavor:Umami")
if fermented_count > 0:
tags.add("flavor:Tangy")
if ph_min is not None and ph_min < 4.5:
tags.add("flavor:Tangy")
# 5. Achievable-via-substitution tags
if available_sub_constraints:
label_to_tag = {
"gluten_free": "can_be:Gluten-Free",
"low_calorie": "can_be:Low-Calorie",
"low_carb": "can_be:Low-Carb",
"vegan": "can_be:Vegan",
"dairy_free": "can_be:Dairy-Free",
"low_sodium": "can_be:Low-Sodium",
}
for label in available_sub_constraints:
tag = label_to_tag.get(label)
if tag:
tags.add(tag)
# 6. Macro-based dietary tags
if servings and servings > 0 and any(
v is not None for v in (protein_g, fat_g, carbs_g, calories)
):
def _per(v: float | None) -> float | None:
return v / servings if v is not None else None
prot_s = _per(protein_g)
fat_s = _per(fat_g)
carb_s = _per(carbs_g)
cal_s = _per(calories)
if prot_s is not None and prot_s >= 20:
tags.add("dietary:High-Protein")
if fat_s is not None and fat_s <= 5:
tags.add("dietary:Low-Fat")
if carb_s is not None and carb_s <= 10:
tags.add("dietary:Low-Carb")
if cal_s is not None and cal_s <= 250:
tags.add("dietary:Light")
elif protein_g is not None and protein_g >= 20:
tags.add("dietary:High-Protein")
# 7. Vegan implies vegetarian
if "dietary:Vegan" in tags:
tags.add("dietary:Vegetarian")
return sorted(tags)

View file

@ -18,8 +18,19 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"style_classifier",
"meal_plan_llm",
"meal_plan_llm_timing",
"community_fork_adapt",
})
# Sources subject to monthly cf-orch call caps. Subscription-based sources are uncapped.
LIFETIME_SOURCES: frozenset[str] = frozenset({"lifetime", "founders"})
# (source, tier) → monthly cf-orch call allowance
LIFETIME_ORCH_CAPS: dict[tuple[str, str], int] = {
("lifetime", "paid"): 60,
("lifetime", "premium"): 180,
("founders", "premium"): 300,
}
# Feature → minimum tier required
KIWI_FEATURES: dict[str, str] = {
# Free tier
@ -43,6 +54,8 @@ KIWI_FEATURES: dict[str, str] = {
"style_picker": "paid",
"recipe_collections": "paid",
"style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
"community_publish": "paid", # Publish plans/outcomes to community feed
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
# Premium tier
"multi_household": "premium",

View file

@ -20,6 +20,9 @@ services:
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
# cf-orch: route LLM calls through the coordinator for managed GPU inference
CF_ORCH_URL: http://host.docker.internal:7700
# Community PostgreSQL — shared across CF products; unset = community features unavailable (fail soft)
COMMUNITY_DB_URL: ${COMMUNITY_DB_URL:-}
COMMUNITY_PSEUDONYM_SALT: ${COMMUNITY_PSEUDONYM_SALT:-}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:

View file

@ -197,7 +197,7 @@ import { householdAPI } from './services/api'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
const currentTab = ref<Tab>('inventory')
const currentTab = ref<Tab>('recipes')
const sidebarCollapsed = ref(false)
const inventoryStore = useInventoryStore()
const { kiwiVisible, kiwiDirection } = useEasterEggs()
@ -225,6 +225,11 @@ async function switchTab(tab: Tab) {
}
onMounted(async () => {
// Pre-fetch inventory so Recipes tab has data on first load
if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
// Handle household invite links: /#/join?household_id=xxx&token=yyy
const hash = window.location.hash
if (hash.includes('/join')) {

View file

@ -0,0 +1,586 @@
<template>
<div class="byo-tab">
<!-- Step 0: Template grid -->
<div v-if="phase === 'select'" class="byo-section">
<h2 class="section-title text-xl mb-sm">Build Your Own Recipe</h2>
<p class="text-sm text-secondary mb-md">
Choose a style, then pick your ingredients one step at a time.
</p>
<div v-if="templatesLoading" class="text-secondary text-sm">Loading templates</div>
<div v-else-if="templatesError" role="alert" class="status-badge status-error mb-md">
{{ templatesError }}
</div>
<div v-else class="template-grid" role="list">
<button
v-for="tmpl in templates"
:key="tmpl.id"
class="template-card card"
role="listitem"
:aria-label="tmpl.title + ': ' + tmpl.descriptor"
@click="selectTemplate(tmpl)"
>
<span class="tmpl-icon" aria-hidden="true">{{ tmpl.icon }}</span>
<span class="tmpl-title">{{ tmpl.title }}</span>
<span class="tmpl-descriptor text-sm text-secondary">{{ tmpl.descriptor }}</span>
</button>
</div>
</div>
<!-- Step 1+: Ingredient wizard -->
<div v-else-if="phase === 'wizard'" class="byo-section">
<!-- Back + step counter -->
<div class="byo-nav mb-sm">
<button class="btn btn-sm btn-secondary" @click="goBack"> Back</button>
<span class="text-sm text-secondary step-counter">Step {{ wizardStep + 1 }} of {{ totalSteps }}</span>
</div>
<h2 class="section-title text-xl mb-xs">What's your {{ currentRole?.display }}?</h2>
<p v-if="currentRole?.hint" class="text-sm text-secondary mb-md">{{ currentRole.hint }}</p>
<!-- Missing ingredient mode toggle -->
<div class="mode-toggle mb-sm" role="radiogroup" aria-label="Missing ingredients">
<button
v-for="mode in missingModes"
:key="mode.value"
:class="['btn', 'btn-sm', recipesStore.missingIngredientMode === mode.value ? 'btn-primary' : 'btn-secondary']"
:aria-checked="recipesStore.missingIngredientMode === mode.value"
role="radio"
@click="recipesStore.missingIngredientMode = mode.value as any"
>{{ mode.label }}</button>
</div>
<!-- Filter row: text search or tag cloud -->
<div class="filter-row mb-sm">
<input
v-if="recipesStore.builderFilterMode === 'text'"
v-model="filterText"
class="form-input filter-input"
:placeholder="'Search ' + (currentRole?.display ?? 'ingredients') + '…'"
aria-label="Search ingredients"
/>
<div
v-else
class="tag-cloud"
role="group"
aria-label="Filter by tag"
>
<button
v-for="tag in candidates?.available_tags ?? []"
:key="tag"
:class="['btn', 'btn-sm', 'tag-chip', selectedTags.has(tag) ? 'tag-active' : '']"
:aria-pressed="selectedTags.has(tag)"
@click="toggleTag(tag)"
>{{ tag }}</button>
<span v-if="(candidates?.available_tags ?? []).length === 0" class="text-secondary text-sm">
No tags available for this ingredient set.
</span>
</div>
<button
class="btn btn-sm btn-secondary filter-mode-btn"
:aria-pressed="recipesStore.builderFilterMode === 'tags'"
:aria-label="recipesStore.builderFilterMode === 'text' ? 'Switch to tag filter' : 'Switch to text search'"
@click="recipesStore.builderFilterMode = recipesStore.builderFilterMode === 'text' ? 'tags' : 'text'"
>{{ recipesStore.builderFilterMode === 'text' ? '🏷️' : '🔍' }}</button>
</div>
<!-- Candidates loading / error -->
<div v-if="candidatesLoading" class="text-secondary text-sm mb-sm">Loading options</div>
<div v-else-if="candidatesError" role="alert" class="status-badge status-error mb-sm">
{{ candidatesError }}
</div>
<!-- Compatible candidates -->
<div v-if="filteredCompatible.length > 0" class="candidates-section mb-sm">
<p class="text-xs font-semibold text-secondary mb-xs" aria-hidden="true">Available</p>
<div class="ingredient-grid">
<button
v-for="item in filteredCompatible"
:key="item.name"
:class="['ingredient-card', 'btn', selectedInRole.has(item.name) ? 'ingredient-active' : '']"
:aria-pressed="selectedInRole.has(item.name)"
:aria-label="item.name + (item.in_pantry ? '' : ' — not in pantry')"
@click="toggleIngredient(item.name)"
>
<span class="ingredient-name">{{ item.name }}</span>
<span v-if="!item.in_pantry && recipesStore.missingIngredientMode === 'add-to-cart'"
class="cart-icon" aria-hidden="true">🛒</span>
</button>
</div>
</div>
<!-- Other candidates (greyed or add-to-cart mode only) -->
<template v-if="recipesStore.missingIngredientMode !== 'hidden' && filteredOther.length > 0">
<div class="candidates-separator text-xs text-secondary mb-xs">also works</div>
<div class="ingredient-grid ingredient-grid-other mb-sm">
<button
v-for="item in filteredOther"
:key="item.name"
:class="['ingredient-card', 'btn',
item.in_pantry ? '' : 'ingredient-missing',
selectedInRole.has(item.name) ? 'ingredient-active' : '']"
:aria-pressed="selectedInRole.has(item.name)"
:aria-label="item.name + (item.in_pantry ? '' : ' — not in pantry')"
:disabled="!item.in_pantry && recipesStore.missingIngredientMode === 'greyed'"
@click="item.in_pantry || recipesStore.missingIngredientMode !== 'greyed' ? toggleIngredient(item.name) : undefined"
>
<span class="ingredient-name">{{ item.name }}</span>
<span v-if="!item.in_pantry && recipesStore.missingIngredientMode === 'add-to-cart'"
class="cart-icon" aria-hidden="true">🛒</span>
</button>
</div>
</template>
<!-- No-match state: nothing compatible AND nothing visible in other section.
filteredOther items are hidden when mode is 'hidden', so check visibility too. -->
<template v-if="!candidatesLoading && !candidatesError && filteredCompatible.length === 0 && (filteredOther.length === 0 || recipesStore.missingIngredientMode === 'hidden')">
<!-- Custom freeform input: text filter with no matches offer "use anyway" -->
<div v-if="recipesStore.builderFilterMode === 'text' && filterText.trim().length > 0" class="custom-ingredient-prompt mb-sm">
<p class="text-sm text-secondary mb-xs">
No match for "{{ filterText.trim() }}" in your pantry.
</p>
<button class="btn btn-secondary" @click="useCustomIngredient">
Use "{{ filterText.trim() }}" anyway
</button>
</div>
<!-- No pantry items at all for this role -->
<p v-else class="text-sm text-secondary mb-sm">
Nothing in your pantry fits this role yet. You can skip it or
<button class="btn-link" @click="recipesStore.missingIngredientMode = 'greyed'">show options to add.</button>
</p>
</template>
<!-- Skip / Next -->
<div class="byo-actions">
<button
v-if="!currentRole?.required"
class="btn btn-secondary"
@click="advanceStep"
>Skip (optional)</button>
<button
v-else-if="currentRole?.required && selectedInRole.size === 0"
class="btn btn-secondary"
@click="advanceStep"
>I'll add this later</button>
<button
class="btn btn-primary"
:disabled="buildLoading"
@click="wizardStep < totalSteps - 1 ? advanceStep() : buildRecipe()"
>
{{ wizardStep < totalSteps - 1 ? 'Next →' : 'Build this recipe' }}
</button>
</div>
</div>
<!-- Result -->
<div v-else-if="phase === 'result'" class="byo-section">
<div v-if="buildLoading" class="text-secondary text-sm mb-md">Building your recipe</div>
<div v-else-if="buildError" role="alert" class="status-badge status-error mb-md">
{{ buildError }}
</div>
<template v-else-if="builtRecipe">
<RecipeDetailPanel
:recipe="builtRecipe"
:grocery-links="[]"
@close="phase = 'select'"
@cooked="phase = 'select'"
/>
<!-- Shopping list: items the user chose that aren't in their pantry -->
<div v-if="(builtRecipe.missing_ingredients ?? []).length > 0" class="cart-list card mb-sm">
<h3 class="text-sm font-semibold mb-xs">🛒 You'll need to pick up</h3>
<ul class="cart-items">
<li v-for="item in builtRecipe.missing_ingredients" :key="item" class="cart-item text-sm">{{ item }}</li>
</ul>
</div>
<div class="byo-actions mt-sm">
<button class="btn btn-secondary" @click="resetToTemplate">Try a different build</button>
<button class="btn btn-secondary" @click="phase = 'wizard'">Adjust ingredients</button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRecipesStore } from '../stores/recipes'
import RecipeDetailPanel from './RecipeDetailPanel.vue'
import { recipesAPI, type AssemblyTemplateOut, type RoleCandidatesResponse, type RecipeSuggestion } from '../services/api'
const recipesStore = useRecipesStore()
type Phase = 'select' | 'wizard' | 'result'
const phase = ref<Phase>('select')
// Template grid state
const templates = ref<AssemblyTemplateOut[]>([])
const templatesLoading = ref(false)
const templatesError = ref<string | null>(null)
// Wizard state
const selectedTemplate = ref<AssemblyTemplateOut | null>(null)
const wizardStep = ref(0)
const roleOverrides = ref<Record<string, string[]>>({})
// Candidates for current step
const candidates = ref<RoleCandidatesResponse | null>(null)
const candidatesLoading = ref(false)
const candidatesError = ref<string | null>(null)
// Filter state (reset on step advance)
const filterText = ref('')
const selectedTags = ref<Set<string>>(new Set())
// Result state
const builtRecipe = ref<RecipeSuggestion | null>(null)
const buildLoading = ref(false)
const buildError = ref<string | null>(null)
// Shopping list is derived from builtRecipe.missing_ingredients (computed by backend)
const missingModes = [
{ label: 'Available only', value: 'hidden' },
{ label: 'Show missing', value: 'greyed' },
{ label: 'Add to cart', value: 'add-to-cart' },
]
const totalSteps = computed(() => selectedTemplate.value?.role_sequence.length ?? 0)
const currentRole = computed(() => selectedTemplate.value?.role_sequence[wizardStep.value] ?? null)
const selectedInRole = computed<Set<string>>(() => {
const role = currentRole.value?.display
if (!role) return new Set()
return new Set(roleOverrides.value[role] ?? [])
})
const priorPicks = computed<string[]>(() => {
if (!selectedTemplate.value) return []
return selectedTemplate.value.role_sequence
.slice(0, wizardStep.value)
.flatMap((r) => roleOverrides.value[r.display] ?? [])
})
const filteredCompatible = computed(() => applyFilter(candidates.value?.compatible ?? []))
const filteredOther = computed(() => applyFilter(candidates.value?.other ?? []))
function applyFilter(items: RoleCandidatesResponse['compatible']) {
if (recipesStore.builderFilterMode === 'text') {
const q = filterText.value.trim().toLowerCase()
if (!q) return items
return items.filter((i) => i.name.toLowerCase().includes(q))
} else {
if (selectedTags.value.size === 0) return items
return items.filter((i) =>
[...selectedTags.value].every((tag) => i.tags.includes(tag))
)
}
}
function toggleTag(tag: string) {
const next = new Set(selectedTags.value)
next.has(tag) ? next.delete(tag) : next.add(tag)
selectedTags.value = next
}
function toggleIngredient(name: string) {
const role = currentRole.value?.display
if (!role) return
const current = new Set(roleOverrides.value[role] ?? [])
current.has(name) ? current.delete(name) : current.add(name)
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
}
function useCustomIngredient() {
const name = filterText.value.trim()
if (!name) return
const role = currentRole.value?.display
if (!role) return
// Add to role overrides so it's included in the build request
const current = new Set(roleOverrides.value[role] ?? [])
current.add(name)
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
// Inject into the local candidates list so it renders as a selected card.
// Mark in_pantry: true so it stays visible regardless of missing-ingredient mode.
if (candidates.value) {
const knownNames = new Set([
...(candidates.value.compatible ?? []).map((i) => i.name.toLowerCase()),
...(candidates.value.other ?? []).map((i) => i.name.toLowerCase()),
])
if (!knownNames.has(name.toLowerCase())) {
candidates.value = {
...candidates.value,
compatible: [{ name, in_pantry: true, tags: [] }, ...(candidates.value.compatible ?? [])],
}
}
}
filterText.value = ''
}
async function selectTemplate(tmpl: AssemblyTemplateOut) {
selectedTemplate.value = tmpl
wizardStep.value = 0
roleOverrides.value = {}
phase.value = 'wizard'
await loadCandidates()
}
async function loadCandidates() {
if (!selectedTemplate.value || !currentRole.value) return
candidatesLoading.value = true
candidatesError.value = null
filterText.value = ''
selectedTags.value = new Set()
try {
candidates.value = await recipesAPI.getRoleCandidates(
selectedTemplate.value.id,
currentRole.value.display,
priorPicks.value,
)
} catch {
candidatesError.value = 'Could not load ingredient options. Please try again.'
} finally {
candidatesLoading.value = false
}
}
async function advanceStep() {
if (!selectedTemplate.value) return
if (wizardStep.value < totalSteps.value - 1) {
wizardStep.value++
await loadCandidates()
}
}
function goBack() {
if (phase.value === 'result') {
phase.value = 'wizard'
return
}
if (wizardStep.value > 0) {
wizardStep.value--
loadCandidates()
} else {
phase.value = 'select'
selectedTemplate.value = null
}
}
async function buildRecipe() {
if (!selectedTemplate.value) return
buildLoading.value = true
buildError.value = null
phase.value = 'result'
const overrides: Record<string, string> = {}
for (const [role, picks] of Object.entries(roleOverrides.value)) {
if (picks.length > 0) overrides[role] = picks[0]!
}
try {
builtRecipe.value = await recipesAPI.buildRecipe({
template_id: selectedTemplate.value.id,
role_overrides: overrides,
})
} catch {
buildError.value = 'Could not build recipe. Try adjusting your ingredients.'
} finally {
buildLoading.value = false
}
}
function resetToTemplate() {
phase.value = 'select'
selectedTemplate.value = null
wizardStep.value = 0
roleOverrides.value = {}
builtRecipe.value = null
buildError.value = null
}
onMounted(async () => {
templatesLoading.value = true
try {
templates.value = await recipesAPI.getTemplates()
} catch {
templatesError.value = 'Could not load templates. Please refresh.'
} finally {
templatesLoading.value = false
}
})
</script>
<style scoped>
.byo-tab {
padding: var(--spacing-sm) 0;
}
.byo-section {
max-width: 640px;
}
.template-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
@media (min-width: 640px) {
.template-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.template-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
padding: var(--spacing-md);
text-align: left;
cursor: pointer;
}
.tmpl-icon {
font-size: 1.5rem;
}
.tmpl-title {
font-weight: 600;
font-size: 0.95rem;
}
.tmpl-descriptor {
line-height: 1.35;
}
.byo-nav {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.step-counter {
margin-left: auto;
}
.mode-toggle {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
}
.filter-row {
display: flex;
gap: var(--spacing-xs);
align-items: flex-start;
}
.filter-input {
flex: 1;
}
.filter-mode-btn {
flex-shrink: 0;
min-width: 36px;
}
.tag-cloud {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.tag-active {
background: var(--color-primary);
color: var(--color-bg-primary);
}
.ingredient-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-xs);
}
@media (min-width: 640px) {
.ingredient-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.ingredient-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
min-height: 44px;
cursor: pointer;
}
.ingredient-active {
border: 2px solid var(--color-primary);
background: var(--color-primary-light);
color: var(--color-bg-primary);
}
.ingredient-missing {
opacity: 0.55;
}
.ingredient-name {
flex: 1;
font-size: 0.9rem;
}
.cart-icon {
font-size: 0.85rem;
margin-left: var(--spacing-xs);
}
.candidates-separator {
margin-top: var(--spacing-sm);
padding-top: var(--spacing-xs);
border-top: 1px solid var(--color-border);
}
.byo-actions {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.btn-link {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.cart-list {
padding: var(--spacing-sm) var(--spacing-md);
}
.cart-items {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
margin-top: var(--spacing-xs);
}
.cart-item {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px var(--spacing-sm);
}
</style>

View file

@ -0,0 +1,337 @@
<template>
<div class="community-feed-panel">
<!-- Filter tabs: All / Plans / Successes / Bloopers -->
<div role="tablist" aria-label="Community post filters" class="filter-bar flex gap-xs mb-md">
<button
v-for="f in filters"
:key="f.id"
role="tab"
:aria-selected="activeFilter === f.id"
:tabindex="activeFilter === f.id ? 0 : -1"
:class="['btn', 'tab-btn', activeFilter === f.id ? 'btn-primary' : 'btn-secondary']"
@click="setFilter(f.id)"
@keydown="onFilterKeydown"
@pointerdown="f.id === 'recipe_blooper' ? onBlooperPointerDown($event) : undefined"
@pointerup="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
@pointerleave="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
>{{ f.label }}</button>
</div>
<!-- Share a plan action row -->
<div class="action-row flex-between mb-sm">
<button
class="btn btn-secondary btn-sm share-plan-btn"
aria-haspopup="dialog"
@click="showPublishPlan = true"
>
Share a plan
</button>
</div>
<!-- Loading skeletons -->
<div
v-if="store.loading"
class="skeleton-list flex-col gap-sm"
aria-busy="true"
aria-label="Loading posts"
>
<div v-for="n in 3" :key="n" class="skeleton-card">
<div class="skeleton-line skeleton-line-short"></div>
<div class="skeleton-line skeleton-line-long mt-xs"></div>
<div class="skeleton-line skeleton-line-med mt-xs"></div>
</div>
</div>
<!-- Error state -->
<div
v-else-if="store.error"
class="error-state card"
role="alert"
>
<p class="text-sm text-secondary mb-sm">{{ store.error }}</p>
<button class="btn btn-secondary btn-sm" @click="retry">
Try again
</button>
</div>
<!-- Empty state -->
<div
v-else-if="store.posts.length === 0"
class="empty-state card text-center"
>
<p class="text-secondary mb-xs">No posts yet</p>
<p class="text-sm text-muted">Be the first to share a meal plan or recipe story.</p>
</div>
<!-- Post list -->
<div v-else class="post-list flex-col gap-sm">
<CommunityPostCard
v-for="post in store.posts"
:key="post.slug"
:post="post"
@fork="handleFork"
/>
</div>
<!-- Fork success toast -->
<Transition name="toast-fade">
<div
v-if="forkFeedback"
class="fork-toast status-badge status-success"
role="status"
aria-live="polite"
>
{{ forkFeedback }}
</div>
</Transition>
<!-- Fork error toast -->
<Transition name="toast-fade">
<div
v-if="forkError"
class="fork-toast status-badge status-error"
role="alert"
aria-live="assertive"
>
{{ forkError }}
</div>
</Transition>
<!-- Publish plan modal -->
<PublishPlanModal
v-if="showPublishPlan"
:plan="null"
@close="showPublishPlan = false"
@published="onPlanPublished"
/>
<!-- Hall of Chaos easter egg: hold Bloopers tab for 800ms -->
<HallOfChaosView
v-if="showHallOfChaos"
@close="showHallOfChaos = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { ForkResult } from '../stores/community'
import CommunityPostCard from './CommunityPostCard.vue'
import PublishPlanModal from './PublishPlanModal.vue'
import HallOfChaosView from './HallOfChaosView.vue'
const emit = defineEmits<{
'plan-forked': [payload: ForkResult]
}>()
const store = useCommunityStore()
const activeFilter = ref('all')
const showPublishPlan = ref(false)
const showHallOfChaos = ref(false)
let blooperHoldTimer: ReturnType<typeof setTimeout> | null = null
function onBlooperPointerDown(_e: PointerEvent) {
blooperHoldTimer = setTimeout(() => {
showHallOfChaos.value = true
blooperHoldTimer = null
}, 800)
}
function onBlooperPointerCancel() {
if (blooperHoldTimer !== null) {
clearTimeout(blooperHoldTimer)
blooperHoldTimer = null
}
}
const filters = [
{ id: 'all', label: 'All' },
{ id: 'plan', label: 'Plans' },
{ id: 'recipe_success', label: 'Successes' },
{ id: 'recipe_blooper', label: 'Bloopers' },
]
const filterIds = filters.map((f) => f.id)
function onFilterKeydown(e: KeyboardEvent) {
const current = filterIds.indexOf(activeFilter.value)
let next = current
if (e.key === 'ArrowRight') {
e.preventDefault()
next = (current + 1) % filterIds.length
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
next = (current - 1 + filterIds.length) % filterIds.length
} else {
return
}
setFilter(filterIds[next]!)
// Move DOM focus to the newly active tab per ARIA tablist pattern
const bar = (e.currentTarget as HTMLElement).closest('[role="tablist"]')
const buttons = bar?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons?.[next]?.focus()
}
async function setFilter(filterId: string) {
activeFilter.value = filterId
await store.fetchPosts(filterId === 'all' ? undefined : filterId)
}
async function retry() {
await store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
}
const forkFeedback = ref<string | null>(null)
const forkError = ref<string | null>(null)
function showToast(msg: string, type: 'success' | 'error') {
if (type === 'success') {
forkFeedback.value = msg
setTimeout(() => { forkFeedback.value = null }, 3000)
} else {
forkError.value = msg
setTimeout(() => { forkError.value = null }, 4000)
}
}
async function handleFork(slug: string) {
try {
const result = await store.forkPost(slug)
showToast('Plan added to your week.', 'success')
emit('plan-forked', result)
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : 'Could not fork this plan.', 'error')
}
}
function onPlanPublished(_payload: { slug: string }) {
showPublishPlan.value = false
store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
}
onMounted(async () => {
if (store.posts.length === 0) {
await store.fetchPosts()
}
})
onUnmounted(() => {
if (blooperHoldTimer !== null) {
clearTimeout(blooperHoldTimer)
blooperHoldTimer = null
}
})
</script>
<style scoped>
.community-feed-panel {
position: relative;
}
.filter-bar {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-sm);
}
.tab-btn {
border-radius: var(--radius-md) var(--radius-md) 0 0;
border-bottom: none;
}
.action-row {
padding: var(--spacing-xs) 0;
}
.share-plan-btn {
font-size: var(--font-size-xs);
}
/* Loading skeletons */
.skeleton-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
overflow: hidden;
}
.skeleton-line {
height: 12px;
border-radius: var(--radius-sm);
background: var(--color-bg-elevated);
animation: shimmer 1.4s ease-in-out infinite;
}
.skeleton-line-short { width: 35%; }
.skeleton-line-med { width: 60%; }
.skeleton-line-long { width: 90%; }
@keyframes shimmer {
0% { opacity: 0.6; }
50% { opacity: 1.0; }
100% { opacity: 0.6; }
}
/* Empty / error states */
.empty-state {
padding: var(--spacing-xl) var(--spacing-lg);
}
.error-state {
padding: var(--spacing-md);
}
/* Post list */
.post-list {
padding-top: var(--spacing-sm);
}
/* Toast */
.fork-toast {
position: fixed;
bottom: calc(72px + var(--spacing-md));
left: 50%;
transform: translateX(-50%);
z-index: 300;
white-space: nowrap;
box-shadow: var(--shadow-lg);
}
@media (min-width: 769px) {
.fork-toast {
bottom: var(--spacing-lg);
}
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
@media (prefers-reduced-motion: reduce) {
.skeleton-line {
animation: none;
opacity: 0.7;
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: none;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
transform: translateX(-50%);
}
}
</style>

View file

@ -0,0 +1,178 @@
<template>
<article class="community-post-card" :class="`post-type-${post.post_type}`">
<!-- Header row: type badge + date -->
<div class="card-header flex-between gap-sm mb-xs">
<span
class="post-type-badge status-badge"
:class="typeBadgeClass"
:aria-label="`Post type: ${typeLabel}`"
>{{ typeLabel }}</span>
<time
class="post-date text-xs text-muted"
:datetime="post.published"
:title="fullDate"
>{{ shortDate }}</time>
</div>
<!-- Title -->
<h3 class="post-title text-base font-semibold mb-xs">{{ post.title }}</h3>
<!-- Author -->
<p class="post-author text-xs text-muted mb-xs">
by {{ post.pseudonym }}
</p>
<!-- Description (if present) -->
<p v-if="post.description" class="post-description text-sm text-secondary mb-sm">
{{ post.description }}
</p>
<!-- Dietary tag pills -->
<div
v-if="post.dietary_tags.length > 0"
class="tag-row flex flex-wrap gap-xs mb-sm"
role="list"
aria-label="Dietary tags"
>
<span
v-for="tag in post.dietary_tags"
:key="tag"
class="status-badge status-success tag-pill"
role="listitem"
>{{ tag }}</span>
</div>
<!-- Fork button (plan posts only) -->
<div v-if="post.post_type === 'plan'" class="card-actions mt-sm">
<button
class="btn btn-primary btn-sm btn-fork"
:aria-label="`Fork ${post.title} to my meal plan`"
@click="$emit('fork', post.slug)"
>
Fork to my plan
</button>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { CommunityPost } from '../stores/community'
const props = defineProps<{
post: CommunityPost
}>()
defineEmits<{
fork: [slug: string]
}>()
const typeLabel = computed(() => {
switch (props.post.post_type) {
case 'plan': return 'Meal Plan'
case 'recipe_success': return 'Success'
case 'recipe_blooper': return 'Blooper'
default: return props.post.post_type
}
})
const typeBadgeClass = computed(() => {
switch (props.post.post_type) {
case 'plan': return 'status-info'
case 'recipe_success': return 'status-success'
case 'recipe_blooper': return 'status-warning'
default: return 'status-info'
}
})
const shortDate = computed(() => {
try {
return new Date(props.post.published).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
} catch {
return ''
}
})
const fullDate = computed(() => {
try {
return new Date(props.post.published).toLocaleString()
} catch {
return props.post.published
}
})
</script>
<style scoped>
.community-post-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
transition: box-shadow 0.18s ease;
}
.community-post-card:hover {
box-shadow: var(--shadow-md);
}
.post-type-plan { border-left: 3px solid var(--color-info); }
.post-type-recipe_success { border-left: 3px solid var(--color-success); }
.post-type-recipe_blooper { border-left: 3px solid var(--color-warning); }
.card-header {
align-items: center;
}
.post-type-badge,
.post-date {
flex-shrink: 0;
}
.post-title {
margin: 0;
color: var(--color-text-primary);
line-height: 1.3;
}
.post-author,
.post-description {
margin: 0;
}
.post-description {
line-height: 1.5;
}
.tag-pill {
text-transform: lowercase;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.btn-fork {
min-width: 120px;
}
@media (max-width: 480px) {
.community-post-card {
padding: var(--spacing-sm);
border-radius: var(--radius-md);
}
.btn-fork {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.community-post-card {
transition: none;
}
}
</style>

View file

@ -140,11 +140,13 @@ import { ref, computed, onMounted } from 'vue'
const props = defineProps<{ currentTab?: string }>()
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
// Probe once on mount hidden until confirmed enabled so button never flashes
const enabled = ref(false)
onMounted(async () => {
try {
const res = await fetch('/api/v1/feedback/status')
const res = await fetch(`${apiBase}/api/v1/feedback/status`)
if (res.ok) {
const data = await res.json()
enabled.value = data.enabled === true
@ -205,7 +207,7 @@ async function submit() {
loading.value = true
submitError.value = ''
try {
const res = await fetch('/api/v1/feedback', {
const res = await fetch(`${apiBase}/api/v1/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -407,6 +409,114 @@ async function submit() {
.mt-md { margin-top: var(--spacing-md); }
.mt-xs { margin-top: var(--spacing-xs); }
/* ── Form elements ────────────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.form-label {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.form-input {
width: 100%;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--font-size-sm);
line-height: 1.5;
transition: border-color 0.15s;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: var(--color-border-focus);
}
.form-input::placeholder { color: var(--color-text-muted); opacity: 0.7; }
/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--color-primary);
color: #fff;
border: 1px solid var(--color-primary);
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
.btn-ghost {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border-color: var(--color-border-focus);
}
/* ── Filter chips ─────────────────────────────────────────────────────── */
.filter-chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.btn-chip {
padding: 5px var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn-chip.active,
.btn-chip:hover {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
/* ── Card ─────────────────────────────────────────────────────────────── */
.card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
/* ── Text utilities ───────────────────────────────────────────────────── */
.text-muted { color: var(--color-text-muted); }
.text-sm { font-size: var(--font-size-sm); line-height: 1.5; }
.text-xs { font-size: 0.75rem; line-height: 1.5; }
.font-semibold { font-weight: 600; }
/* Transition */
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }

View file

@ -0,0 +1,182 @@
<template>
<div class="hall-of-chaos-overlay" role="dialog" aria-modal="true" aria-label="Hall of Chaos">
<!-- Header -->
<div class="chaos-header">
<h2 class="chaos-title">HALL OF CHAOS</h2>
<p class="chaos-subtitle text-sm">
Chaos Level: <span class="chaos-level">{{ chaosLevel }}</span>
</p>
<button
class="btn btn-secondary chaos-exit-btn"
aria-label="Exit Hall of Chaos"
@click="$emit('close')"
>
Escape the chaos
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="chaos-loading text-center text-secondary" aria-live="polite" aria-busy="true">
Assembling the chaos...
</div>
<!-- Error -->
<div v-else-if="error" class="chaos-empty text-center text-secondary" role="alert">
The chaos is temporarily indisposed.
</div>
<!-- Empty -->
<div v-else-if="posts.length === 0" class="chaos-empty text-center text-secondary">
<p>No bloopers yet. Be the first to make a glorious mistake.</p>
</div>
<!-- Blooper cards -->
<div v-else class="chaos-grid" aria-label="Blooper posts">
<article
v-for="(post, index) in posts"
:key="post.slug"
class="chaos-card"
:class="`chaos-card--tilt-${(index % 5) + 1}`"
:style="{ '--chaos-border-color': borderColors[index % borderColors.length] }"
>
<p class="chaos-card-author text-xs text-muted">{{ post.pseudonym }}</p>
<h3 class="chaos-card-title text-base font-semibold">{{ post.title }}</h3>
<p v-if="post.outcome_notes" class="chaos-card-notes text-sm text-secondary">
{{ post.outcome_notes }}
</p>
<p v-if="post.recipe_name" class="chaos-card-recipe text-xs text-muted mt-xs">
Recipe: {{ post.recipe_name }}
</p>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import api from '../services/api'
import type { CommunityPost } from '../stores/community'
defineEmits<{ close: [] }>()
const posts = ref<CommunityPost[]>([])
const chaosLevel = ref(0)
const loading = ref(true)
const error = ref(false)
// CSS custom property strings -- no hardcoded hex
const borderColors = [
'var(--color-warning)',
'var(--color-info)',
'var(--color-success)',
'var(--color-error)',
'var(--color-warning)',
]
onMounted(async () => {
try {
const response = await api.get<{ posts: CommunityPost[]; chaos_level: number }>(
'/community/hall-of-chaos'
)
posts.value = response.data.posts
chaosLevel.value = response.data.chaos_level
} catch {
error.value = true
} finally {
loading.value = false
}
})
</script>
<style scoped>
.hall-of-chaos-overlay {
position: absolute;
inset: 0;
z-index: 200;
background: var(--color-bg-primary);
overflow-y: auto;
padding: var(--spacing-md);
border-radius: var(--radius-lg);
}
.chaos-header {
text-align: center;
margin-bottom: var(--spacing-lg);
}
.chaos-title {
font-size: 2rem;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--color-warning);
margin: 0 0 var(--spacing-xs);
text-transform: uppercase;
}
.chaos-subtitle {
color: var(--color-text-secondary);
margin: 0 0 var(--spacing-sm);
}
.chaos-level {
font-weight: 700;
color: var(--color-warning);
}
.chaos-exit-btn {
font-size: var(--font-size-xs);
}
.chaos-loading,
.chaos-empty {
padding: var(--spacing-xl);
}
.chaos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--spacing-md);
padding-bottom: var(--spacing-lg);
}
/* Static tilts applied once at render -- not animations, no reduced-motion concern */
.chaos-card {
background: var(--color-bg-card);
border: 2px solid var(--chaos-border-color, var(--color-border));
border-radius: var(--radius-lg);
padding: var(--spacing-md);
}
.chaos-card--tilt-1 { transform: rotate(-3deg); }
.chaos-card--tilt-2 { transform: rotate(2deg); }
.chaos-card--tilt-3 { transform: rotate(-1.5deg); }
.chaos-card--tilt-4 { transform: rotate(4deg); }
.chaos-card--tilt-5 { transform: rotate(-4.5deg); }
.chaos-card-title {
margin: var(--spacing-xs) 0;
color: var(--color-text-primary);
}
.chaos-card-author,
.chaos-card-notes,
.chaos-card-recipe {
margin: 0;
}
@media (max-width: 480px) {
.chaos-grid {
grid-template-columns: 1fr;
}
.chaos-card--tilt-1,
.chaos-card--tilt-2,
.chaos-card--tilt-3,
.chaos-card--tilt-4,
.chaos-card--tilt-5 {
transform: none;
}
}
</style>

View file

@ -0,0 +1,365 @@
<template>
<Teleport to="body">
<div
class="modal-overlay"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
class="modal-panel card"
role="dialog"
aria-modal="true"
aria-labelledby="publish-outcome-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex-between mb-md">
<h2 id="publish-outcome-title" class="section-title">
Share a recipe story
<span v-if="recipeName" class="recipe-name-hint text-sm text-muted">
-- {{ recipeName }}
</span>
</h2>
<button
class="btn-close"
aria-label="Close"
@click="$emit('close')"
>&#x2715;</button>
</div>
<!-- Post type selector -->
<div class="form-group">
<fieldset class="type-fieldset">
<legend class="form-label">What kind of story is this?</legend>
<div class="type-toggle flex gap-sm">
<button
ref="firstFocusRef"
:class="['btn', 'type-btn', postType === 'recipe_success' ? 'type-btn-active' : 'btn-secondary']"
:aria-pressed="postType === 'recipe_success'"
@click="postType = 'recipe_success'"
>
Success
</button>
<button
:class="['btn', 'type-btn', postType === 'recipe_blooper' ? 'type-btn-active type-btn-blooper' : 'btn-secondary']"
:aria-pressed="postType === 'recipe_blooper'"
@click="postType = 'recipe_blooper'"
>
Blooper
</button>
</div>
</fieldset>
</div>
<!-- Title field -->
<div class="form-group">
<label class="form-label" for="outcome-title">
Title <span class="required-mark" aria-hidden="true">*</span>
</label>
<input
id="outcome-title"
v-model="title"
class="form-input"
type="text"
maxlength="200"
placeholder="e.g. Perfect crust on the first try"
autocomplete="off"
required
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ title.length }}/200
</span>
</div>
<!-- Outcome notes field -->
<div class="form-group">
<label class="form-label" for="outcome-notes">
What happened? <span class="optional-mark">(optional)</span>
</label>
<textarea
id="outcome-notes"
v-model="outcomeNotes"
class="form-input form-textarea"
maxlength="2000"
rows="4"
placeholder="Describe what you tried, what worked, or what went sideways."
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ outcomeNotes.length }}/2000
</span>
</div>
<!-- Pseudonym field -->
<div class="form-group">
<label class="form-label" for="outcome-pseudonym">
Community name <span class="optional-mark">(optional)</span>
</label>
<input
id="outcome-pseudonym"
v-model="pseudonymName"
class="form-input"
type="text"
maxlength="40"
placeholder="Leave blank to use your existing handle"
autocomplete="nickname"
/>
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
</div>
<!-- Submission feedback (aria-live region, always rendered) -->
<div
class="feedback-region"
aria-live="polite"
aria-atomic="true"
>
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
</div>
<!-- Footer actions -->
<div class="modal-footer flex gap-sm">
<button
class="btn btn-primary"
:disabled="submitting || !title.trim()"
:aria-busy="submitting"
@click="onSubmit"
>
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
{{ submitting ? 'Publishing...' : 'Publish' }}
</button>
<button class="btn btn-secondary" @click="$emit('close')">
Cancel
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { PublishPayload } from '../stores/community'
const props = defineProps<{
recipeId: number | null
recipeName: string | null
visible?: boolean
}>()
const emit = defineEmits<{
close: []
published: [payload: { slug: string }]
}>()
const store = useCommunityStore()
const postType = ref<'recipe_success' | 'recipe_blooper'>('recipe_success')
const title = ref('')
const outcomeNotes = ref('')
const pseudonymName = ref('')
const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref<string | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const firstFocusRef = ref<HTMLButtonElement | null>(null)
let previousFocus: HTMLElement | null = null
function getFocusables(): HTMLElement[] {
if (!dialogRef.value) return []
return Array.from(
dialogRef.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (e.key !== 'Tab') return
// Only intercept Tab when focus is inside this dialog
if (!dialogRef.value?.contains(document.activeElement)) return
const focusables = getFocusables()
if (focusables.length === 0) return
const first = focusables[0]!
const last = focusables[focusables.length - 1]!
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
(firstFocusRef.value ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
async function onSubmit() {
submitError.value = null
submitSuccess.value = null
if (!title.value.trim()) return
const payload: PublishPayload = {
post_type: postType.value,
title: title.value.trim(),
}
if (outcomeNotes.value.trim()) payload.outcome_notes = outcomeNotes.value.trim()
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
if (props.recipeId != null) payload.recipe_id = props.recipeId
submitting.value = true
try {
const result = await store.publishPost(payload)
submitSuccess.value = 'Your story has been posted.'
nextTick(() => {
emit('published', { slug: result.slug })
})
} catch (err: unknown) {
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 400;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.btn-close {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-secondary);
padding: var(--spacing-xs);
line-height: 1;
border-radius: var(--radius-sm);
}
.btn-close:hover {
color: var(--color-text-primary);
}
.recipe-name-hint {
font-style: italic;
}
.required-mark {
color: var(--color-error);
margin-left: 2px;
}
.optional-mark {
font-weight: 400;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.type-fieldset {
border: none;
padding: 0;
margin: 0;
}
.type-toggle {
flex-wrap: wrap;
}
.type-btn {
min-width: 100px;
}
.type-btn-active {
background: var(--color-success);
color: white;
border-color: var(--color-success);
font-weight: 700;
}
.type-btn-active.type-btn-blooper {
background: var(--color-warning);
border-color: var(--color-warning);
color: var(--color-text-primary);
}
.char-counter {
text-align: right;
display: block;
margin-top: var(--spacing-xs);
}
.feedback-region {
min-height: 1.4rem;
margin-bottom: var(--spacing-xs);
}
.feedback-error {
color: var(--color-error);
margin: 0;
}
.feedback-success {
color: var(--color-success);
margin: 0;
}
.modal-footer {
justify-content: flex-start;
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-md);
flex-wrap: wrap;
}
@media (max-width: 480px) {
.modal-panel {
max-height: 95vh;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}
</style>

View file

@ -0,0 +1,311 @@
<template>
<Teleport to="body">
<div
class="modal-overlay"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
class="modal-panel card"
role="dialog"
aria-modal="true"
aria-labelledby="publish-plan-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex-between mb-md">
<h2 id="publish-plan-title" class="section-title">Share this week's plan</h2>
<button
class="btn-close"
aria-label="Close"
@click="$emit('close')"
>&#x2715;</button>
</div>
<!-- Title field -->
<div class="form-group">
<label class="form-label" for="plan-pub-title">
Title <span class="required-mark" aria-hidden="true">*</span>
</label>
<input
id="plan-pub-title"
ref="firstFocusRef"
v-model="title"
class="form-input"
type="text"
maxlength="200"
placeholder="e.g. Mediterranean Week"
autocomplete="off"
required
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ title.length }}/200
</span>
</div>
<!-- Description field -->
<div class="form-group">
<label class="form-label" for="plan-pub-desc">
Description <span class="optional-mark">(optional)</span>
</label>
<textarea
id="plan-pub-desc"
v-model="description"
class="form-input form-textarea"
maxlength="2000"
rows="3"
placeholder="What makes this week worth sharing?"
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ description.length }}/2000
</span>
</div>
<!-- Pseudonym field -->
<div class="form-group">
<label class="form-label" for="plan-pub-pseudonym">
Community name <span class="optional-mark">(optional)</span>
</label>
<input
id="plan-pub-pseudonym"
v-model="pseudonymName"
class="form-input"
type="text"
maxlength="40"
placeholder="Leave blank to use your existing handle"
autocomplete="nickname"
/>
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
</div>
<!-- Submission feedback (aria-live region, always rendered) -->
<div
class="feedback-region"
aria-live="polite"
aria-atomic="true"
>
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
</div>
<!-- Footer actions -->
<div class="modal-footer flex gap-sm">
<button
class="btn btn-primary"
:disabled="submitting || !title.trim()"
:aria-busy="submitting"
@click="onSubmit"
>
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
{{ submitting ? 'Publishing...' : 'Publish' }}
</button>
<button class="btn btn-secondary" @click="$emit('close')">
Cancel
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { PublishPayload } from '../stores/community'
const props = defineProps<{
plan?: {
id: number
week_start: string
slots: Array<{ day: string; meal_type: string; recipe_id: number; recipe_name: string }>
} | null
visible?: boolean
}>()
const emit = defineEmits<{
close: []
published: [payload: { slug: string }]
}>()
const store = useCommunityStore()
const title = ref('')
const description = ref('')
const pseudonymName = ref('')
const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref<string | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const firstFocusRef = ref<HTMLInputElement | null>(null)
let previousFocus: HTMLElement | null = null
function getFocusables(): HTMLElement[] {
if (!dialogRef.value) return []
return Array.from(
dialogRef.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (e.key !== 'Tab') return
// Only intercept Tab when focus is inside this dialog
if (!dialogRef.value?.contains(document.activeElement)) return
const focusables = getFocusables()
if (focusables.length === 0) return
const first = focusables[0]!
const last = focusables[focusables.length - 1]!
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
(firstFocusRef.value ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
async function onSubmit() {
submitError.value = null
submitSuccess.value = null
if (!title.value.trim()) return
const payload: PublishPayload = {
post_type: 'plan',
title: title.value.trim(),
}
if (description.value.trim()) payload.description = description.value.trim()
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
if (props.plan?.id != null) payload.plan_id = props.plan.id
if (props.plan?.slots?.length) {
payload.slots = props.plan.slots.map(({ day, meal_type, recipe_id }) => ({ day, meal_type, recipe_id }))
}
submitting.value = true
try {
const result = await store.publishPost(payload)
submitSuccess.value = 'Plan published to the community feed.'
nextTick(() => {
emit('published', { slug: result.slug })
})
} catch (err: unknown) {
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 400;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.btn-close {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-secondary);
padding: var(--spacing-xs);
line-height: 1;
border-radius: var(--radius-sm);
}
.btn-close:hover {
color: var(--color-text-primary);
}
.required-mark {
color: var(--color-error);
margin-left: 2px;
}
.optional-mark {
font-weight: 400;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.char-counter {
text-align: right;
display: block;
margin-top: var(--spacing-xs);
}
.feedback-region {
min-height: 1.4rem;
margin-bottom: var(--spacing-xs);
}
.feedback-error {
color: var(--color-error);
margin: 0;
}
.feedback-success {
color: var(--color-success);
margin: 0;
}
.modal-footer {
justify-content: flex-start;
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-md);
flex-wrap: wrap;
}
@media (max-width: 480px) {
.modal-panel {
max-height: 95vh;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}
</style>

View file

@ -161,14 +161,21 @@
<div class="add-pantry-col">
<p v-if="addError" role="alert" aria-live="assertive" class="add-error text-xs">{{ addError }}</p>
<p v-if="addedToPantry" role="status" aria-live="polite" class="add-success text-xs"> Added to pantry!</p>
<button
class="btn btn-accent flex-1"
:disabled="addingToPantry"
@click="addToPantry"
>
<span v-if="addingToPantry">Adding</span>
<span v-else>Add {{ checkedCount }} to pantry</span>
</button>
<div class="grocery-actions">
<button
class="btn btn-primary flex-1"
@click="copyGroceryList"
>{{ groceryCopied ? '✓ Copied!' : `📋 Grocery list (${checkedCount})` }}</button>
<button
class="btn btn-secondary"
:disabled="addingToPantry"
@click="addToPantry"
:title="`Add ${checkedCount} item${checkedCount !== 1 ? 's' : ''} to your pantry`"
>
<span v-if="addingToPantry">Adding</span>
<span v-else>+ Pantry</span>
</button>
</div>
</div>
</template>
<button v-else class="btn btn-primary flex-1" @click="handleCook">
@ -246,6 +253,7 @@ const checkedIngredients = ref<Set<string>>(new Set())
const addingToPantry = ref(false)
const addedToPantry = ref(false)
const addError = ref<string | null>(null)
const groceryCopied = ref(false)
const checkedCount = computed(() => checkedIngredients.value.size)
@ -300,6 +308,19 @@ async function shareList() {
}
}
async function copyGroceryList() {
const items = [...checkedIngredients.value]
if (!items.length) return
const text = `Shopping list for ${props.recipe.title}:\n${items.map((i) => `${i}`).join('\n')}`
if (navigator.share) {
await navigator.share({ title: `Shopping list: ${props.recipe.title}`, text })
} else {
await navigator.clipboard.writeText(text)
groceryCopied.value = true
setTimeout(() => { groceryCopied.value = false }, 2000)
}
}
function groceryLinkFor(ingredient: string): GroceryLink | undefined {
const needle = ingredient.toLowerCase()
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
@ -577,6 +598,12 @@ function handleCook() {
gap: 2px;
}
.grocery-actions {
display: flex;
gap: var(--spacing-xs);
align-items: stretch;
}
.add-error {
color: var(--color-error, #dc2626);
}

View file

@ -21,6 +21,7 @@
v-if="activeTab === 'browse'"
role="tabpanel"
aria-labelledby="tab-browse"
tabindex="0"
@open-recipe="openRecipeById"
/>
@ -29,11 +30,29 @@
v-else-if="activeTab === 'saved'"
role="tabpanel"
aria-labelledby="tab-saved"
tabindex="0"
@open-recipe="openRecipeById"
/>
<!-- Community tab -->
<CommunityFeedPanel
v-else-if="activeTab === 'community'"
role="tabpanel"
aria-labelledby="tab-community"
tabindex="0"
@plan-forked="onPlanForked"
/>
<!-- Build Your Own tab -->
<BuildYourOwnTab
v-else-if="activeTab === 'build'"
role="tabpanel"
aria-labelledby="tab-build"
tabindex="0"
/>
<!-- Find tab (existing search UI) -->
<div v-else role="tabpanel" aria-labelledby="tab-find">
<div v-else ref="findPanelRef" role="tabpanel" aria-labelledby="tab-find" tabindex="0">
<!-- Controls Panel -->
<div class="card mb-controls">
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
@ -47,6 +66,7 @@
:key="lvl.value"
:class="['btn', 'btn-secondary', { active: recipesStore.level === lvl.value }]"
@click="recipesStore.level = lvl.value"
:aria-pressed="recipesStore.level === lvl.value"
:title="lvl.description"
>
{{ lvl.label }}
@ -77,8 +97,8 @@
</button>
<!-- Dietary Preferences (collapsible) -->
<details class="collapsible form-group">
<summary class="collapsible-summary filter-summary">
<details class="collapsible form-group" @toggle="(e: Event) => dietaryOpen = (e.target as HTMLDetailsElement).open">
<summary class="collapsible-summary filter-summary" :aria-expanded="dietaryOpen">
Dietary preferences
<span v-if="dietaryActive" class="filter-active-dot" aria-label="filters active"></span>
</summary>
@ -154,8 +174,9 @@
<!-- Max Missing hidden in shopping mode -->
<div v-if="!recipesStore.shoppingMode" class="form-group">
<label class="form-label">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
<label class="form-label" for="max-missing">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
<input
id="max-missing"
type="number"
class="form-input"
min="0"
@ -169,8 +190,8 @@
</details>
<!-- Advanced Filters (collapsible) -->
<details class="collapsible form-group">
<summary class="collapsible-summary filter-summary">
<details class="collapsible form-group" @toggle="(e: Event) => advancedOpen = (e.target as HTMLDetailsElement).open">
<summary class="collapsible-summary filter-summary" :aria-expanded="advancedOpen">
Advanced filters
<span v-if="advancedActive" class="filter-active-dot" aria-label="filters active"></span>
</summary>
@ -181,26 +202,26 @@
<label class="form-label">Nutrition limits <span class="text-muted text-xs">(per recipe, optional)</span></label>
<div class="nutrition-filters-grid mt-xs">
<div class="form-group">
<label class="form-label">Max Calories</label>
<input type="number" class="form-input" min="0" placeholder="e.g. 600"
<label class="form-label" for="filter-max-cal">Max Calories</label>
<input id="filter-max-cal" type="number" class="form-input" min="0" placeholder="e.g. 600"
:value="recipesStore.nutritionFilters.max_calories ?? ''"
@input="onNutritionInput('max_calories', $event)" />
</div>
<div class="form-group">
<label class="form-label">Max Sugar (g)</label>
<input type="number" class="form-input" min="0" placeholder="e.g. 10"
<label class="form-label" for="filter-max-sugar">Max Sugar (g)</label>
<input id="filter-max-sugar" type="number" class="form-input" min="0" placeholder="e.g. 10"
:value="recipesStore.nutritionFilters.max_sugar_g ?? ''"
@input="onNutritionInput('max_sugar_g', $event)" />
</div>
<div class="form-group">
<label class="form-label">Max Carbs (g)</label>
<input type="number" class="form-input" min="0" placeholder="e.g. 50"
<label class="form-label" for="filter-max-carbs">Max Carbs (g)</label>
<input id="filter-max-carbs" type="number" class="form-input" min="0" placeholder="e.g. 50"
:value="recipesStore.nutritionFilters.max_carbs_g ?? ''"
@input="onNutritionInput('max_carbs_g', $event)" />
</div>
<div class="form-group">
<label class="form-label">Max Sodium (mg)</label>
<input type="number" class="form-input" min="0" placeholder="e.g. 800"
<label class="form-label" for="filter-max-sodium">Max Sodium (mg)</label>
<input id="filter-max-sodium" type="number" class="form-input" min="0" placeholder="e.g. 800"
:value="recipesStore.nutritionFilters.max_sodium_mg ?? ''"
@input="onNutritionInput('max_sodium_mg', $event)" />
</div>
@ -219,14 +240,16 @@
:key="style.id"
:class="['btn', 'btn-secondary', 'btn-sm', { active: recipesStore.styleId === style.id }]"
@click="recipesStore.styleId = recipesStore.styleId === style.id ? null : style.id"
:aria-pressed="recipesStore.styleId === 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>
<label class="form-label" for="adv-category">Category <span class="text-muted text-xs">(optional)</span></label>
<input
id="adv-category"
class="form-input"
v-model="categoryInput"
placeholder="e.g. Breakfast, Asian, Chicken, &lt; 30 Mins"
@ -264,26 +287,27 @@
</div>
<!-- Error -->
<div v-if="recipesStore.error" class="status-badge status-error mb-md">
<div v-if="recipesStore.error" role="alert" class="status-badge status-error mb-md">
{{ recipesStore.error }}
</div>
<!-- Screen reader announcement when results load -->
<!-- Screen reader announcement for loading + results -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<span v-if="recipesStore.result && !recipesStore.loading">
<span v-if="recipesStore.loading">Finding recipes</span>
<span v-else-if="recipesStore.result">
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
</span>
</div>
<!-- Results -->
<div v-if="recipesStore.result" class="results-section fade-in">
<div v-if="recipesStore.result" class="results-section fade-in" :aria-busy="recipesStore.loading">
<!-- Rate limit warning -->
<div
v-if="recipesStore.result.rate_limited"
class="status-badge status-warning rate-limit-banner mb-md"
>
You've used your {{ recipesStore.result.rate_limit_count }} free suggestions today. Upgrade for
unlimited.
Today's free suggestions are used up. Your limit resets tomorrow, or
<a href="#" class="link-inline" @click.prevent="$emit('upgrade')">upgrade for unlimited access</a>.
</div>
<!-- Element gaps -->
@ -312,22 +336,26 @@
v-for="lvl in availableLevels"
:key="lvl"
:class="['filter-chip', { active: filterLevel === lvl }]"
:aria-pressed="filterLevel === lvl"
@click="filterLevel = filterLevel === lvl ? null : lvl"
>Lv{{ lvl }}</button>
>{{ levelLabels[lvl] ?? `Level ${lvl}` }}</button>
</template>
<button
:class="['filter-chip', { active: filterMissing === 0 }]"
:aria-pressed="filterMissing === 0"
@click="filterMissing = filterMissing === 0 ? null : 0"
>Can make now</button>
<button
:class="['filter-chip', { active: filterMissing === 2 }]"
:aria-pressed="filterMissing === 2"
@click="filterMissing = filterMissing === 2 ? null : 2"
>2 missing</button>
<button
v-if="hasActiveFilters"
class="filter-chip filter-chip-clear"
aria-label="Clear all recipe filters"
@click="clearFilters"
> Clear</button>
><span aria-hidden="true"></span> Clear</button>
</div>
</div>
@ -340,7 +368,7 @@
<p>No recipes match your filters.</p>
<button class="btn btn-ghost btn-sm mt-xs" @click="clearFilters">Clear filters</button>
</template>
<p v-else>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p>
<p v-else>We didn't find matches at this level. Try <button class="btn btn-ghost btn-sm" @click="recipesStore.level = 1; handleSuggest()">Level 1 Use What I Have</button> or adjust your filters.</p>
</div>
<!-- Recipe Cards -->
@ -390,28 +418,28 @@
<!-- Nutrition chips -->
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm">
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">
🔥 {{ Math.round(recipe.nutrition.calories) }} kcal
<span aria-hidden="true">🔥</span><span class="sr-only">Calories:</span> {{ Math.round(recipe.nutrition.calories) }} kcal
</span>
<span v-if="recipe.nutrition.fat_g != null" class="nutrition-chip">
🧈 {{ recipe.nutrition.fat_g.toFixed(1) }}g fat
<span aria-hidden="true">🧈</span><span class="sr-only">Fat:</span> {{ recipe.nutrition.fat_g.toFixed(1) }}g fat
</span>
<span v-if="recipe.nutrition.protein_g != null" class="nutrition-chip">
💪 {{ recipe.nutrition.protein_g.toFixed(1) }}g protein
<span aria-hidden="true">💪</span><span class="sr-only">Protein:</span> {{ recipe.nutrition.protein_g.toFixed(1) }}g protein
</span>
<span v-if="recipe.nutrition.carbs_g != null" class="nutrition-chip">
🌾 {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs
<span aria-hidden="true">🌾</span><span class="sr-only">Carbs:</span> {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs
</span>
<span v-if="recipe.nutrition.fiber_g != null" class="nutrition-chip">
🌿 {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber
<span aria-hidden="true">🌿</span><span class="sr-only">Fiber:</span> {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber
</span>
<span v-if="recipe.nutrition.sugar_g != null" class="nutrition-chip nutrition-chip-sugar">
🍬 {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar
<span aria-hidden="true">🍬</span><span class="sr-only">Sugar:</span> {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar
</span>
<span v-if="recipe.nutrition.sodium_mg != null" class="nutrition-chip">
🧂 {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium
<span aria-hidden="true">🧂</span><span class="sr-only">Sodium:</span> {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium
</span>
<span v-if="recipe.nutrition.servings != null" class="nutrition-chip nutrition-chip-servings">
🍽 {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
<span aria-hidden="true">🍽</span><span class="sr-only">Servings:</span> {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
</span>
<span v-if="recipe.nutrition.estimated" class="nutrition-chip nutrition-chip-estimated" title="Estimated from ingredient profiles">
~ estimated
@ -420,7 +448,7 @@
<!-- Missing ingredients -->
<div v-if="recipe.missing_ingredients.length > 0" class="mb-sm">
<p class="text-sm font-semibold text-warning">You'd need:</p>
<p class="text-sm font-semibold text-secondary">You'd need:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="ing in recipe.missing_ingredients"
@ -448,8 +476,12 @@
</div>
<!-- Swap candidates collapsible -->
<details v-if="recipe.swap_candidates.length > 0" class="collapsible mb-sm">
<summary class="text-sm font-semibold collapsible-summary">
<details
v-if="recipe.swap_candidates.length > 0"
class="collapsible mb-sm"
@toggle="(e: Event) => toggleSwapOpen(recipe.id, (e.target as HTMLDetailsElement).open)"
>
<summary class="text-sm font-semibold collapsible-summary" :aria-expanded="openSwapIds.has(recipe.id)">
Possible swaps ({{ recipe.swap_candidates.length }})
</summary>
<div class="card-secondary mt-xs">
@ -510,6 +542,16 @@
</button>
</div>
<!-- Soft Build Your Own nudge when corpus results are sparse -->
<div
v-if="!recipesStore.loading && filteredSuggestions.length <= 2 && recipesStore.result"
class="byo-nudge text-sm text-secondary mt-md"
>
Not finding what you want?
<button class="btn-link" @click="activeTab = 'build'">Try Build Your Own</button>
to make something from scratch.
</div>
</div>
<!-- Recipe detail panel mounts as a full-screen overlay -->
@ -536,6 +578,11 @@
</div><!-- end Find tab -->
<!-- Recipe load error announced to screen readers via aria-live -->
<div v-if="browserRecipeError" role="alert" class="status-badge status-error mb-sm">
{{ browserRecipeError }}
</div>
<!-- Detail panel for browser/saved recipe lookups -->
<RecipeDetailPanel
v-if="browserSelectedRecipe"
@ -548,12 +595,16 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRecipesStore } from '../stores/recipes'
import { useInventoryStore } from '../stores/inventory'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import RecipeDetailPanel from './RecipeDetailPanel.vue'
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
import SavedRecipesPanel from './SavedRecipesPanel.vue'
import CommunityFeedPanel from './CommunityFeedPanel.vue'
import BuildYourOwnTab from './BuildYourOwnTab.vue'
import type { ForkResult } from '../stores/community'
import type { RecipeSuggestion, GroceryLink } from '../services/api'
import { recipesAPI } from '../services/api'
@ -561,37 +612,82 @@ const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore()
// Tab state
type TabId = 'find' | 'browse' | 'saved'
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
const tabs: Array<{ id: TabId; label: string }> = [
{ id: 'find', label: 'Find' },
{ id: 'browse', label: 'Browse' },
{ id: 'saved', label: 'Saved' },
{ id: 'saved', label: 'Saved' },
{ id: 'build', label: 'Build Your Own' },
{ id: 'community', label: 'Community' },
{ id: 'find', label: 'Find' },
{ id: 'browse', label: 'Browse' },
]
const activeTab = ref<TabId>('find')
const activeTab = ref<TabId>('saved')
const savedStore = useSavedRecipesStore()
// Template ref for the Find-tab panel div (used for focus management on tab switch)
const findPanelRef = ref<HTMLElement | null>(null)
function onTabKeydown(e: KeyboardEvent) {
const tabIds: TabId[] = ['find', 'browse', 'saved']
const tabIds: TabId[] = ['saved', 'build', 'community', 'find', 'browse']
const current = tabIds.indexOf(activeTab.value)
if (e.key === 'ArrowRight') {
e.preventDefault()
activeTab.value = tabIds[(current + 1) % tabIds.length]!
nextTick(() => {
// Move focus to the newly active panel so keyboard users don't have to Tab
// through the entire tab bar again to reach the panel content
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
panel?.focus()
})
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
activeTab.value = tabIds[(current - 1 + tabIds.length) % tabIds.length]!
nextTick(() => {
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
panel?.focus()
})
}
}
// Community tab: navigate to Find tab after a plan fork (full plan view deferred to Task 9)
function onPlanForked(_payload: ForkResult) {
activeTab.value = 'find'
}
// Browser/saved tab recipe detail panel (fetches full recipe from API)
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)
const browserRecipeError = ref<string | null>(null)
async function openRecipeById(recipeId: number) {
browserRecipeError.value = null
try {
browserSelectedRecipe.value = await recipesAPI.getRecipe(recipeId)
} catch {
// silently ignore recipe may not exist
browserRecipeError.value = 'Could not load this recipe. Please try again.'
}
}
// Collapsible panel open state kept in sync with DOM toggle event so
// :aria-expanded on <summary> reflects the actual open/closed state
const dietaryOpen = ref(false)
const advancedOpen = ref(false)
// Per-recipe swap section open tracking (Set of recipe IDs whose swap <details> are open)
// Uses reassignment instead of .add()/.delete() so Vue's ref() detects the change
const openSwapIds = ref<Set<number>>(new Set())
function toggleSwapOpen(recipeId: number, isOpen: boolean) {
const next = new Set(openSwapIds.value)
isOpen ? next.add(recipeId) : next.delete(recipeId)
openSwapIds.value = next
}
// Human-readable level labels for filter chips (avoids "Lv1" etc. for screen readers)
const levelLabels: Record<number, string> = {
1: 'Use What I Have',
2: 'Allow Swaps',
3: 'Get Creative',
4: 'Surprise Me',
}
// Local input state for tags
const constraintInput = ref('')
const allergyInput = ref('')
@ -825,10 +921,37 @@ onMounted(async () => {
if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
// Pre-load saved recipes so we know immediately whether to redirect
await savedStore.load()
})
// If Saved tab is empty after loading, bounce to Build Your Own
watch(
() => ({ loading: savedStore.loading, count: savedStore.saved.length }),
({ loading, count }) => {
if (!loading && count === 0 && activeTab.value === 'saved') {
activeTab.value = 'build'
}
},
{ immediate: true },
)
</script>
<style scoped>
.byo-nudge {
padding: var(--spacing-sm) 0;
}
.btn-link {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
text-decoration: underline;
font-size: inherit;
}
.tab-bar {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-sm);
@ -923,7 +1046,10 @@ onMounted(async () => {
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
/* WCAG 2.2 2.5.8: minimum 24×24px touch target */
min-width: 24px;
min-height: 24px;
padding: 4px 6px;
font-size: 12px;
line-height: 1;
color: var(--color-text-muted);
@ -942,7 +1068,10 @@ onMounted(async () => {
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
/* WCAG 2.2 2.5.8: minimum 24×24px touch target */
min-width: 24px;
min-height: 24px;
padding: 4px 6px;
font-size: 14px;
line-height: 1;
color: var(--color-text-muted);

View file

@ -209,7 +209,7 @@ async function submit() {
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
z-index: 500;
padding: var(--spacing-md);
}

View file

@ -503,6 +503,40 @@ export interface Staple {
dietary_tags: string[]
}
// ── Build Your Own types ──────────────────────────────────────────────────
export interface AssemblyRoleOut {
display: string
required: boolean
keywords: string[]
hint: string
}
export interface AssemblyTemplateOut {
id: string
title: string
icon: string
descriptor: string
role_sequence: AssemblyRoleOut[]
}
export interface RoleCandidateItem {
name: string
in_pantry: boolean
tags: string[]
}
export interface RoleCandidatesResponse {
compatible: RoleCandidateItem[]
other: RoleCandidateItem[]
available_tags: string[]
}
export interface BuildRequest {
template_id: string
role_overrides: Record<string, string>
}
// ========== Recipes API ==========
export const recipesAPI = {
@ -518,6 +552,28 @@ export const recipesAPI = {
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
return response.data
},
async getTemplates(): Promise<AssemblyTemplateOut[]> {
const response = await api.get('/recipes/templates')
return response.data
},
async getRoleCandidates(
templateId: string,
role: string,
priorPicks: string[] = [],
): Promise<RoleCandidatesResponse> {
const response = await api.get('/recipes/template-candidates', {
params: {
template_id: templateId,
role,
prior_picks: priorPicks.join(','),
},
})
return response.data
},
async buildRecipe(req: BuildRequest): Promise<RecipeSuggestion> {
const response = await api.post('/recipes/build', req)
return response.data
},
}
// ========== Settings API ==========

View file

@ -0,0 +1,119 @@
/**
* Community Store
*
* Manages community post feed state and fork actions using Pinia.
* Follows the composition store pattern established in recipes.ts.
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '../services/api'
// ========== Types ==========
export interface CommunityPostSlot {
day: string
meal_type: string
recipe_id: number
}
export interface ElementProfiles {
seasoning_score: number | null
richness_score: number | null
brightness_score: number | null
depth_score: number | null
aroma_score: number | null
structure_score: number | null
texture_profile: string | null
}
export interface CommunityPost {
slug: string
pseudonym: string
post_type: 'plan' | 'recipe_success' | 'recipe_blooper'
published: string
title: string
description: string | null
photo_url: string | null
slots: CommunityPostSlot[]
recipe_id: number | null
recipe_name: string | null
level: number | null
outcome_notes: string | null
element_profiles: ElementProfiles
dietary_tags: string[]
allergen_flags: string[]
flavor_molecules: string[]
fat_pct: number | null
protein_pct: number | null
moisture_pct: number | null
}
export interface ForkResult {
plan_id: number
week_start: string
forked_from: string
}
export interface PublishPayload {
post_type: 'plan' | 'recipe_success' | 'recipe_blooper'
title: string
description?: string
pseudonym_name?: string
plan_id?: number
recipe_id?: number
outcome_notes?: string
slots?: CommunityPostSlot[]
}
export interface PublishResult {
slug: string
}
// ========== Store ==========
export const useCommunityStore = defineStore('community', () => {
const posts = ref<CommunityPost[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const currentFilter = ref<string | null>(null)
async function fetchPosts(postType?: string) {
loading.value = true
error.value = null
currentFilter.value = postType ?? null
try {
const params: Record<string, string | number> = { page: 1, page_size: 40 }
if (postType) {
params.post_type = postType
}
const response = await api.get<{ posts: CommunityPost[] }>('/community/posts', { params })
posts.value = response.data.posts
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Could not load community posts.'
} finally {
loading.value = false
}
}
async function forkPost(slug: string): Promise<ForkResult> {
const response = await api.post<ForkResult>(`/community/posts/${slug}/fork`)
return response.data
}
async function publishPost(payload: PublishPayload): Promise<PublishResult> {
const response = await api.post<PublishResult>('/community/posts', payload)
return response.data
}
return {
posts,
loading,
error,
currentFilter,
fetchPosts,
forkPost,
publishPost,
}
})

View file

@ -6,7 +6,7 @@
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type NutritionFilters } from '../services/api'
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
@ -18,6 +18,12 @@ const COOK_LOG_MAX = 200
const BOOKMARKS_KEY = 'kiwi:bookmarks'
const BOOKMARKS_MAX = 50
const MISSING_MODE_KEY = 'kiwi:builder_missing_mode'
const FILTER_MODE_KEY = 'kiwi:builder_filter_mode'
type MissingIngredientMode = 'hidden' | 'greyed' | 'add-to-cart'
type BuilderFilterMode = 'text' | 'tags'
// [id, dismissedAtMs]
type DismissEntry = [number, number]
@ -71,6 +77,16 @@ function saveBookmarks(bookmarks: RecipeSuggestion[]) {
localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(bookmarks.slice(0, BOOKMARKS_MAX)))
}
function loadMissingMode(): MissingIngredientMode {
const raw = localStorage.getItem(MISSING_MODE_KEY)
if (raw === 'hidden' || raw === 'greyed' || raw === 'add-to-cart') return raw
return 'greyed'
}
function loadFilterMode(): BuilderFilterMode {
return localStorage.getItem(FILTER_MODE_KEY) === 'tags' ? 'tags' : 'text'
}
export const useRecipesStore = defineStore('recipes', () => {
// Suggestion result state
const result = ref<RecipeResult | null>(null)
@ -103,6 +119,14 @@ export const useRecipesStore = defineStore('recipes', () => {
// Bookmarks: full RecipeSuggestion snapshots, max BOOKMARKS_MAX
const bookmarks = ref<RecipeSuggestion[]>(loadBookmarks())
// Build Your Own wizard preferences -- persisted across sessions
const missingIngredientMode = ref<MissingIngredientMode>(loadMissingMode())
const builderFilterMode = ref<BuilderFilterMode>(loadFilterMode())
// Persist wizard prefs on change
watch(missingIngredientMode, (val) => localStorage.setItem(MISSING_MODE_KEY, val))
watch(builderFilterMode, (val) => localStorage.setItem(FILTER_MODE_KEY, val))
const dismissedCount = computed(() => dismissedIds.value.size)
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
@ -246,6 +270,8 @@ export const useRecipesStore = defineStore('recipes', () => {
isBookmarked,
toggleBookmark,
clearBookmarks,
missingIngredientMode,
builderFilterMode,
suggest,
loadMore,
dismiss,

View file

@ -18,7 +18,8 @@
/* Theme Colors - Dark Mode (Default) */
--color-text-primary: rgba(255, 248, 235, 0.92);
--color-text-secondary: rgba(255, 248, 235, 0.60);
--color-text-muted: rgba(255, 248, 235, 0.38);
/* Raised from 0.38 → 0.52 for WCAG 1.4.3 AA compliance (~5.5:1 against card bg) */
--color-text-muted: rgba(255, 248, 235, 0.52);
--color-bg-primary: #1e1c1a;
--color-bg-secondary: #161412;
@ -40,7 +41,8 @@
/* Status Colors */
--color-success: #4a8c40;
--color-success-dark: #3a7030;
--color-success-light: #6aac60;
/* Lightened from #6aac60 → #7fc073 for WCAG 1.4.3 AA compliance on dark backgrounds */
--color-success-light: #7fc073;
--color-success-bg: rgba(74, 140, 64, 0.12);
--color-success-border: rgba(74, 140, 64, 0.30);

View file

@ -22,6 +22,8 @@ dependencies = [
# HTTP clients
"httpx>=0.27",
"requests>=2.31",
# mDNS advertisement (optional; user must opt in)
"zeroconf>=0.131",
# CircuitForge shared scaffold
"circuitforge-core>=0.8.0",
]

View file

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
Backfill keywords column: repair character-split R-vector data.
The food.com corpus was imported with Keywords stored as a JSON array of
individual characters (e.g. ["c","(","\"","I","t","a","l","i","a","n",...])
instead of the intended keyword list (e.g. ["Italian","Low-Fat","Easy"]).
This script detects the broken pattern (all array elements have length 1),
rejoins them into the original R-vector string, parses quoted tokens, and
writes the corrected JSON back.
Rows that are already correct (empty array, or multi-char strings) are skipped.
FTS5 index is rebuilt after the update so searches reflect the fix.
Usage:
conda run -n cf python scripts/backfill_keywords.py [path/to/kiwi.db]
# default: data/kiwi.db
Estimated time on 3.1M rows: 3-8 minutes (mostly the FTS rebuild at the end).
"""
from __future__ import annotations
import json
import re
import sqlite3
import sys
from pathlib import Path
_QUOTED = re.compile(r'"([^"]*)"')
def _parse_r_vector(s: str) -> list[str]:
return _QUOTED.findall(s)
def _repair(raw_json: str) -> str | None:
"""Return corrected JSON string, or None if the row is already clean."""
try:
val = json.loads(raw_json)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(val, list) or not val:
return None # empty or non-list — leave as-is
# Already correct: contains multi-character strings
if any(isinstance(e, str) and len(e) > 1 for e in val):
return None
# Broken: all single characters — rejoin and re-parse
if all(isinstance(e, str) and len(e) == 1 for e in val):
rejoined = "".join(val)
keywords = _parse_r_vector(rejoined)
return json.dumps(keywords)
return None
def backfill(db_path: Path, batch_size: int = 5000) -> None:
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
total = conn.execute("SELECT count(*) FROM recipes").fetchone()[0]
print(f"Total recipes: {total:,}")
fixed = 0
skipped = 0
offset = 0
while True:
rows = conn.execute(
"SELECT id, keywords FROM recipes LIMIT ? OFFSET ?",
(batch_size, offset),
).fetchall()
if not rows:
break
updates: list[tuple[str, int]] = []
for row_id, raw_json in rows:
corrected = _repair(raw_json)
if corrected is not None:
updates.append((corrected, row_id))
else:
skipped += 1
if updates:
conn.executemany(
"UPDATE recipes SET keywords = ? WHERE id = ?", updates
)
conn.commit()
fixed += len(updates)
offset += batch_size
done = offset + len(rows) - (batch_size - len(rows))
pct = min(100, int((offset / total) * 100))
print(f" {pct:>3}% processed {offset:,} fixed {fixed:,} skipped {skipped:,}", end="\r")
print(f"\nDone. Fixed {fixed:,} rows, skipped {skipped:,} (already correct or empty).")
if fixed > 0:
print("Rebuilding FTS5 browser index (recipe_browser_fts)…")
try:
conn.execute("INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild')")
conn.commit()
print("FTS rebuild complete.")
except Exception as e:
print(f"FTS rebuild skipped (table may not exist yet): {e}")
conn.close()
if __name__ == "__main__":
db_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("data/kiwi.db")
if not db_path.exists():
print(f"DB not found: {db_path}")
sys.exit(1)
backfill(db_path)

View file

@ -57,6 +57,34 @@ def _parse_r_vector(s: str) -> list[str]:
return _QUOTED.findall(s)
def _parse_keywords(val: object) -> list[str]:
"""Parse the food.com Keywords column into a proper list of keyword strings.
The raw parquet value can arrive in three forms:
- None / NaN []
- str: c("Italian", ...) parse quoted tokens via _parse_r_vector
- list of single chars the R-vector was character-split during dataset
export; rejoin then re-parse
- list of strings already correct, use as-is
"""
import math
if val is None:
return []
if isinstance(val, float) and math.isnan(val):
return []
if isinstance(val, str):
return _parse_r_vector(val)
if isinstance(val, list):
if not val:
return []
# Detect character-split R-vector: every element is a single character
if all(isinstance(e, str) and len(e) == 1 for e in val):
return _parse_r_vector("".join(val))
# Already a proper list of keyword strings
return [str(e) for e in val if e]
return []
def extract_ingredient_names(raw_list: list[str]) -> list[str]:
"""Strip quantities and units from ingredient strings -> normalized names."""
names = []
@ -168,7 +196,7 @@ def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
json.dumps(ingredient_names),
json.dumps(directions),
str(row.get("RecipeCategory", "") or ""),
json.dumps(_safe_list(row.get("Keywords"))),
json.dumps(_parse_keywords(row.get("Keywords"))),
_float_or_none(row.get("Calories")),
_float_or_none(row.get("FatContent")),
_float_or_none(row.get("ProteinContent")),

View file

@ -0,0 +1,255 @@
"""
Infer and backfill normalized tags for all recipes.
Reads recipes in batches, cross-references ingredient_profiles and
substitution_pairs, runs tag_inferrer on each recipe, and writes the result
to recipes.inferred_tags. Also rebuilds recipe_browser_fts after the run.
This script is idempotent: pass --force to re-derive tags even if
inferred_tags is already non-empty.
Usage:
conda run -n cf python scripts/pipeline/infer_recipe_tags.py \\
[path/to/kiwi.db] [--batch-size 2000] [--force]
Estimated time on 3.1M rows: 10-20 minutes (CPU-bound text matching).
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from pathlib import Path
# Allow importing from the app package when run from the repo root
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from app.services.recipe.tag_inferrer import infer_tags
# ---------------------------------------------------------------------------
# Substitution constraint label mapping
# Keys are what we store in substitution_pairs.constraint_label.
# ---------------------------------------------------------------------------
_INTERESTING_CONSTRAINTS = {"gluten_free", "low_calorie", "low_carb", "vegan", "dairy_free", "low_sodium"}
def _load_profiles(conn: sqlite3.Connection) -> dict[str, dict]:
"""
Load ingredient_profiles into a dict keyed by name.
Values hold only the fields we need for tag inference.
"""
profiles: dict[str, dict] = {}
rows = conn.execute("""
SELECT name, elements, glutamate_mg, is_fermented, ph_estimate
FROM ingredient_profiles
""").fetchall()
for name, elements_json, glutamate_mg, is_fermented, ph_estimate in rows:
try:
elements: list[str] = json.loads(elements_json) if elements_json else []
except Exception:
elements = []
profiles[name] = {
"elements": elements,
"glutamate": float(glutamate_mg or 0),
"fermented": bool(is_fermented),
"ph": float(ph_estimate) if ph_estimate is not None else None,
}
return profiles
def _load_sub_index(conn: sqlite3.Connection) -> dict[str, set[str]]:
"""
Build a dict of ingredient_name -> set of available constraint labels.
Only loads constraints we care about.
"""
index: dict[str, set[str]] = {}
placeholders = ",".join("?" * len(_INTERESTING_CONSTRAINTS))
rows = conn.execute(
f"SELECT original_name, constraint_label FROM substitution_pairs "
f"WHERE constraint_label IN ({placeholders})",
list(_INTERESTING_CONSTRAINTS),
).fetchall()
for name, label in rows:
index.setdefault(name, set()).add(label)
return index
def _enrich(
ingredient_names: list[str],
profile_index: dict[str, dict],
sub_index: dict[str, set[str]],
) -> dict:
"""
Cross-reference ingredient_names against our enrichment indices.
Returns a dict of enriched signals ready for infer_tags().
"""
fermented_count = 0
glutamate_total = 0.0
ph_values: list[float] = []
element_totals: dict[str, float] = {}
profiled = 0
constraint_sets: list[set[str]] = []
for name in ingredient_names:
profile = profile_index.get(name)
if profile:
profiled += 1
glutamate_total += profile["glutamate"]
if profile["fermented"]:
fermented_count += 1
if profile["ph"] is not None:
ph_values.append(profile["ph"])
for elem in profile["elements"]:
element_totals[elem] = element_totals.get(elem, 0.0) + 1.0
subs = sub_index.get(name)
if subs:
constraint_sets.append(subs)
# Element coverage: fraction of profiled ingredients that carry each element
element_coverage: dict[str, float] = {}
if profiled > 0:
element_coverage = {e: round(c / profiled, 3) for e, c in element_totals.items()}
# Only emit a can_be:* tag if ALL relevant ingredients have the substitution available.
# (A recipe is gluten_free-achievable only if every gluten source can be swapped.)
# We use a simpler heuristic: if at least one ingredient has the constraint, flag it.
# Future improvement: require coverage of all gluten-bearing ingredients.
available_constraints: list[str] = []
if constraint_sets:
union_constraints: set[str] = set()
for cs in constraint_sets:
union_constraints.update(cs)
available_constraints = sorted(union_constraints & _INTERESTING_CONSTRAINTS)
return {
"element_coverage": element_coverage,
"fermented_count": fermented_count,
"glutamate_total": glutamate_total,
"ph_min": min(ph_values) if ph_values else None,
"available_sub_constraints": available_constraints,
}
def run(db_path: Path, batch_size: int = 2000, force: bool = False) -> None:
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
total = conn.execute("SELECT count(*) FROM recipes").fetchone()[0]
print(f"Total recipes: {total:,}")
print("Loading ingredient profiles...")
profile_index = _load_profiles(conn)
print(f" {len(profile_index):,} profiles loaded")
print("Loading substitution index...")
sub_index = _load_sub_index(conn)
print(f" {len(sub_index):,} substitutable ingredients indexed")
updated = 0
skipped = 0
offset = 0
where_clause = "" if force else "WHERE inferred_tags = '[]' OR inferred_tags IS NULL"
eligible = conn.execute(
f"SELECT count(*) FROM recipes {where_clause}"
).fetchone()[0]
print(f"Recipes to process: {eligible:,} ({'all' if force else 'untagged only'})")
while True:
rows = conn.execute(
f"""
SELECT id, title, ingredient_names, category, keywords,
element_coverage,
calories, fat_g, protein_g, carbs_g, servings
FROM recipes {where_clause}
ORDER BY id
LIMIT ? OFFSET ?
""",
(batch_size, offset),
).fetchall()
if not rows:
break
updates: list[tuple[str, int]] = []
for (row_id, title, ingr_json, category, kw_json,
elem_cov_json, calories, fat_g, protein_g, carbs_g, servings) in rows:
try:
ingredient_names: list[str] = json.loads(ingr_json) if ingr_json else []
corpus_keywords: list[str] = json.loads(kw_json) if kw_json else []
element_coverage: dict[str, float] = (
json.loads(elem_cov_json) if elem_cov_json else {}
)
except Exception:
ingredient_names = []
corpus_keywords = []
element_coverage = {}
enriched = _enrich(ingredient_names, profile_index, sub_index)
# Prefer the pre-computed element_coverage from the recipes table
# (it was computed over all ingredients at import time, not just the
# profiled subset). Fall back to what _enrich computed.
effective_coverage = element_coverage or enriched["element_coverage"]
tags = infer_tags(
title=title or "",
ingredient_names=ingredient_names,
corpus_keywords=corpus_keywords,
corpus_category=category or "",
element_coverage=effective_coverage,
fermented_count=enriched["fermented_count"],
glutamate_total=enriched["glutamate_total"],
ph_min=enriched["ph_min"],
available_sub_constraints=enriched["available_sub_constraints"],
calories=calories,
protein_g=protein_g,
fat_g=fat_g,
carbs_g=carbs_g,
servings=servings,
)
updates.append((json.dumps(tags), row_id))
if updates:
conn.executemany(
"UPDATE recipes SET inferred_tags = ? WHERE id = ?", updates
)
conn.commit()
updated += len(updates)
else:
skipped += len(rows)
offset += len(rows)
pct = min(100, int((offset / eligible) * 100)) if eligible else 100
print(
f" {pct:>3}% offset {offset:,} tagged {updated:,}",
end="\r",
)
print(f"\nDone. Tagged {updated:,} recipes, skipped {skipped:,}.")
if updated > 0:
print("Rebuilding FTS5 browser index (recipe_browser_fts)...")
try:
conn.execute(
"INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild')"
)
conn.commit()
print("FTS rebuild complete.")
except Exception as e:
print(f"FTS rebuild skipped: {e}")
conn.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("db", nargs="?", default="data/kiwi.db", type=Path)
parser.add_argument("--batch-size", type=int, default=2000)
parser.add_argument("--force", action="store_true",
help="Re-derive tags even if inferred_tags is already set.")
args = parser.parse_args()
if not args.db.exists():
print(f"DB not found: {args.db}")
sys.exit(1)
run(args.db, args.batch_size, args.force)

View file

@ -0,0 +1,83 @@
# tests/api/test_community_endpoints.py
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_get_community_posts_no_db_returns_empty():
"""When COMMUNITY_DB_URL is not set, GET /community/posts returns empty list (no 500)."""
with patch("app.api.endpoints.community._community_store", None):
response = client.get("/api/v1/community/posts")
assert response.status_code == 200
data = response.json()
assert "posts" in data
assert isinstance(data["posts"], list)
def test_get_community_post_not_found():
"""GET /community/posts/{slug} returns 404 when slug doesn't exist."""
mock_store = MagicMock()
mock_store.get_post_by_slug.return_value = None
with patch("app.api.endpoints.community._community_store", mock_store):
response = client.get("/api/v1/community/posts/nonexistent-slug")
assert response.status_code == 404
def test_get_community_rss():
"""GET /community/feed.rss returns XML with content-type application/rss+xml."""
mock_store = MagicMock()
mock_store.list_posts.return_value = []
with patch("app.api.endpoints.community._community_store", mock_store):
response = client.get("/api/v1/community/feed.rss")
assert response.status_code == 200
assert "xml" in response.headers.get("content-type", "")
def test_post_community_requires_auth():
"""POST /community/posts requires authentication (401/403/422) or community store (503).
In local/dev mode get_session bypasses JWT auth and returns a privileged user,
so the next gate is the community store check (503 when COMMUNITY_DB_URL is not set).
In cloud mode the endpoint requires a valid session (401/403).
"""
response = client.post("/api/v1/community/posts", json={"title": "Test"})
assert response.status_code in (401, 403, 422, 503)
def test_delete_post_requires_auth():
"""DELETE /community/posts/{slug} requires authentication (401/403) or community store (503).
Same local-mode caveat as test_post_community_requires_auth.
"""
response = client.delete("/api/v1/community/posts/some-slug")
assert response.status_code in (401, 403, 422, 503)
def test_fork_post_route_exists():
"""POST /community/posts/{slug}/fork route exists (not 404)."""
response = client.post("/api/v1/community/posts/some-slug/fork")
assert response.status_code != 404
def test_local_feed_returns_json():
"""GET /community/local-feed returns JSON list for LAN peers."""
mock_store = MagicMock()
mock_store.list_posts.return_value = []
with patch("app.api.endpoints.community._community_store", mock_store):
response = client.get("/api/v1/community/local-feed")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_hall_of_chaos_route_exists():
"""GET /community/hall-of-chaos returns 200 and includes chaos_level key."""
mock_store = MagicMock()
mock_store.list_posts.return_value = []
with patch("app.api.endpoints.community._community_store", mock_store):
response = client.get("/api/v1/community/hall-of-chaos")
assert response.status_code == 200
data = response.json()
assert "chaos_level" in data

View file

@ -0,0 +1,77 @@
"""Tests for GET /templates, GET /template-candidates, POST /build endpoints."""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path):
"""FastAPI test client with a seeded in-memory DB."""
import os
os.environ["KIWI_DB_PATH"] = str(tmp_path / "test.db")
os.environ["CLOUD_MODE"] = "false"
from app.main import app
from app.db.store import Store
store = Store(tmp_path / "test.db")
store.conn.execute(
"INSERT INTO products (name, barcode) VALUES (?,?)", ("chicken breast", None)
)
store.conn.execute(
"INSERT INTO inventory_items (product_id, location, status) VALUES (1,'pantry','available')"
)
store.conn.execute(
"INSERT INTO products (name, barcode) VALUES (?,?)", ("flour tortilla", None)
)
store.conn.execute(
"INSERT INTO inventory_items (product_id, location, status) VALUES (2,'pantry','available')"
)
store.conn.commit()
return TestClient(app)
def test_get_templates_returns_13(client):
resp = client.get("/api/v1/recipes/templates")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 13
def test_get_templates_shape(client):
resp = client.get("/api/v1/recipes/templates")
t = next(t for t in resp.json() if t["id"] == "burrito_taco")
assert t["icon"] == "🌯"
assert len(t["role_sequence"]) >= 2
assert t["role_sequence"][0]["required"] is True
def test_get_template_candidates_returns_shape(client):
resp = client.get(
"/api/v1/recipes/template-candidates",
params={"template_id": "burrito_taco", "role": "tortilla or wrap"}
)
assert resp.status_code == 200
data = resp.json()
assert "compatible" in data
assert "other" in data
assert "available_tags" in data
def test_post_build_returns_recipe(client):
resp = client.post("/api/v1/recipes/build", json={
"template_id": "burrito_taco",
"role_overrides": {
"tortilla or wrap": "flour tortilla",
"protein": "chicken breast",
}
})
assert resp.status_code == 200
data = resp.json()
assert data["id"] == -1
assert len(data["directions"]) > 0
def test_post_build_unknown_template_returns_404(client):
resp = client.post("/api/v1/recipes/build", json={
"template_id": "does_not_exist",
"role_overrides": {}
})
assert resp.status_code == 404

View file

@ -0,0 +1,18 @@
import pytest
from pathlib import Path
from app.db.store import Store
@pytest.fixture
def tmp_db(tmp_path: Path) -> Path:
return tmp_path / "test.db"
def test_migration_028_adds_community_pseudonyms(tmp_db):
"""Migration 028 adds community_pseudonyms table to per-user kiwi.db."""
store = Store(tmp_db)
cur = store.conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='community_pseudonyms'"
)
assert cur.fetchone() is not None
store.close()

View file

@ -42,3 +42,15 @@ def test_check_rate_limit_exceeded(store_with_recipes):
allowed, count = store_with_recipes.check_and_increment_rate_limit("leftover_mode", daily_max=5)
assert allowed is False
assert count == 5
def test_get_element_profiles_returns_known_items(store_with_profiles):
profiles = store_with_profiles.get_element_profiles(["butter", "parmesan", "unknown_item"])
assert profiles["butter"] == ["Richness"]
assert "Depth" in profiles["parmesan"]
assert "unknown_item" not in profiles
def test_get_element_profiles_empty_list(store_with_profiles):
profiles = store_with_profiles.get_element_profiles([])
assert profiles == {}

View file

View file

@ -0,0 +1,48 @@
# tests/services/community/test_ap_compat.py
import pytest
import json
from datetime import datetime, timezone
from app.services.community.ap_compat import post_to_ap_json_ld
POST = {
"slug": "kiwi-plan-test-pasta-week",
"title": "Pasta Week",
"description": "Seven days of carbs",
"published": datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc),
"pseudonym": "PastaWitch",
"dietary_tags": ["vegetarian"],
}
def test_ap_json_ld_context():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
assert doc["@context"] == "https://www.w3.org/ns/activitystreams"
def test_ap_json_ld_type():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
assert doc["type"] == "Note"
def test_ap_json_ld_id_is_uri():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
assert doc["id"].startswith("https://")
assert POST["slug"] in doc["id"]
def test_ap_json_ld_published_is_iso8601():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
from datetime import datetime
datetime.fromisoformat(doc["published"].replace("Z", "+00:00"))
def test_ap_json_ld_attributed_to_pseudonym():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
assert doc["attributedTo"] == "PastaWitch"
def test_ap_json_ld_tags_include_kiwi():
doc = post_to_ap_json_ld(POST, base_url="https://menagerie.circuitforge.tech/kiwi")
tag_names = [t["name"] for t in doc.get("tag", [])]
assert "#kiwi" in tag_names

View file

@ -0,0 +1,43 @@
# tests/services/community/test_community_store.py
# MIT License
import pytest
from unittest.mock import MagicMock, patch
from app.services.community.community_store import KiwiCommunityStore, get_or_create_pseudonym
def test_get_or_create_pseudonym_new_user():
"""First-time publish: creates a new pseudonym in per-user SQLite."""
mock_store = MagicMock()
mock_store.get_current_pseudonym.return_value = None
result = get_or_create_pseudonym(
store=mock_store,
directus_user_id="user-123",
requested_name="PastaWitch",
)
mock_store.set_pseudonym.assert_called_once_with("user-123", "PastaWitch")
assert result == "PastaWitch"
def test_get_or_create_pseudonym_existing():
"""If user already has a pseudonym, return it without creating a new one."""
mock_store = MagicMock()
mock_store.get_current_pseudonym.return_value = "PastaWitch"
result = get_or_create_pseudonym(
store=mock_store,
directus_user_id="user-123",
requested_name=None,
)
mock_store.set_pseudonym.assert_not_called()
assert result == "PastaWitch"
def test_kiwi_community_store_list_meal_plans():
"""KiwiCommunityStore.list_meal_plans filters by post_type='plan'."""
mock_db = MagicMock()
store = KiwiCommunityStore(mock_db)
with patch.object(store, "list_posts", return_value=[]) as mock_list:
result = store.list_meal_plans(limit=10)
mock_list.assert_called_once()
call_kwargs = mock_list.call_args.kwargs
assert call_kwargs.get("post_type") == "plan"

View file

@ -0,0 +1,78 @@
# tests/services/community/test_element_snapshot.py
import pytest
from unittest.mock import MagicMock
from app.services.community.element_snapshot import compute_snapshot, ElementSnapshot
def make_mock_store(recipe_rows: list[dict]) -> MagicMock:
"""Return a mock Store whose get_recipes_by_ids returns the given rows."""
store = MagicMock()
store.get_recipes_by_ids.return_value = recipe_rows
return store
RECIPE_ROW = {
"id": 1,
"name": "Spaghetti Carbonara",
"ingredient_names": ["pasta", "eggs", "guanciale", "pecorino"],
"keywords": ["italian", "quick", "dinner"],
"category": "dinner",
"fat": 22.0,
"protein": 18.0,
"moisture": 45.0,
"seasoning_score": 0.7,
"richness_score": 0.8,
"brightness_score": 0.2,
"depth_score": 0.6,
"aroma_score": 0.5,
"structure_score": 0.9,
"texture_profile": "creamy",
}
def test_compute_snapshot_basic():
store = make_mock_store([RECIPE_ROW])
snap = compute_snapshot(recipe_ids=[1], store=store)
assert isinstance(snap, ElementSnapshot)
assert 0.0 <= snap.seasoning_score <= 1.0
assert snap.texture_profile == "creamy"
def test_compute_snapshot_averages_multiple_recipes():
row2 = {**RECIPE_ROW, "id": 2, "seasoning_score": 0.3, "richness_score": 0.2}
store = make_mock_store([RECIPE_ROW, row2])
snap = compute_snapshot(recipe_ids=[1, 2], store=store)
# seasoning average of 0.7 and 0.3 = 0.5
assert abs(snap.seasoning_score - 0.5) < 0.01
def test_compute_snapshot_allergen_flags_detected():
row = {**RECIPE_ROW, "ingredient_names": ["pasta", "eggs", "milk", "shrimp", "peanuts"]}
store = make_mock_store([row])
snap = compute_snapshot(recipe_ids=[1], store=store)
assert "gluten" in snap.allergen_flags # pasta
assert "dairy" in snap.allergen_flags # milk
assert "shellfish" in snap.allergen_flags # shrimp
assert "nuts" in snap.allergen_flags # peanuts
def test_compute_snapshot_dietary_tags_vegetarian():
row = {**RECIPE_ROW, "ingredient_names": ["pasta", "eggs", "tomato", "basil"]}
store = make_mock_store([row])
snap = compute_snapshot(recipe_ids=[1], store=store)
assert "vegetarian" in snap.dietary_tags
def test_compute_snapshot_no_recipes_returns_defaults():
store = make_mock_store([])
snap = compute_snapshot(recipe_ids=[], store=store)
assert snap.seasoning_score == 0.0
assert snap.dietary_tags == ()
assert snap.allergen_flags == ()
def test_element_snapshot_immutable():
store = make_mock_store([RECIPE_ROW])
snap = compute_snapshot(recipe_ids=[1], store=store)
with pytest.raises((AttributeError, TypeError)):
snap.seasoning_score = 0.0 # type: ignore

View file

@ -0,0 +1,51 @@
# tests/services/community/test_feed.py
import pytest
from datetime import datetime, timezone
from app.services.community.feed import posts_to_rss
def make_post_dict(**kwargs):
defaults = dict(
slug="kiwi-plan-test-pasta-week",
title="Pasta Week",
description="Seven days of carbs",
published=datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc),
post_type="plan",
pseudonym="PastaWitch",
)
defaults.update(kwargs)
return defaults
def test_rss_is_valid_xml():
import xml.etree.ElementTree as ET
rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi")
root = ET.fromstring(rss)
assert root.tag == "rss"
assert root.attrib.get("version") == "2.0"
def test_rss_contains_item():
import xml.etree.ElementTree as ET
rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi")
root = ET.fromstring(rss)
items = root.findall(".//item")
assert len(items) == 1
def test_rss_item_has_required_fields():
import xml.etree.ElementTree as ET
rss = posts_to_rss([make_post_dict()], base_url="https://menagerie.circuitforge.tech/kiwi")
root = ET.fromstring(rss)
item = root.find(".//item")
assert item.find("title") is not None
assert item.find("link") is not None
assert item.find("pubDate") is not None
def test_rss_empty_posts():
import xml.etree.ElementTree as ET
rss = posts_to_rss([], base_url="https://menagerie.circuitforge.tech/kiwi")
root = ET.fromstring(rss)
items = root.findall(".//item")
assert len(items) == 0

View file

@ -0,0 +1,39 @@
# tests/services/community/test_mdns.py
import pytest
from unittest.mock import MagicMock, patch
from app.services.community.mdns import KiwiMDNS
def test_mdns_does_not_advertise_when_disabled():
"""When enabled=False, KiwiMDNS does not register any zeroconf service."""
with patch("app.services.community.mdns.Zeroconf") as mock_zc:
mdns = KiwiMDNS(enabled=False, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed")
mdns.start()
mock_zc.assert_not_called()
def test_mdns_advertises_when_enabled():
with patch("app.services.community.mdns.Zeroconf") as mock_zc_cls:
with patch("app.services.community.mdns.ServiceInfo") as mock_si:
mock_zc = MagicMock()
mock_zc_cls.return_value = mock_zc
mdns = KiwiMDNS(enabled=True, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed")
mdns.start()
mock_zc.register_service.assert_called_once()
def test_mdns_stop_unregisters_when_enabled():
with patch("app.services.community.mdns.Zeroconf") as mock_zc_cls:
with patch("app.services.community.mdns.ServiceInfo"):
mock_zc = MagicMock()
mock_zc_cls.return_value = mock_zc
mdns = KiwiMDNS(enabled=True, port=8512, feed_url="http://localhost:8512/api/v1/community/local-feed")
mdns.start()
mdns.stop()
mock_zc.unregister_service.assert_called_once()
mock_zc.close.assert_called_once()
def test_mdns_stop_is_noop_when_not_started():
mdns = KiwiMDNS(enabled=False, port=8512, feed_url="http://localhost/feed")
mdns.stop() # must not raise

View file

@ -0,0 +1,216 @@
"""Tests for Build Your Own recipe assembly schemas."""
import pytest
from app.models.schemas.recipe import (
AssemblyRoleOut,
AssemblyTemplateOut,
RoleCandidateItem,
RoleCandidatesResponse,
BuildRequest,
)
def test_assembly_role_out_schema():
"""Test AssemblyRoleOut schema creation and field access."""
role = AssemblyRoleOut(
display="protein",
required=True,
keywords=["chicken"],
hint="Main ingredient"
)
assert role.display == "protein"
assert role.required is True
assert role.keywords == ["chicken"]
assert role.hint == "Main ingredient"
def test_assembly_template_out_schema():
"""Test AssemblyTemplateOut schema with nested roles."""
tmpl = AssemblyTemplateOut(
id="burrito_taco",
title="Burrito / Taco",
icon="🌯",
descriptor="Protein, veg, and sauce in a tortilla or over rice",
role_sequence=[
AssemblyRoleOut(
display="base",
required=True,
keywords=["tortilla"],
hint="The wrap"
),
],
)
assert tmpl.id == "burrito_taco"
assert tmpl.title == "Burrito / Taco"
assert tmpl.icon == "🌯"
assert len(tmpl.role_sequence) == 1
assert tmpl.role_sequence[0].display == "base"
def test_role_candidate_item_schema():
"""Test RoleCandidateItem schema with tags."""
item = RoleCandidateItem(
name="bell pepper",
in_pantry=True,
tags=["sweet", "vegetable"]
)
assert item.name == "bell pepper"
assert item.in_pantry is True
assert "sweet" in item.tags
def test_role_candidates_response_schema():
"""Test RoleCandidatesResponse with compatible and other candidates."""
resp = RoleCandidatesResponse(
compatible=[
RoleCandidateItem(name="bell pepper", in_pantry=True, tags=["sweet"])
],
other=[
RoleCandidateItem(
name="corn",
in_pantry=False,
tags=["sweet", "starchy"]
)
],
available_tags=["sweet", "starchy"],
)
assert len(resp.compatible) == 1
assert resp.compatible[0].name == "bell pepper"
assert len(resp.other) == 1
assert "sweet" in resp.available_tags
assert "starchy" in resp.available_tags
def test_build_request_schema():
"""Test BuildRequest schema with template and role overrides."""
req = BuildRequest(
template_id="burrito_taco",
role_overrides={"protein": "chicken", "sauce": "verde"}
)
assert req.template_id == "burrito_taco"
assert req.role_overrides["protein"] == "chicken"
assert req.role_overrides["sauce"] == "verde"
def test_role_candidates_response_defaults():
"""Test RoleCandidatesResponse with default factory fields."""
resp = RoleCandidatesResponse()
assert resp.compatible == []
assert resp.other == []
assert resp.available_tags == []
def test_build_request_defaults():
"""Test BuildRequest with default role_overrides."""
req = BuildRequest(template_id="test_template")
assert req.template_id == "test_template"
assert req.role_overrides == {}
def test_get_templates_for_api_returns_13():
from app.services.recipe.assembly_recipes import get_templates_for_api
templates = get_templates_for_api()
assert len(templates) == 13
def test_get_templates_for_api_shape():
from app.services.recipe.assembly_recipes import get_templates_for_api
templates = get_templates_for_api()
t = next(t for t in templates if t["id"] == "burrito_taco")
assert t["title"] == "Burrito / Taco"
assert t["icon"] == "🌯"
assert isinstance(t["role_sequence"], list)
assert len(t["role_sequence"]) >= 1
role = t["role_sequence"][0]
assert "display" in role
assert "required" in role
assert "keywords" in role
assert "hint" in role
def test_get_templates_for_api_all_have_slugs():
from app.services.recipe.assembly_recipes import get_templates_for_api
templates = get_templates_for_api()
slugs = {t["id"] for t in templates}
assert len(slugs) == 13
assert all(isinstance(s, str) and len(s) > 3 for s in slugs)
def test_get_role_candidates_splits_compatible_other():
from app.services.recipe.assembly_recipes import get_role_candidates
profile_index = {
"rice": ["Starch", "Structure"],
"chicken": ["Protein"],
"broccoli": ["Vegetable"],
}
result = get_role_candidates(
template_slug="stir_fry",
role_display="protein",
pantry_set={"rice", "chicken", "broccoli"},
prior_picks=["rice"],
profile_index=profile_index,
)
assert isinstance(result["compatible"], list)
assert isinstance(result["other"], list)
assert isinstance(result["available_tags"], list)
all_names = [c["name"] for c in result["compatible"] + result["other"]]
assert "chicken" in all_names
def test_get_role_candidates_available_tags():
from app.services.recipe.assembly_recipes import get_role_candidates
profile_index = {
"chicken": ["Protein", "Umami"],
"tofu": ["Protein"],
}
result = get_role_candidates(
template_slug="stir_fry",
role_display="protein",
pantry_set={"chicken", "tofu"},
prior_picks=[],
profile_index=profile_index,
)
assert "Protein" in result["available_tags"]
def test_get_role_candidates_unknown_template_returns_empty():
from app.services.recipe.assembly_recipes import get_role_candidates
result = get_role_candidates(
template_slug="nonexistent_template",
role_display="protein",
pantry_set={"chicken"},
prior_picks=[],
profile_index={},
)
assert result == {"compatible": [], "other": [], "available_tags": []}
def test_build_from_selection_returns_recipe():
from app.services.recipe.assembly_recipes import build_from_selection
result = build_from_selection(
template_slug="burrito_taco",
role_overrides={"tortilla or wrap": "flour tortilla", "protein": "chicken"},
pantry_set={"flour tortilla", "chicken", "salsa"},
)
assert result is not None
assert len(result.directions) > 0
assert result.id == -1
def test_build_from_selection_missing_required_role_returns_none():
from app.services.recipe.assembly_recipes import build_from_selection
result = build_from_selection(
template_slug="burrito_taco",
role_overrides={"protein": "chicken"},
pantry_set={"chicken"},
)
assert result is None
def test_build_from_selection_unknown_template_returns_none():
from app.services.recipe.assembly_recipes import build_from_selection
result = build_from_selection(
template_slug="does_not_exist",
role_overrides={},
pantry_set={"chicken"},
)
assert result is None

View file

@ -119,3 +119,18 @@ def test_grocery_links_free_tier(store_with_recipes):
assert hasattr(link, "ingredient")
assert hasattr(link, "retailer")
assert hasattr(link, "url")
def test_suggest_returns_no_assembly_results(store_with_recipes):
"""Assembly templates (negative IDs) must no longer appear in suggest() output."""
from app.services.recipe.recipe_engine import RecipeEngine
from app.models.schemas.recipe import RecipeRequest
engine = RecipeEngine(store_with_recipes)
req = RecipeRequest(
pantry_items=["flour tortilla", "chicken", "salsa", "rice"],
level=1,
constraints=[],
)
result = engine.suggest(req)
assembly_ids = [s.id for s in result.suggestions if s.id < 0]
assert assembly_ids == [], f"Found assembly results in suggest(): {assembly_ids}"