Compare commits

...

37 commits

Author SHA1 Message Date
33c619b6b5 feat(kiwi-fe): wire OrchUsagePill into RecipesView and Settings opt-in toggle
- Import and mount OrchUsagePill near the recipe level selector in RecipesView;
  pill is self-hiding when not enabled or no lifetime key is present
- Add useOrchUsage composable to SettingsView with a Display section checkbox
  so users can opt in to seeing the cloud recipe call budget pill
- Add @/ path alias to vite.config.ts and tsconfig.app.json to resolve the
  existing @/services/api import in useOrchUsage.ts (fixes vite build error)
- tsc --noEmit and vite build both pass clean
2026-04-14 15:51:34 -07:00
1ae54c370d feat(kiwi-fe): add OrchUsagePill component with calm low-budget state 2026-04-14 15:46:58 -07:00
b4f8bde952 feat(kiwi-fe): add useOrchUsage composable with opt-in localStorage persistence 2026-04-14 15:46:12 -07:00
bdfbc963b7 feat(kiwi-fe): add getOrchUsage API call and OrchUsage type 2026-04-14 15:45:22 -07:00
99e9cbb8c1 refactor(kiwi): remove unused LIFETIME_SOURCES import from recipes.py 2026-04-14 15:44:42 -07:00
006582b179 feat(kiwi): add /orch-usage proxy endpoint for frontend budget display 2026-04-14 15:42:58 -07:00
1a6898324c feat(kiwi): merge meal planner feature into main
Adds full meal planning workflow to Kiwi:
- Weekly meal plan creation with configurable meal types (Paid gate)
- Drag-and-assign recipe slots per day
- Prep session generation with sequenced task lists and time estimates
- LLM-assisted full-week plan and timing fill-in (BYOK-unlockable)
- Community feed (local ActivityPub-compat + cloud federation)
- Build Your Own recipe tab with assembly templates
- Save/bookmark any recipe with star rating, notes, and style tags
- Shopping list export from built recipes
- Tab reorder: Saved > Build > Community > Find > Browse
- Auto-redirect from empty Saved tab to Build
- Custom ingredient injection persists in candidate list
- z-index fix: save modal above recipe detail panel
- Route ordering fix: /recipes/saved before /{recipe_id} catch-all
2026-04-14 15:37:57 -07:00
01216b82c3 feat(kiwi): gate L3/L4 recipes behind orch budget check; fallback to L2 on exhaustion 2026-04-14 15:24:57 -07:00
2071540a56 feat(kiwi): add Heimdall orch budget client with fail-open semantics 2026-04-14 15:15:43 -07:00
bd73ca0b6d fix(tests): correct build endpoint test fixture
- Use monkeypatch.setattr to patch cloud_session._LOCAL_KIWI_DB
  instead of wrong KIWI_DB_PATH env var (module-level singleton
  computed at import time; env var had no effect)
- Assert id > 0 (real persisted DB id) instead of -1 (old
  pre-persistence sentinel value)
2026-04-14 14:57:16 -07:00
9941227fae 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)
2026-04-14 14:53:52 -07:00
3933136666 fix: save, shopping list, and route ordering for Build Your Own
- Persist built recipes to recipes table on /build so they get real DB IDs
  and can be bookmarked via saved_recipes (FK was pointing at negative IDs)
- Populate missing_ingredients in build_from_selection() from role_overrides
  vs pantry diff -- backend now owns shopping list computation
- Remove client-side cartItems tracking; shopping list derived from
  builtRecipe.missing_ingredients instead
- Fix saved_recipes 422: mount saved_recipes router before recipes router in
  routes.py so /recipes/saved isn't captured by /recipes/{recipe_id}
- Bump SaveRecipeModal z-index to 500 (above detail-overlay at 400)
- Replace "Add to pantry" primary action with "Grocery list" clipboard copy;
  "Add to pantry" demoted to compact secondary button
2026-04-14 14:48:30 -07:00
b4f031e87d feat(kiwi): add orch_fallback field to RecipeResult 2026-04-14 14:38:37 -07:00
fbae9ced72 feat(kiwi): add LIFETIME_ORCH_CAPS and LIFETIME_SOURCES constants 2026-04-14 14:38:36 -07:00
19c0664637 fix(review): address code review findings before merge
- update_prep_task: move whitelist guard above filter so invalid column
  check runs on raw kwargs (was dead code — set(filtered) - allowed is
  always empty); fixes latent SQL injection path for future callers
- main.py: move register_kiwi_programs() into lifespan context manager
  so it runs once at startup, not at module import time
- MealPlanView.vue: remove debug console.log stubs from onSlotClick and
  onAddMealType (follow-up issue handlers, not ready for production)
2026-04-12 14:16:24 -07:00
e52c406d0a docs(bsl): document cf-text/LLMRouter routing chain in llm_timing and llm_planner 2026-04-12 14:07:32 -07:00
4281b0ce19 feat(services/bsl): add llm_router — cf-text via cf-orch on cloud, LLMRouter (ollama/vllm) local fallback
refs kiwi#68
2026-04-12 14:07:13 -07:00
f54127a8cc fix(meal-planner): add GET prep-session endpoint, fix list_plans schema, replace assert with ValueError
- Add GET /{plan_id}/prep-session endpoint so frontend can retrieve existing sessions without creating
- Fix list_plans response_model from list[dict] to list[PlanSummary] with proper _plan_summary() mapping
- Replace assert in store.update_prep_task with ValueError (assert is stripped under python -O)
- Add day_of_week 0-6 validation to upsert_slot endpoint
- Remove MagicMock sqlite artifact files left by pytest (already in .gitignore)
2026-04-12 14:04:53 -07:00
062b5d16a1 feat(services/bsl): add llm_planner — LLM-assisted full-week meal plan generation (Paid/BYOK) 2026-04-12 13:58:04 -07:00
5f094eb37a feat(services/bsl): add llm_timing — estimate cook times via LLM for missing corpus data (Paid/BYOK) 2026-04-12 13:58:03 -07:00
2baa8c49a9 feat(frontend): add MealPlan tab with grid, shopping list, and prep schedule
closes kiwi#68, kiwi#71
2026-04-12 13:57:55 -07:00
faaa6fbf86 feat(frontend): add PrepSessionView with editable task durations 2026-04-12 13:57:48 -07:00
67b521559e feat(frontend): add ShoppingListPanel with pantry diff and affiliate links 2026-04-12 13:57:48 -07:00
a7fc441105 feat(frontend): add MealPlanGrid compact-expandable week grid component 2026-04-12 13:57:47 -07:00
543c64ea30 feat(frontend): add mealPlan Pinia store with immutable slot updates 2026-04-12 13:57:40 -07:00
4865498db9 feat(frontend): add mealPlanAPI client with TypeScript types 2026-04-12 13:55:14 -07:00
482666907b fix(meal-planner): validate meal_type path param, enforce store whitelist safety, add week_start date validation, make PrepTask frozen
- upsert_slot: raise 422 immediately if meal_type not in VALID_MEAL_TYPES
- update_prep_task: assert whitelist safety contract after dict comprehension
- CreatePlanRequest: week_start typed as date with must_be_monday validator; str() cast at call site
- PrepTask: frozen=True; build_prep_tasks rewired to use (priority, kwargs) tuples so frozen instances are built with correct sequence_order in one pass (no post-construction mutation)
- Move deferred import json to file-level in meal_plans.py
- Fix test dates: "2026-04-14" was a Tuesday; updated request bodies to "2026-04-13" (Monday)
2026-04-12 13:51:50 -07:00
bfc63f1fc9 feat(services): add planner.py orchestration helpers 2026-04-12 13:44:27 -07:00
536eedfd6c feat(routes): register meal-plans router at /api/v1/meal-plans
refs kiwi#68
2026-04-12 13:44:08 -07:00
98087120ac feat(api): add /api/v1/meal-plans/ endpoints — CRUD, shopping list, prep session
refs kiwi#68 kiwi#71
2026-04-12 13:44:01 -07:00
b9dd1427de feat(affiliates): register Kiwi grocery retailer programs at startup
refs kiwi#74
2026-04-12 13:15:28 -07:00
25027762cf feat(services): add prep_scheduler — sequences batch cooking tasks by equipment priority 2026-04-12 13:14:54 -07:00
4459b1ab7e feat(services): add shopping_list service with pantry diff
refs kiwi#68
2026-04-12 13:14:08 -07:00
ffb34c9c62 feat(store): add meal plan, slot, prep session, and prep task CRUD methods 2026-04-12 13:13:18 -07:00
067b0821af feat(schemas): add meal plan Pydantic models 2026-04-12 13:12:41 -07:00
594fd3f3bf feat(tiers): move meal_planning to Free; add meal_plan_config/llm/llm_timing keys
refs kiwi#68
2026-04-12 13:12:11 -07:00
3235fb365f feat(db): add meal_plans, slots, prep_sessions, prep_tasks migrations (022-025) 2026-04-12 13:11:34 -07:00
49 changed files with 2990 additions and 31 deletions

24
.gitleaks.toml Normal file
View file

@ -0,0 +1,24 @@
# Kiwi gitleaks config — extends base CircuitForge config with local rules
[extend]
path = "/Library/Development/CircuitForge/circuitforge-hooks/gitleaks.toml"
# ── Test fixture allowlists ───────────────────────────────────────────────────
[[rules]]
id = "cf-generic-env-token"
description = "Generic KEY=<token> in env-style assignment — catches FORGEJO_API_TOKEN=hex etc."
regex = '''(?i)(token|secret|key|password|passwd|pwd|api_key)\s*[=:]\s*['"]?[A-Za-z0-9\-_]{20,}['"]?'''
[rules.allowlist]
paths = [
'.*test.*',
]
regexes = [
'api_key:\s*ollama',
'api_key:\s*any',
'your-[a-z\-]+-here',
'replace-with-',
'xxxx',
'test-fixture-',
'CFG-KIWI-TEST-',
]

View file

@ -0,0 +1,294 @@
# app/api/endpoints/meal_plans.py
"""Meal plan CRUD, shopping list, and prep session endpoints."""
from __future__ import annotations
import asyncio
import json
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from app.cloud_session import CloudUser, get_session
from app.db.session import get_store
from app.db.store import Store
from app.models.schemas.meal_plan import (
CreatePlanRequest,
GapItem,
PlanSummary,
PrepSessionSummary,
PrepTaskSummary,
ShoppingListResponse,
SlotSummary,
UpdatePrepTaskRequest,
UpsertSlotRequest,
VALID_MEAL_TYPES,
)
from app.services.meal_plan.affiliates import get_retailer_links
from app.services.meal_plan.prep_scheduler import build_prep_tasks
from app.services.meal_plan.shopping_list import compute_shopping_list
from app.tiers import can_use
router = APIRouter()
# ── helpers ───────────────────────────────────────────────────────────────────
def _slot_summary(row: dict) -> SlotSummary:
return SlotSummary(
id=row["id"],
plan_id=row["plan_id"],
day_of_week=row["day_of_week"],
meal_type=row["meal_type"],
recipe_id=row.get("recipe_id"),
recipe_title=row.get("recipe_title"),
servings=row["servings"],
custom_label=row.get("custom_label"),
)
def _plan_summary(plan: dict, slots: list[dict]) -> PlanSummary:
meal_types = plan.get("meal_types") or ["dinner"]
if isinstance(meal_types, str):
meal_types = json.loads(meal_types)
return PlanSummary(
id=plan["id"],
week_start=plan["week_start"],
meal_types=meal_types,
slots=[_slot_summary(s) for s in slots],
created_at=plan["created_at"],
)
def _prep_task_summary(row: dict) -> PrepTaskSummary:
return PrepTaskSummary(
id=row["id"],
recipe_id=row.get("recipe_id"),
task_label=row["task_label"],
duration_minutes=row.get("duration_minutes"),
sequence_order=row["sequence_order"],
equipment=row.get("equipment"),
is_parallel=bool(row.get("is_parallel", False)),
notes=row.get("notes"),
user_edited=bool(row.get("user_edited", False)),
)
# ── plan CRUD ─────────────────────────────────────────────────────────────────
@router.post("/", response_model=PlanSummary)
async def create_plan(
req: CreatePlanRequest,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PlanSummary:
# Free tier is locked to dinner-only; paid+ may configure meal types
if can_use("meal_plan_config", session.tier):
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
else:
meal_types = ["dinner"]
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
return _plan_summary(plan, slots)
@router.get("/", response_model=list[PlanSummary])
async def list_plans(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> list[PlanSummary]:
plans = await asyncio.to_thread(store.list_meal_plans)
result = []
for p in plans:
slots = await asyncio.to_thread(store.get_plan_slots, p["id"])
result.append(_plan_summary(p, slots))
return result
@router.get("/{plan_id}", response_model=PlanSummary)
async def get_plan(
plan_id: int,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PlanSummary:
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Plan not found.")
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
return _plan_summary(plan, slots)
# ── slots ─────────────────────────────────────────────────────────────────────
@router.put("/{plan_id}/slots/{day_of_week}/{meal_type}", response_model=SlotSummary)
async def upsert_slot(
plan_id: int,
day_of_week: int,
meal_type: str,
req: UpsertSlotRequest,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> SlotSummary:
if day_of_week < 0 or day_of_week > 6:
raise HTTPException(status_code=422, detail="day_of_week must be 0-6.")
if meal_type not in VALID_MEAL_TYPES:
raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.")
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Plan not found.")
row = await asyncio.to_thread(
store.upsert_slot,
plan_id, day_of_week, meal_type,
req.recipe_id, req.servings, req.custom_label,
)
return _slot_summary(row)
@router.delete("/{plan_id}/slots/{slot_id}", status_code=204)
async def delete_slot(
plan_id: int,
slot_id: int,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> None:
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Plan not found.")
await asyncio.to_thread(store.delete_slot, slot_id)
# ── shopping list ─────────────────────────────────────────────────────────────
@router.get("/{plan_id}/shopping-list", response_model=ShoppingListResponse)
async def get_shopping_list(
plan_id: int,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> ShoppingListResponse:
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Plan not found.")
recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id)
inventory = await asyncio.to_thread(store.list_inventory)
gaps, covered = compute_shopping_list(recipes, inventory)
# Enrich gap items with retailer links
def _to_schema(item, enrich: bool) -> GapItem:
links = get_retailer_links(item.ingredient_name) if enrich else []
return GapItem(
ingredient_name=item.ingredient_name,
needed_raw=item.needed_raw,
have_quantity=item.have_quantity,
have_unit=item.have_unit,
covered=item.covered,
retailer_links=links,
)
gap_items = [_to_schema(g, enrich=True) for g in gaps]
covered_items = [_to_schema(c, enrich=False) for c in covered]
disclosure = (
"Some links may be affiliate links. Purchases through them support Kiwi development."
if gap_items else None
)
return ShoppingListResponse(
plan_id=plan_id,
gap_items=gap_items,
covered_items=covered_items,
disclosure=disclosure,
)
# ── prep session ──────────────────────────────────────────────────────────────
@router.get("/{plan_id}/prep-session", response_model=PrepSessionSummary)
async def get_prep_session(
plan_id: int,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PrepSessionSummary:
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Plan not found.")
prep_session = await asyncio.to_thread(store.get_prep_session_for_plan, plan_id)
if prep_session is None:
raise HTTPException(status_code=404, detail="No prep session for this plan.")
raw_tasks = await asyncio.to_thread(store.get_prep_tasks, prep_session["id"])
return PrepSessionSummary(
id=prep_session["id"],
plan_id=plan_id,
scheduled_date=prep_session["scheduled_date"],
status=prep_session["status"],
tasks=[_prep_task_summary(t) for t in raw_tasks],
)
@router.post("/{plan_id}/prep-session", response_model=PrepSessionSummary)
async def create_prep_session(
plan_id: int,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PrepSessionSummary:
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Plan not found.")
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id)
prep_tasks = build_prep_tasks(slots=slots, recipes=recipes)
scheduled_date = date.today().isoformat()
prep_session = await asyncio.to_thread(
store.create_prep_session, plan_id, scheduled_date
)
session_id = prep_session["id"]
task_dicts = [
{
"recipe_id": t.recipe_id,
"slot_id": t.slot_id,
"task_label": t.task_label,
"duration_minutes": t.duration_minutes,
"sequence_order": t.sequence_order,
"equipment": t.equipment,
"is_parallel": t.is_parallel,
"notes": t.notes,
}
for t in prep_tasks
]
inserted = await asyncio.to_thread(store.bulk_insert_prep_tasks, session_id, task_dicts)
return PrepSessionSummary(
id=prep_session["id"],
plan_id=prep_session["plan_id"],
scheduled_date=prep_session["scheduled_date"],
status=prep_session["status"],
tasks=[_prep_task_summary(r) for r in inserted],
)
@router.patch(
"/{plan_id}/prep-session/tasks/{task_id}",
response_model=PrepTaskSummary,
)
async def update_prep_task(
plan_id: int,
task_id: int,
req: UpdatePrepTaskRequest,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PrepTaskSummary:
updated = await asyncio.to_thread(
store.update_prep_task,
task_id,
duration_minutes=req.duration_minutes,
sequence_order=req.sequence_order,
notes=req.notes,
equipment=req.equipment,
)
if updated is None:
raise HTTPException(status_code=404, detail="Task not found.")
return _prep_task_summary(updated)

View file

@ -0,0 +1,27 @@
"""Proxy endpoint: exposes cf-orch call budget to the Kiwi frontend.
Only lifetime/founders users have a license_key subscription and free
users receive null (no budget UI shown).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.cloud_session import CloudUser, get_session
from app.services.heimdall_orch import get_orch_usage
router = APIRouter()
@router.get("")
async def orch_usage_endpoint(
session: CloudUser = Depends(get_session),
) -> dict | None:
"""Return the current period's orch usage for the authenticated user.
Returns null if the user has no lifetime/founders license key (i.e. they
are on a subscription or free plan no budget cap applies to them).
"""
if session.license_key is None:
return None
return get_orch_usage(session.license_key, "kiwi")

View file

@ -29,6 +29,7 @@ from app.services.recipe.browser_domains import (
get_keywords_for_category,
)
from app.services.recipe.recipe_engine import RecipeEngine
from app.services.heimdall_orch import check_orch_budget
from app.tiers import can_use
router = APIRouter()
@ -68,7 +69,25 @@ async def suggest_recipes(
)
if req.style_id and not can_use("style_picker", req.tier):
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
# Orch budget check for lifetime/founders keys — downgrade to L2 (local) if exhausted.
# Subscription and local/BYOK users skip this check entirely.
orch_fallback = False
if (
req.level in (3, 4)
and session.license_key is not None
and not session.has_byok
and session.tier != "local"
):
budget = check_orch_budget(session.license_key, "kiwi")
if not budget.get("allowed", True):
req = req.model_copy(update={"level": 2})
orch_fallback = True
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
if orch_fallback:
result = result.model_copy(update={"orch_fallback": True})
return result
@router.get("/browse/domains")
@ -212,11 +231,27 @@ async def build_recipe(
for item in items
if item.get("product_name")
}
return build_from_selection(
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()

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate, meal_plans, orch_usage
from app.api.endpoints.community import router as community_router
api_router = APIRouter()
@ -9,11 +9,13 @@ api_router.include_router(receipts.router, prefix="/receipts", tags=
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(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
api_router.include_router(community_router)
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"])
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:
@ -250,7 +252,7 @@ def get_session(request: Request) -> CloudUser:
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,
@ -258,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

@ -0,0 +1,8 @@
-- 022_meal_plans.sql
CREATE TABLE meal_plans (
id INTEGER PRIMARY KEY,
week_start TEXT NOT NULL,
meal_types TEXT NOT NULL DEFAULT '["dinner"]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View file

@ -0,0 +1,11 @@
-- 023_meal_plan_slots.sql
CREATE TABLE meal_plan_slots (
id INTEGER PRIMARY KEY,
plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
day_of_week INTEGER NOT NULL CHECK(day_of_week BETWEEN 0 AND 6),
meal_type TEXT NOT NULL,
recipe_id INTEGER REFERENCES recipes(id),
servings REAL NOT NULL DEFAULT 2.0,
custom_label TEXT,
UNIQUE(plan_id, day_of_week, meal_type)
);

View file

@ -0,0 +1,10 @@
-- 024_prep_sessions.sql
CREATE TABLE prep_sessions (
id INTEGER PRIMARY KEY,
plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
scheduled_date TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft'
CHECK(status IN ('draft','reviewed','done')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View file

@ -0,0 +1,15 @@
-- 025_prep_tasks.sql
CREATE TABLE prep_tasks (
id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES prep_sessions(id) ON DELETE CASCADE,
recipe_id INTEGER REFERENCES recipes(id),
slot_id INTEGER REFERENCES meal_plan_slots(id),
task_label TEXT NOT NULL,
duration_minutes INTEGER,
sequence_order INTEGER NOT NULL,
equipment TEXT,
is_parallel INTEGER NOT NULL DEFAULT 0,
notes TEXT,
user_edited INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View file

@ -44,7 +44,9 @@ class Store:
"ingredients", "ingredient_names", "directions",
"keywords", "element_coverage",
# saved recipe columns
"style_tags"):
"style_tags",
# meal plan columns
"meal_types"):
if key in d and isinstance(d[key], str):
try:
d[key] = json.loads(d[key])
@ -686,6 +688,44 @@ 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.
@ -1041,6 +1081,123 @@ class Store:
)
self.conn.commit()
# ── meal plans ────────────────────────────────────────────────────────
def create_meal_plan(self, week_start: str, meal_types: list[str]) -> dict:
return self._insert_returning(
"INSERT INTO meal_plans (week_start, meal_types) VALUES (?, ?) RETURNING *",
(week_start, json.dumps(meal_types)),
)
def get_meal_plan(self, plan_id: int) -> dict | None:
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
def list_meal_plans(self) -> list[dict]:
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
def upsert_slot(
self,
plan_id: int,
day_of_week: int,
meal_type: str,
recipe_id: int | None,
servings: float,
custom_label: str | None,
) -> dict:
return self._insert_returning(
"""INSERT INTO meal_plan_slots
(plan_id, day_of_week, meal_type, recipe_id, servings, custom_label)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(plan_id, day_of_week, meal_type) DO UPDATE SET
recipe_id = excluded.recipe_id,
servings = excluded.servings,
custom_label = excluded.custom_label
RETURNING *""",
(plan_id, day_of_week, meal_type, recipe_id, servings, custom_label),
)
def delete_slot(self, slot_id: int) -> None:
self.conn.execute("DELETE FROM meal_plan_slots WHERE id = ?", (slot_id,))
self.conn.commit()
def get_plan_slots(self, plan_id: int) -> list[dict]:
return self._fetch_all(
"""SELECT s.*, r.name AS recipe_title
FROM meal_plan_slots s
LEFT JOIN recipes r ON r.id = s.recipe_id
WHERE s.plan_id = ?
ORDER BY s.day_of_week, s.meal_type""",
(plan_id,),
)
def get_plan_recipes(self, plan_id: int) -> list[dict]:
"""Return full recipe rows for all recipes assigned to a plan."""
return self._fetch_all(
"""SELECT DISTINCT r.*
FROM meal_plan_slots s
JOIN recipes r ON r.id = s.recipe_id
WHERE s.plan_id = ? AND s.recipe_id IS NOT NULL""",
(plan_id,),
)
# ── prep sessions ─────────────────────────────────────────────────────
def create_prep_session(self, plan_id: int, scheduled_date: str) -> dict:
return self._insert_returning(
"INSERT INTO prep_sessions (plan_id, scheduled_date) VALUES (?, ?) RETURNING *",
(plan_id, scheduled_date),
)
def get_prep_session_for_plan(self, plan_id: int) -> dict | None:
return self._fetch_one(
"SELECT * FROM prep_sessions WHERE plan_id = ? ORDER BY id DESC LIMIT 1",
(plan_id,),
)
def bulk_insert_prep_tasks(self, session_id: int, tasks: list[dict]) -> list[dict]:
"""Insert multiple prep tasks and return them all."""
inserted = []
for t in tasks:
row = self._insert_returning(
"""INSERT INTO prep_tasks
(session_id, recipe_id, slot_id, task_label, duration_minutes,
sequence_order, equipment, is_parallel, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *""",
(
session_id, t.get("recipe_id"), t.get("slot_id"),
t["task_label"], t.get("duration_minutes"),
t["sequence_order"], t.get("equipment"),
int(t.get("is_parallel", False)), t.get("notes"),
),
)
inserted.append(row)
return inserted
def get_prep_tasks(self, session_id: int) -> list[dict]:
return self._fetch_all(
"SELECT * FROM prep_tasks WHERE session_id = ? ORDER BY sequence_order",
(session_id,),
)
def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None:
allowed = {"duration_minutes", "sequence_order", "notes", "equipment"}
invalid = set(kwargs) - allowed # check raw kwargs BEFORE filtering
if invalid:
raise ValueError(f"Unexpected column(s) in update_prep_task: {invalid}")
updates = {k: v for k, v in kwargs.items() if v is not None}
if not updates:
return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,))
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [1, task_id]
self.conn.execute(
f"UPDATE prep_tasks SET {set_clause}, user_edited = ? WHERE id = ?",
values,
)
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(

View file

@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router
from app.core.config import settings
from app.services.meal_plan.affiliates import register_kiwi_programs
logger = logging.getLogger(__name__)
@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
logger.info("Starting Kiwi API...")
settings.ensure_dirs()
register_kiwi_programs()
# Start LLM background task scheduler
from app.tasks.scheduler import get_scheduler

View file

@ -0,0 +1,96 @@
# app/models/schemas/meal_plan.py
"""Pydantic schemas for meal planning endpoints."""
from __future__ import annotations
from datetime import date as _date
from pydantic import BaseModel, Field, field_validator
VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"}
class CreatePlanRequest(BaseModel):
week_start: _date
meal_types: list[str] = Field(default_factory=lambda: ["dinner"])
@field_validator("week_start")
@classmethod
def must_be_monday(cls, v: _date) -> _date:
if v.weekday() != 0:
raise ValueError("week_start must be a Monday (weekday 0)")
return v
class UpsertSlotRequest(BaseModel):
recipe_id: int | None = None
servings: float = Field(2.0, gt=0)
custom_label: str | None = None
class SlotSummary(BaseModel):
id: int
plan_id: int
day_of_week: int
meal_type: str
recipe_id: int | None
recipe_title: str | None
servings: float
custom_label: str | None
class PlanSummary(BaseModel):
id: int
week_start: str
meal_types: list[str]
slots: list[SlotSummary]
created_at: str
class RetailerLink(BaseModel):
retailer: str
label: str
url: str
class GapItem(BaseModel):
ingredient_name: str
needed_raw: str | None # e.g. "2 cups" from recipe text
have_quantity: float | None # from pantry
have_unit: str | None
covered: bool # True = pantry has it
retailer_links: list[RetailerLink] = Field(default_factory=list)
class ShoppingListResponse(BaseModel):
plan_id: int
gap_items: list[GapItem]
covered_items: list[GapItem]
disclosure: str | None = None # affiliate disclosure text when links present
class PrepTaskSummary(BaseModel):
id: int
recipe_id: int | None
task_label: str
duration_minutes: int | None
sequence_order: int
equipment: str | None
is_parallel: bool
notes: str | None
user_edited: bool
class PrepSessionSummary(BaseModel):
id: int
plan_id: int
scheduled_date: str
status: str
tasks: list[PrepTaskSummary]
class UpdatePrepTaskRequest(BaseModel):
duration_minutes: int | None = None
sequence_order: int | None = None
notes: str | None = None
equipment: str | None = None

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):

View file

@ -0,0 +1,80 @@
"""Heimdall cf-orch budget client.
Calls Heimdall's /orch/* endpoints to gate and record cf-orch usage for
lifetime/founders license holders. Always fails open on network errors
a Heimdall outage should never block the user.
"""
from __future__ import annotations
import logging
import os
import requests
log = logging.getLogger(__name__)
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
def _headers() -> dict[str, str]:
if HEIMDALL_ADMIN_TOKEN:
return {"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"}
return {}
def check_orch_budget(key_display: str, product: str) -> dict:
"""Call POST /orch/check and return the response dict.
On any error (network, auth, etc.) returns a permissive dict so the
caller can proceed without blocking the user.
"""
try:
resp = requests.post(
f"{HEIMDALL_URL}/orch/check",
json={"key_display": key_display, "product": product},
headers=_headers(),
timeout=5,
)
if resp.ok:
return resp.json()
log.warning("Heimdall orch/check returned %s for key %s", resp.status_code, key_display[:12])
except Exception as exc:
log.warning("Heimdall orch/check failed (fail-open): %s", exc)
# Fail open — Heimdall outage must never block the user
return {
"allowed": True,
"calls_used": 0,
"calls_total": 0,
"topup_calls": 0,
"period_start": "",
"resets_on": "",
}
def get_orch_usage(key_display: str, product: str) -> dict:
"""Call GET /orch/usage and return the response dict.
Returns zeros on error (non-blocking).
"""
try:
resp = requests.get(
f"{HEIMDALL_URL}/orch/usage",
params={"key_display": key_display, "product": product},
headers=_headers(),
timeout=5,
)
if resp.ok:
return resp.json()
log.warning("Heimdall orch/usage returned %s", resp.status_code)
except Exception as exc:
log.warning("Heimdall orch/usage failed: %s", exc)
return {
"calls_used": 0,
"topup_calls": 0,
"calls_total": 0,
"period_start": "",
"resets_on": "",
}

View file

@ -0,0 +1 @@
"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core)."""

View file

@ -0,0 +1,108 @@
# app/services/meal_plan/affiliates.py
"""Register Kiwi-specific affiliate programs and provide search URL builders.
Called once at API startup. Programs not yet in core.affiliates are registered
here. The actual affiliate IDs are read from environment variables at call
time, so the process can start before accounts are approved (plain URLs
returned when env vars are absent).
"""
from __future__ import annotations
from urllib.parse import quote_plus
from circuitforge_core.affiliates import AffiliateProgram, register_program, wrap_url
# ── URL builders ──────────────────────────────────────────────────────────────
def _walmart_search(url: str, affiliate_id: str) -> str:
sep = "&" if "?" in url else "?"
return f"{url}{sep}affil=apa&affiliateId={affiliate_id}"
def _target_search(url: str, affiliate_id: str) -> str:
sep = "&" if "?" in url else "?"
return f"{url}{sep}afid={affiliate_id}"
def _thrive_search(url: str, affiliate_id: str) -> str:
sep = "&" if "?" in url else "?"
return f"{url}{sep}raf={affiliate_id}"
def _misfits_search(url: str, affiliate_id: str) -> str:
sep = "&" if "?" in url else "?"
return f"{url}{sep}ref={affiliate_id}"
# ── Registration ──────────────────────────────────────────────────────────────
def register_kiwi_programs() -> None:
"""Register Kiwi retailer programs. Safe to call multiple times (idempotent)."""
register_program(AffiliateProgram(
name="Walmart",
retailer_key="walmart",
env_var="WALMART_AFFILIATE_ID",
build_url=_walmart_search,
))
register_program(AffiliateProgram(
name="Target",
retailer_key="target",
env_var="TARGET_AFFILIATE_ID",
build_url=_target_search,
))
register_program(AffiliateProgram(
name="Thrive Market",
retailer_key="thrive",
env_var="THRIVE_AFFILIATE_ID",
build_url=_thrive_search,
))
register_program(AffiliateProgram(
name="Misfits Market",
retailer_key="misfits",
env_var="MISFITS_AFFILIATE_ID",
build_url=_misfits_search,
))
# ── Search URL helpers ─────────────────────────────────────────────────────────
_SEARCH_TEMPLATES: dict[str, str] = {
"amazon": "https://www.amazon.com/s?k={q}",
"instacart": "https://www.instacart.com/store/search_v3/term?term={q}",
"walmart": "https://www.walmart.com/search?q={q}",
"target": "https://www.target.com/s?searchTerm={q}",
"thrive": "https://thrivemarket.com/search?q={q}",
"misfits": "https://www.misfitsmarket.com/shop?search={q}",
}
KIWI_RETAILERS = list(_SEARCH_TEMPLATES.keys())
def get_retailer_links(ingredient_name: str) -> list[dict]:
"""Return affiliate-wrapped search links for *ingredient_name*.
Returns a list of dicts: {"retailer": str, "label": str, "url": str}.
Falls back to plain search URL when no affiliate ID is configured.
"""
q = quote_plus(ingredient_name)
links = []
for key, template in _SEARCH_TEMPLATES.items():
plain_url = template.format(q=q)
try:
affiliate_url = wrap_url(plain_url, retailer=key)
except Exception:
affiliate_url = plain_url
links.append({"retailer": key, "label": _label(key), "url": affiliate_url})
return links
def _label(key: str) -> str:
return {
"amazon": "Amazon",
"instacart": "Instacart",
"walmart": "Walmart",
"target": "Target",
"thrive": "Thrive Market",
"misfits": "Misfits Market",
}.get(key, key.title())

View file

@ -0,0 +1,91 @@
# app/services/meal_plan/llm_planner.py
# BSL 1.1 — LLM feature
"""LLM-assisted full-week meal plan generation.
Returns suggestions for human review never writes to the DB directly.
The API endpoint presents the suggestions and waits for user approval
before calling store.upsert_slot().
Routing: pass a router from get_meal_plan_router() in llm_router.py.
Cloud: cf-text via cf-orch (3B-7B GGUF, ~2GB VRAM).
Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml).
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
_PLAN_SYSTEM = """\
You are a practical meal planning assistant. Given a pantry inventory and
dietary preferences, suggest a week of dinners (or other configured meals).
Prioritise ingredients that are expiring soon. Prefer variety across the week.
Respect all dietary restrictions.
Respond with a JSON array only no prose, no markdown fences.
Each item: {"day": 0-6, "meal_type": "dinner", "recipe_id": <int or null>, "suggestion": "<recipe name>"}
day 0 = Monday, day 6 = Sunday.
If you cannot match a known recipe_id, set recipe_id to null and provide a suggestion name.
"""
@dataclass(frozen=True)
class PlanSuggestion:
day: int # 0 = Monday
meal_type: str
recipe_id: int | None
suggestion: str # human-readable name
def generate_plan(
pantry_items: list[str],
meal_types: list[str],
dietary_notes: str,
router,
) -> list[PlanSuggestion]:
"""Return a list of PlanSuggestion for user review.
Never writes to DB caller must upsert slots after user approves.
Returns an empty list if router is None or response is unparseable.
"""
if router is None:
return []
pantry_text = "\n".join(f"- {item}" for item in pantry_items[:50])
meal_text = ", ".join(meal_types)
user_msg = (
f"Meal types: {meal_text}\n"
f"Dietary notes: {dietary_notes or 'none'}\n\n"
f"Pantry (partial):\n{pantry_text}"
)
try:
response = router.complete(
system=_PLAN_SYSTEM,
user=user_msg,
max_tokens=512,
temperature=0.7,
)
items = json.loads(response.strip())
suggestions = []
for item in items:
if not isinstance(item, dict):
continue
day = item.get("day")
meal_type = item.get("meal_type", "dinner")
if not isinstance(day, int) or day < 0 or day > 6:
continue
suggestions.append(PlanSuggestion(
day=day,
meal_type=meal_type,
recipe_id=item.get("recipe_id"),
suggestion=str(item.get("suggestion", "")),
))
return suggestions
except Exception as exc:
logger.debug("LLM plan generation failed: %s", exc)
return []

View file

@ -0,0 +1,96 @@
# app/services/meal_plan/llm_router.py
# BSL 1.1 — LLM feature
"""Provide a router-compatible LLM client for meal plan generation tasks.
Cloud (CF_ORCH_URL set):
Allocates a cf-text service via cf-orch (3B-7B GGUF, ~2GB VRAM).
Returns an _OrchTextRouter that wraps the cf-text HTTP endpoint
with a .complete(system, user, **kwargs) interface.
Local / self-hosted (no CF_ORCH_URL):
Returns an LLMRouter instance which tries ollama, vllm, or any
backend configured in ~/.config/circuitforge/llm.yaml.
Both paths expose the same interface so llm_timing.py and llm_planner.py
need no knowledge of the backend.
"""
from __future__ import annotations
import logging
import os
from contextlib import nullcontext
logger = logging.getLogger(__name__)
# cf-orch service name and VRAM budget for meal plan LLM tasks.
# These are lighter than recipe_llm (4.0 GB) — cf-text handles them.
_SERVICE_TYPE = "cf-text"
_TTL_S = 120.0
_CALLER = "kiwi-meal-plan"
class _OrchTextRouter:
"""Thin adapter that makes a cf-text HTTP endpoint look like LLMRouter."""
def __init__(self, base_url: str) -> None:
self._base_url = base_url.rstrip("/")
def complete(
self,
system: str = "",
user: str = "",
max_tokens: int = 512,
temperature: float = 0.7,
**_kwargs,
) -> str:
from openai import OpenAI
client = OpenAI(base_url=self._base_url + "/v1", api_key="any")
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": user})
try:
model = client.models.list().data[0].id
except Exception:
model = "local"
resp = client.chat.completions.create(
model=model,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
)
return resp.choices[0].message.content or ""
def get_meal_plan_router():
"""Return an LLM client for meal plan tasks.
Tries cf-orch cf-text allocation first (cloud); falls back to LLMRouter
(local ollama/vllm). Returns None if no backend is available.
"""
cf_orch_url = os.environ.get("CF_ORCH_URL")
if cf_orch_url:
try:
from circuitforge_orch.client import CFOrchClient
client = CFOrchClient(cf_orch_url)
ctx = client.allocate(
service=_SERVICE_TYPE,
ttl_s=_TTL_S,
caller=_CALLER,
)
alloc = ctx.__enter__()
if alloc is not None:
return _OrchTextRouter(alloc.url), ctx
except Exception as exc:
logger.debug("cf-orch cf-text allocation failed, falling back to LLMRouter: %s", exc)
# Local fallback: LLMRouter (ollama / vllm / openai-compat)
try:
from circuitforge_core.llm.router import LLMRouter
return LLMRouter(), nullcontext(None)
except FileNotFoundError:
logger.debug("LLMRouter: no llm.yaml and no LLM env vars — meal plan LLM disabled")
return None, nullcontext(None)
except Exception as exc:
logger.debug("LLMRouter init failed: %s", exc)
return None, nullcontext(None)

View file

@ -0,0 +1,65 @@
# app/services/meal_plan/llm_timing.py
# BSL 1.1 — LLM feature
"""Estimate cook times for recipes missing corpus prep/cook time fields.
Used only when tier allows `meal_plan_llm_timing`. Falls back gracefully
when no LLM backend is available.
Routing: pass a router from get_meal_plan_router() in llm_router.py.
Cloud: cf-text via cf-orch (3B GGUF, ~2GB VRAM).
Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml).
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
_TIMING_PROMPT = """\
You are a practical cook. Given a recipe name and its ingredients, estimate:
1. prep_time: minutes of active prep work (chopping, mixing, etc.)
2. cook_time: minutes of cooking (oven, stovetop, etc.)
Respond with ONLY two integers on separate lines:
prep_time
cook_time
If you cannot estimate, respond with:
0
0
"""
def estimate_timing(recipe_name: str, ingredients: list[str], router) -> tuple[int | None, int | None]:
"""Return (prep_minutes, cook_minutes) for a recipe using LLMRouter.
Returns (None, None) if the router is unavailable or the response is
unparseable. Never raises.
Args:
recipe_name: Name of the recipe.
ingredients: List of raw ingredient strings from the corpus.
router: An LLMRouter instance (from circuitforge_core.llm).
"""
if router is None:
return None, None
ingredient_list = "\n".join(f"- {i}" for i in (ingredients or [])[:15])
prompt = f"Recipe: {recipe_name}\n\nIngredients:\n{ingredient_list}"
try:
response = router.complete(
system=_TIMING_PROMPT,
user=prompt,
max_tokens=16,
temperature=0.0,
)
lines = response.strip().splitlines()
prep = int(lines[0].strip()) if lines else 0
cook = int(lines[1].strip()) if len(lines) > 1 else 0
if prep == 0 and cook == 0:
return None, None
return prep or None, cook or None
except Exception as exc:
logger.debug("LLM timing estimation failed for %r: %s", recipe_name, exc)
return None, None

View file

@ -0,0 +1,26 @@
# app/services/meal_plan/planner.py
"""Plan and slot orchestration — thin layer over Store.
No FastAPI imports. Provides helpers used by the API endpoint.
"""
from __future__ import annotations
from app.db.store import Store
from app.models.schemas.meal_plan import VALID_MEAL_TYPES
def create_plan(store: Store, week_start: str, meal_types: list[str]) -> dict:
"""Create a plan, filtering meal_types to valid values only."""
valid = [t for t in meal_types if t in VALID_MEAL_TYPES]
if not valid:
valid = ["dinner"]
return store.create_meal_plan(week_start, valid)
def get_plan_with_slots(store: Store, plan_id: int) -> dict | None:
"""Return a plan row with its slots list attached, or None."""
plan = store.get_meal_plan(plan_id)
if plan is None:
return None
slots = store.get_plan_slots(plan_id)
return {**plan, "slots": slots}

View file

@ -0,0 +1,91 @@
# app/services/meal_plan/prep_scheduler.py
"""Sequence prep tasks for a batch cooking session.
Pure function no DB or network calls. Sorts tasks by equipment priority
(oven first to maximise oven utilisation) then assigns sequence_order.
"""
from __future__ import annotations
from dataclasses import dataclass
_EQUIPMENT_PRIORITY = {"oven": 0, "stovetop": 1, "cold": 2, "no-heat": 3}
_DEFAULT_PRIORITY = 4
@dataclass(frozen=True)
class PrepTask:
recipe_id: int | None
slot_id: int | None
task_label: str
duration_minutes: int | None
sequence_order: int
equipment: str | None
is_parallel: bool = False
notes: str | None = None
user_edited: bool = False
def _total_minutes(recipe: dict) -> int | None:
prep = recipe.get("prep_time")
cook = recipe.get("cook_time")
if prep is None and cook is None:
return None
return (prep or 0) + (cook or 0)
def _equipment(recipe: dict) -> str | None:
# Corpus recipes don't have an explicit equipment field; use test helper
# field if present, otherwise infer from cook_time (long = oven heuristic).
if "_equipment" in recipe:
return recipe["_equipment"]
minutes = _total_minutes(recipe)
if minutes and minutes >= 45:
return "oven"
return "stovetop"
def build_prep_tasks(slots: list[dict], recipes: list[dict]) -> list[PrepTask]:
"""Return a sequenced list of PrepTask objects from plan slots + recipe rows.
Algorithm:
1. Build a recipe_id recipe dict lookup.
2. Create one task per slot that has a recipe assigned.
3. Sort by equipment priority (oven first).
4. Assign contiguous sequence_order starting at 1.
"""
if not slots or not recipes:
return []
recipe_map: dict[int, dict] = {r["id"]: r for r in recipes}
raw_tasks: list[tuple[int, dict]] = [] # (priority, kwargs)
for slot in slots:
recipe_id = slot.get("recipe_id")
if not recipe_id:
continue
recipe = recipe_map.get(recipe_id)
if not recipe:
continue
eq = _equipment(recipe)
priority = _EQUIPMENT_PRIORITY.get(eq or "", _DEFAULT_PRIORITY)
raw_tasks.append((priority, {
"recipe_id": recipe_id,
"slot_id": slot.get("id"),
"task_label": recipe.get("name", f"Recipe {recipe_id}"),
"duration_minutes": _total_minutes(recipe),
"equipment": eq,
}))
raw_tasks.sort(key=lambda t: t[0])
return [
PrepTask(
recipe_id=kw["recipe_id"],
slot_id=kw["slot_id"],
task_label=kw["task_label"],
duration_minutes=kw["duration_minutes"],
sequence_order=i,
equipment=kw["equipment"],
)
for i, (_, kw) in enumerate(raw_tasks, 1)
]

View file

@ -0,0 +1,88 @@
# app/services/meal_plan/shopping_list.py
"""Compute a shopping list from a meal plan and current pantry inventory.
Pure function no DB or network calls. Takes plain dicts from the Store
and returns GapItem dataclasses.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
@dataclass(frozen=True)
class GapItem:
ingredient_name: str
needed_raw: str | None # first quantity token from recipe text, e.g. "300g"
have_quantity: float | None # pantry quantity when partial match
have_unit: str | None
covered: bool
retailer_links: list = field(default_factory=list) # filled by API layer
_QUANTITY_RE = re.compile(r"^(\d+[\d./]*\s*(?:g|kg|ml|l|oz|lb|cup|cups|tsp|tbsp|tbsps|tsps)?)\b", re.I)
def _extract_quantity(ingredient_text: str) -> str | None:
"""Pull the leading quantity string from a raw ingredient line."""
m = _QUANTITY_RE.match(ingredient_text.strip())
return m.group(1).strip() if m else None
def _normalise(name: str) -> str:
"""Lowercase, strip possessives and plural -s for fuzzy matching."""
return name.lower().strip().rstrip("s")
def compute_shopping_list(
recipes: list[dict],
inventory: list[dict],
) -> tuple[list[GapItem], list[GapItem]]:
"""Return (gap_items, covered_items) for a list of recipe dicts + inventory dicts.
Deduplicates by normalised ingredient name the first recipe's quantity
string wins when the same ingredient appears in multiple recipes.
"""
if not recipes:
return [], []
# Build pantry lookup: normalised_name → inventory row
pantry: dict[str, dict] = {}
for item in inventory:
pantry[_normalise(item["name"])] = item
# Collect unique ingredients with their first quantity token
seen: dict[str, str | None] = {} # normalised_name → needed_raw
for recipe in recipes:
names: list[str] = recipe.get("ingredient_names") or []
raw_lines: list[str] = recipe.get("ingredients") or []
for i, name in enumerate(names):
key = _normalise(name)
if key in seen:
continue
raw = raw_lines[i] if i < len(raw_lines) else ""
seen[key] = _extract_quantity(raw)
gaps: list[GapItem] = []
covered: list[GapItem] = []
for norm_name, needed_raw in seen.items():
pantry_row = pantry.get(norm_name)
if pantry_row:
covered.append(GapItem(
ingredient_name=norm_name,
needed_raw=needed_raw,
have_quantity=pantry_row.get("quantity"),
have_unit=pantry_row.get("unit"),
covered=True,
))
else:
gaps.append(GapItem(
ingredient_name=norm_name,
needed_raw=needed_raw,
have_quantity=None,
have_unit=None,
covered=False,
))
return gaps, covered

View file

@ -995,6 +995,12 @@ def build_from_selection(
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,
@ -1002,7 +1008,7 @@ def build_from_selection(
element_coverage={},
swap_candidates=[],
matched_ingredients=all_matched,
missing_ingredients=[],
missing_ingredients=missing,
directions=tmpl.directions,
notes=tmpl.notes,
level=1,

View file

@ -16,9 +16,21 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"expiry_llm_matching",
"receipt_ocr",
"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
@ -34,7 +46,10 @@ KIWI_FEATURES: dict[str, str] = {
"receipt_ocr": "paid", # BYOK-unlockable
"recipe_suggestions": "paid", # BYOK-unlockable
"expiry_llm_matching": "paid", # BYOK-unlockable
"meal_planning": "paid",
"meal_planning": "free",
"meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack)
"meal_plan_llm": "paid", # LLM-assisted full-week plan generation; BYOK-unlockable
"meal_plan_llm_timing": "paid", # LLM time fill-in for recipes missing corpus times; BYOK-unlockable
"dietary_profiles": "paid",
"style_picker": "paid",
"recipe_collections": "paid",

View file

@ -46,6 +46,18 @@
<span class="sidebar-label">Receipts</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'mealplan' }]" @click="switchTab('mealplan')" aria-label="Meal Plan">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="8" y1="4" x2="8" y2="9"/>
<line x1="16" y1="4" x2="16" y2="9"/>
<line x1="7" y1="14" x2="11" y2="14"/>
<line x1="7" y1="17" x2="14" y2="17"/>
</svg>
<span class="sidebar-label">Meal Plan</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
@ -79,6 +91,9 @@
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
<SettingsView />
</div>
<div v-show="currentTab === 'mealplan'" class="tab-content">
<MealPlanView />
</div>
</div>
</main>
</div>
@ -118,6 +133,17 @@
</svg>
<span class="nav-label">Settings</span>
</button>
<button :class="['nav-item', { active: currentTab === 'mealplan' }]" @click="switchTab('mealplan')" aria-label="Meal Plan">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="8" y1="4" x2="8" y2="9"/>
<line x1="16" y1="4" x2="16" y2="9"/>
<line x1="7" y1="14" x2="11" y2="14"/>
<line x1="7" y1="17" x2="14" y2="17"/>
</svg>
<span class="nav-label">Meal Plan</span>
</button>
</nav>
<!-- Feedback FAB hidden when FORGEJO_API_TOKEN not configured -->
@ -163,12 +189,13 @@ import InventoryList from './components/InventoryList.vue'
import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue'
import MealPlanView from './components/MealPlanView.vue'
import FeedbackButton from './components/FeedbackButton.vue'
import { useInventoryStore } from './stores/inventory'
import { useEasterEggs } from './composables/useEasterEggs'
import { householdAPI } from './services/api'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
const currentTab = ref<Tab>('recipes')
const sidebarCollapsed = ref(false)

View file

@ -186,6 +186,13 @@
@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>
@ -231,6 +238,8 @@ 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' },
@ -281,6 +290,7 @@ function toggleIngredient(name: string) {
const current = new Set(roleOverrides.value[role] ?? [])
current.has(name) ? current.delete(name) : current.add(name)
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
}
function useCustomIngredient() {
@ -288,9 +298,27 @@ function useCustomIngredient() {
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 = ''
}
@ -536,4 +564,23 @@ onMounted(async () => {
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,126 @@
<!-- frontend/src/components/MealPlanGrid.vue -->
<template>
<div class="meal-plan-grid">
<!-- Collapsible header (mobile) -->
<div class="grid-toggle-row">
<span class="grid-label">This week</span>
<button
class="grid-toggle-btn"
:aria-expanded="!collapsed"
:aria-label="collapsed ? 'Show plan' : 'Hide plan'"
@click="collapsed = !collapsed"
>{{ collapsed ? 'Show plan' : 'Hide plan' }}</button>
</div>
<div v-show="!collapsed" class="grid-body">
<!-- Day headers -->
<div class="day-headers">
<div class="meal-type-col-spacer" />
<div
v-for="(day, i) in DAY_LABELS"
:key="i"
class="day-header"
:aria-label="day"
>{{ day }}</div>
</div>
<!-- One row per meal type -->
<div
v-for="mealType in activeMealTypes"
:key="mealType"
class="meal-row"
>
<div class="meal-type-label">{{ mealType }}</div>
<button
v-for="dayIndex in 7"
:key="dayIndex - 1"
class="slot-btn"
:class="{ filled: !!getSlot(dayIndex - 1, mealType) }"
:aria-label="`${DAY_LABELS[dayIndex - 1]} ${mealType}: ${getSlot(dayIndex - 1, mealType)?.recipe_title ?? 'empty'}`"
@click="$emit('slot-click', { dayOfWeek: dayIndex - 1, mealType })"
>
<span v-if="getSlot(dayIndex - 1, mealType)" class="slot-title">
{{ getSlot(dayIndex - 1, mealType)!.recipe_title ?? getSlot(dayIndex - 1, mealType)!.custom_label ?? '...' }}
</span>
<span v-else class="slot-empty" aria-hidden="true">+</span>
</button>
</div>
<!-- Add meal type row (Paid only) -->
<div v-if="canAddMealType" class="add-meal-type-row">
<button class="add-meal-type-btn" @click="$emit('add-meal-type')">
+ Add meal type
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMealPlanStore } from '../stores/mealPlan'
defineProps<{
activeMealTypes: string[]
canAddMealType: boolean
}>()
defineEmits<{
(e: 'slot-click', payload: { dayOfWeek: number; mealType: string }): void
(e: 'add-meal-type'): void
}>()
const store = useMealPlanStore()
const { getSlot } = store
const collapsed = ref(false)
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
</script>
<style scoped>
.meal-plan-grid { display: flex; flex-direction: column; gap: 0.5rem; }
.grid-toggle-row {
display: flex; align-items: center; justify-content: space-between;
padding: 0.25rem 0;
}
.grid-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.07em; opacity: 0.6; }
.grid-toggle-btn {
font-size: 0.75rem; background: none; border: none; cursor: pointer;
color: var(--color-accent); padding: 0.2rem 0.5rem;
}
.grid-body { display: flex; flex-direction: column; gap: 3px; }
.day-headers { display: grid; grid-template-columns: 3rem repeat(7, 1fr); gap: 3px; }
.meal-type-col-spacer { }
.day-header { text-align: center; font-size: 0.7rem; font-weight: 700; padding: 3px; background: var(--color-surface-2); border-radius: 4px; }
.meal-row { display: grid; grid-template-columns: 3rem repeat(7, 1fr); gap: 3px; align-items: start; }
.meal-type-label { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.55; display: flex; align-items: center; font-weight: 600; }
.slot-btn {
border: 1px dashed var(--color-border);
border-radius: 6px;
min-height: 44px;
background: var(--color-surface);
cursor: pointer;
padding: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 0.65rem;
transition: border-color 0.15s, background 0.15s;
width: 100%;
}
.slot-btn:hover { border-color: var(--color-accent); }
.slot-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.slot-btn.filled { border-color: var(--color-success); background: var(--color-success-subtle); }
.slot-title { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; color: var(--color-text); }
.slot-empty { opacity: 0.25; font-size: 1rem; }
.add-meal-type-row { padding: 0.4rem 0 0.2rem; }
.add-meal-type-btn { font-size: 0.75rem; background: none; border: none; cursor: pointer; color: var(--color-accent); padding: 0; }
@media (max-width: 600px) {
.day-headers, .meal-row { grid-template-columns: 2.5rem repeat(7, 1fr); }
}
</style>

View file

@ -0,0 +1,153 @@
<!-- frontend/src/components/MealPlanView.vue -->
<template>
<div class="meal-plan-view">
<!-- Week picker + new plan button -->
<div class="plan-controls">
<select
class="week-select"
:value="activePlan?.id ?? ''"
aria-label="Select week"
@change="onSelectPlan(Number(($event.target as HTMLSelectElement).value))"
>
<option value="" disabled>Select a week...</option>
<option v-for="p in plans" :key="p.id" :value="p.id">
Week of {{ p.week_start }}
</option>
</select>
<button class="new-plan-btn" @click="onNewPlan">+ New week</button>
</div>
<template v-if="activePlan">
<!-- Compact expandable week grid (always visible) -->
<MealPlanGrid
:active-meal-types="activePlan.meal_types"
:can-add-meal-type="canAddMealType"
@slot-click="onSlotClick"
@add-meal-type="onAddMealType"
/>
<!-- Panel tabs: Shopping List | Prep Schedule -->
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
<button
v-for="tab in TABS"
:key="tab.id"
role="tab"
:aria-selected="activeTab === tab.id"
:aria-controls="`tabpanel-${tab.id}`"
:id="`tab-${tab.id}`"
class="panel-tab"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<div
v-show="activeTab === 'shopping'"
id="tabpanel-shopping"
role="tabpanel"
aria-labelledby="tab-shopping"
class="tab-panel"
>
<ShoppingListPanel @load="store.loadShoppingList()" />
</div>
<div
v-show="activeTab === 'prep'"
id="tabpanel-prep"
role="tabpanel"
aria-labelledby="tab-prep"
class="tab-panel"
>
<PrepSessionView @load="store.loadPrepSession()" />
</div>
</template>
<div v-else-if="!loading" class="empty-plan-state">
<p>No meal plan yet for this week.</p>
<button class="new-plan-btn" @click="onNewPlan">Start planning</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useMealPlanStore } from '../stores/mealPlan'
import MealPlanGrid from './MealPlanGrid.vue'
import ShoppingListPanel from './ShoppingListPanel.vue'
import PrepSessionView from './PrepSessionView.vue'
const TABS = [
{ id: 'shopping', label: 'Shopping List' },
{ id: 'prep', label: 'Prep Schedule' },
] as const
type TabId = typeof TABS[number]['id']
const store = useMealPlanStore()
const { plans, activePlan, loading } = storeToRefs(store)
const activeTab = ref<TabId>('shopping')
// canAddMealType is a UI hint backend enforces the paid gate authoritatively
const canAddMealType = computed(() =>
(activePlan.value?.meal_types.length ?? 0) < 4
)
onMounted(() => store.loadPlans())
async function onNewPlan() {
const today = new Date()
const day = today.getDay()
// Compute Monday of current week (getDay: 0=Sun, 1=Mon...)
const monday = new Date(today)
monday.setDate(today.getDate() - ((day + 6) % 7))
const weekStart = monday.toISOString().split('T')[0]
await store.createPlan(weekStart, ['dinner'])
}
async function onSelectPlan(planId: number) {
if (planId) await store.setActivePlan(planId)
}
function onSlotClick(_: { dayOfWeek: number; mealType: string }) {
// Recipe picker integration filed as follow-up
}
function onAddMealType() {
// Add meal type picker Paid gate enforced by backend
}
</script>
<style scoped>
.meal-plan-view { display: flex; flex-direction: column; gap: 1rem; }
.plan-controls { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.week-select {
flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px;
border: 1px solid var(--color-border); background: var(--color-surface);
color: var(--color-text); font-size: 0.85rem;
}
.new-plan-btn {
padding: 0.4rem 1rem; border-radius: 20px; font-size: 0.82rem;
background: var(--color-accent-subtle); color: var(--color-accent);
border: 1px solid var(--color-accent); cursor: pointer; white-space: nowrap;
}
.new-plan-btn:hover { background: var(--color-accent); color: white; }
.panel-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
.panel-tab {
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
background: none; border: 1px solid transparent; border-bottom: none; cursor: pointer;
color: var(--color-text-secondary); transition: color 0.15s, background 0.15s;
}
.panel-tab.active {
color: var(--color-accent); background: var(--color-accent-subtle);
border-color: var(--color-border); border-bottom-color: transparent;
}
.panel-tab:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.tab-panel { padding-top: 0.75rem; }
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
</style>

View file

@ -0,0 +1,70 @@
<template>
<!-- Only shown when user has opted in AND a lifetime key is present (usage != null) -->
<div
v-if="enabled && usage !== null"
class="orch-usage-pill"
:class="{ 'orch-usage-pill--low': isLow }"
:title="`Cloud recipe calls this period: ${usage.calls_used} of ${usage.calls_total}`"
>
<span class="orch-usage-pill__label">
{{ usage.calls_used + usage.topup_calls }} / {{ usage.calls_total }} calls
<span class="orch-usage-pill__reset">· resets {{ resetsLabel }}</span>
</span>
<a
class="orch-usage-pill__topup"
href="https://circuitforge.tech/kiwi/topup"
target="_blank"
rel="noopener"
>Topup</a>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useOrchUsage } from '@/composables/useOrchUsage'
const { usage, enabled, resetsLabel } = useOrchUsage()
// Warn visually when less than 20% remains calm yellow only, no red/panic
const isLow = computed(() => {
if (!usage.value || usage.value.calls_total === 0) return false
const remaining = usage.value.calls_total - usage.value.calls_used + usage.value.topup_calls
return remaining / usage.value.calls_total < 0.2
})
</script>
<style scoped>
.orch-usage-pill {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 0.25rem 0.625rem;
border-radius: 999px;
font-size: var(--font-size-sm);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.orch-usage-pill--low {
background: var(--color-warning-bg);
border-color: var(--color-warning-border);
color: var(--color-warning);
}
.orch-usage-pill__reset {
opacity: 0.7;
}
.orch-usage-pill__topup {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
white-space: nowrap;
margin-left: var(--spacing-xs);
}
.orch-usage-pill__topup:hover {
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,115 @@
<!-- frontend/src/components/PrepSessionView.vue -->
<template>
<div class="prep-session-view">
<div v-if="loading" class="panel-loading">Building prep schedule...</div>
<template v-else-if="prepSession">
<p class="prep-intro">
Tasks are ordered to make the most of your oven and stovetop.
Edit any time estimate that looks wrong your changes are saved.
</p>
<ol class="task-list" role="list">
<li
v-for="task in prepSession.tasks"
:key="task.id"
class="task-item"
:class="{ 'user-edited': task.user_edited }"
>
<div class="task-header">
<span class="task-order" aria-hidden="true">{{ task.sequence_order }}</span>
<span class="task-label">{{ task.task_label }}</span>
<span v-if="task.equipment" class="task-equip">{{ task.equipment }}</span>
</div>
<div class="task-meta">
<label class="duration-label">
Time estimate (min):
<input
type="number"
min="1"
class="duration-input"
:value="task.duration_minutes ?? ''"
:placeholder="task.duration_minutes ? '' : 'unknown'"
:aria-label="`Duration for ${task.task_label} in minutes`"
@change="onDurationChange(task.id, ($event.target as HTMLInputElement).value)"
/>
</label>
<span v-if="task.user_edited" class="edited-badge" title="You edited this">edited</span>
</div>
<div v-if="task.notes" class="task-notes">{{ task.notes }}</div>
</li>
</ol>
<div v-if="!prepSession.tasks.length" class="empty-state">
No recipes assigned yet add some to your plan first.
</div>
</template>
<div v-else class="empty-state">
<button class="load-btn" @click="$emit('load')">Build prep schedule</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useMealPlanStore } from '../stores/mealPlan'
import { storeToRefs } from 'pinia'
defineEmits<{ (e: 'load'): void }>()
const store = useMealPlanStore()
const { prepSession, prepLoading: loading } = storeToRefs(store)
async function onDurationChange(taskId: number, value: string) {
const minutes = parseInt(value, 10)
if (!isNaN(minutes) && minutes > 0) {
await store.updatePrepTask(taskId, { duration_minutes: minutes })
}
}
</script>
<style scoped>
.prep-session-view { display: flex; flex-direction: column; gap: 1rem; }
.panel-loading { font-size: 0.85rem; opacity: 0.6; padding: 1rem 0; }
.prep-intro { font-size: 0.82rem; opacity: 0.65; margin: 0; }
.task-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.task-item {
padding: 0.6rem 0.8rem; border-radius: 8px;
background: var(--color-surface-2); border: 1px solid var(--color-border);
display: flex; flex-direction: column; gap: 0.35rem;
}
.task-item.user-edited { border-color: var(--color-accent); }
.task-header { display: flex; align-items: center; gap: 0.5rem; }
.task-order {
width: 22px; height: 22px; border-radius: 50%;
background: var(--color-accent); color: white;
font-size: 0.7rem; font-weight: 700;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.task-label { flex: 1; font-size: 0.88rem; font-weight: 500; }
.task-equip { font-size: 0.68rem; padding: 2px 6px; border-radius: 12px; background: var(--color-surface); opacity: 0.7; }
.task-meta { display: flex; align-items: center; gap: 0.75rem; }
.duration-label { font-size: 0.75rem; opacity: 0.7; display: flex; align-items: center; gap: 0.3rem; }
.duration-input {
width: 52px; padding: 2px 4px; border-radius: 4px;
border: 1px solid var(--color-border); background: var(--color-surface);
font-size: 0.78rem; color: var(--color-text);
}
.duration-input:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.edited-badge { font-size: 0.65rem; opacity: 0.5; font-style: italic; }
.task-notes { font-size: 0.75rem; opacity: 0.6; }
.empty-state { font-size: 0.85rem; opacity: 0.55; padding: 1rem 0; text-align: center; }
.load-btn {
font-size: 0.85rem; padding: 0.5rem 1.2rem;
background: var(--color-accent-subtle); color: var(--color-accent);
border: 1px solid var(--color-accent); border-radius: 20px; cursor: pointer;
}
.load-btn:hover { background: var(--color-accent); color: white; }
</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

@ -75,6 +75,8 @@
<p v-if="activeLevel" class="level-description text-sm text-secondary mt-xs">
{{ activeLevel.description }}
</p>
<!-- Orch budget pill only visible when user has opted in (Settings toggle) -->
<OrchUsagePill class="mt-xs" />
</div>
<!-- Surprise Me confirmation -->
@ -604,6 +606,7 @@ import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
import SavedRecipesPanel from './SavedRecipesPanel.vue'
import CommunityFeedPanel from './CommunityFeedPanel.vue'
import BuildYourOwnTab from './BuildYourOwnTab.vue'
import OrchUsagePill from './OrchUsagePill.vue'
import type { ForkResult } from '../stores/community'
import type { RecipeSuggestion, GroceryLink } from '../services/api'
import { recipesAPI } from '../services/api'

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

@ -63,6 +63,22 @@
</button>
</div>
</section>
<!-- Display Preferences -->
<section class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Display</h3>
<label class="orch-pill-toggle flex-start gap-sm text-sm">
<input
type="checkbox"
:checked="orchPillEnabled"
@change="setOrchPillEnabled(($event.target as HTMLInputElement).checked)"
/>
Show cloud recipe call budget in Recipes
</label>
<p class="text-xs text-muted mt-xs">
Displays your remaining cloud recipe calls near the level selector. Only visible if you have a lifetime or founders key.
</p>
</section>
</div>
<div class="card mt-md">
<h2 class="section-title text-xl mb-md">Cook History</h2>
@ -159,9 +175,11 @@ import { ref, computed, onMounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
import { useRecipesStore } from '../stores/recipes'
import { householdAPI, type HouseholdStatus } from '../services/api'
import { useOrchUsage } from '../composables/useOrchUsage'
const settingsStore = useSettingsStore()
const recipesStore = useRecipesStore()
const { enabled: orchPillEnabled, setEnabled: setOrchPillEnabled } = useOrchUsage()
const sortedCookLog = computed(() =>
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
@ -450,4 +468,17 @@ onMounted(async () => {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.orch-pill-toggle {
cursor: pointer;
align-items: center;
color: var(--color-text);
}
.orch-pill-toggle input[type="checkbox"] {
accent-color: var(--color-primary);
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,112 @@
<!-- frontend/src/components/ShoppingListPanel.vue -->
<template>
<div class="shopping-list-panel">
<div v-if="loading" class="panel-loading">Loading shopping list...</div>
<template v-else-if="shoppingList">
<!-- Disclosure banner -->
<div v-if="shoppingList.disclosure && shoppingList.gap_items.length" class="disclosure-banner">
{{ shoppingList.disclosure }}
</div>
<!-- Gap items (need to buy) -->
<section v-if="shoppingList.gap_items.length" aria-label="Items to buy">
<h3 class="section-heading">To buy ({{ shoppingList.gap_items.length }})</h3>
<ul class="item-list" role="list">
<li v-for="item in shoppingList.gap_items" :key="item.ingredient_name" class="gap-item">
<label class="item-row">
<input type="checkbox" class="item-check" :aria-label="`Mark ${item.ingredient_name} as grabbed`" />
<span class="item-name">{{ item.ingredient_name }}</span>
<span v-if="item.needed_raw" class="item-qty gap">{{ item.needed_raw }}</span>
</label>
<div v-if="item.retailer_links.length" class="retailer-links">
<a
v-for="link in item.retailer_links"
:key="link.retailer"
:href="link.url"
target="_blank"
rel="noopener noreferrer sponsored"
class="retailer-link"
:aria-label="`Buy ${item.ingredient_name} at ${link.label}`"
>{{ link.label }}</a>
</div>
</li>
</ul>
</section>
<!-- Covered items (already in pantry) -->
<section v-if="shoppingList.covered_items.length" aria-label="Items already in pantry">
<h3 class="section-heading covered-heading">In your pantry ({{ shoppingList.covered_items.length }})</h3>
<ul class="item-list covered-list" role="list">
<li v-for="item in shoppingList.covered_items" :key="item.ingredient_name" class="covered-item">
<span class="check-icon" aria-hidden="true"></span>
<span class="item-name">{{ item.ingredient_name }}</span>
<span v-if="item.have_quantity" class="item-qty">{{ item.have_quantity }} {{ item.have_unit }}</span>
</li>
</ul>
</section>
<div v-if="!shoppingList.gap_items.length && !shoppingList.covered_items.length" class="empty-state">
No ingredients yet add some recipes to your plan first.
</div>
</template>
<div v-else class="empty-state">
<button class="load-btn" @click="$emit('load')">Generate shopping list</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useMealPlanStore } from '../stores/mealPlan'
import { storeToRefs } from 'pinia'
defineEmits<{ (e: 'load'): void }>()
const store = useMealPlanStore()
const { shoppingList, shoppingListLoading: loading } = storeToRefs(store)
</script>
<style scoped>
.shopping-list-panel { display: flex; flex-direction: column; gap: 1rem; }
.panel-loading { font-size: 0.85rem; opacity: 0.6; padding: 1rem 0; }
.disclosure-banner {
font-size: 0.72rem; opacity: 0.55; padding: 0.4rem 0.6rem;
background: var(--color-surface-2); border-radius: 6px;
}
.section-heading { font-size: 0.8rem; font-weight: 600; margin: 0 0 0.5rem; }
.covered-heading { opacity: 0.6; }
.item-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.gap-item { display: flex; flex-direction: column; gap: 3px; padding: 6px 0; border-bottom: 1px solid var(--color-border); }
.gap-item:last-child { border-bottom: none; }
.item-row { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.item-check { width: 16px; height: 16px; flex-shrink: 0; }
.item-name { flex: 1; font-size: 0.85rem; }
.item-qty { font-size: 0.75rem; opacity: 0.7; }
.item-qty.gap { color: var(--color-warning, #e88); opacity: 1; }
.retailer-links { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 1.5rem; }
.retailer-link {
font-size: 0.68rem; padding: 2px 8px; border-radius: 20px;
background: var(--color-surface-2); color: var(--color-accent);
text-decoration: none; border: 1px solid var(--color-border);
transition: background 0.15s;
}
.retailer-link:hover { background: var(--color-accent-subtle); }
.retailer-link:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.covered-item { display: flex; align-items: center; gap: 0.5rem; padding: 4px 0; opacity: 0.6; font-size: 0.82rem; }
.check-icon { color: var(--color-success); font-size: 0.75rem; }
.empty-state { font-size: 0.85rem; opacity: 0.55; padding: 1rem 0; text-align: center; }
.load-btn {
font-size: 0.85rem; padding: 0.5rem 1.2rem;
background: var(--color-accent-subtle); color: var(--color-accent);
border: 1px solid var(--color-accent); border-radius: 20px; cursor: pointer;
}
.load-btn:hover { background: var(--color-accent); color: white; }
</style>

View file

@ -0,0 +1,55 @@
/**
* useOrchUsage reactive cf-orch call budget for lifetime/founders users.
*
* Auto-fetches on mount when enabled. Returns null for subscription users (no cap applies).
* The `enabled` state is persisted to localStorage users opt in to seeing the pill.
*/
import { ref, computed, onMounted } from 'vue'
import { getOrchUsage, type OrchUsage } from '@/services/api'
const STORAGE_KEY = 'kiwi:orchUsagePillEnabled'
// Shared singleton state across all component instances
const usage = ref<OrchUsage | null>(null)
const loading = ref(false)
const enabled = ref<boolean>(
typeof localStorage !== 'undefined'
? localStorage.getItem(STORAGE_KEY) === 'true'
: false
)
async function fetchUsage(): Promise<void> {
loading.value = true
try {
usage.value = await getOrchUsage()
} catch {
// Non-blocking — UI stays hidden on error
usage.value = null
} finally {
loading.value = false
}
}
function setEnabled(val: boolean): void {
enabled.value = val
localStorage.setItem(STORAGE_KEY, String(val))
if (val && usage.value === null && !loading.value) {
fetchUsage()
}
}
const resetsLabel = computed<string>(() => {
if (!usage.value?.resets_on) return ''
const d = new Date(usage.value.resets_on + 'T00:00:00')
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
})
export function useOrchUsage() {
onMounted(() => {
if (enabled.value && usage.value === null && !loading.value) {
fetchUsage()
}
})
return { usage, loading, enabled, setEnabled, fetchUsage, resetsLabel }
}

View file

@ -701,6 +701,122 @@ export const savedRecipesAPI = {
},
}
// --- Meal Plan types ---
export interface MealPlanSlot {
id: number
plan_id: number
day_of_week: number // 0 = Monday
meal_type: string
recipe_id: number | null
recipe_title: string | null
servings: number
custom_label: string | null
}
export interface MealPlan {
id: number
week_start: string // ISO date, e.g. "2026-04-13"
meal_types: string[]
slots: MealPlanSlot[]
created_at: string
}
export interface RetailerLink {
retailer: string
label: string
url: string
}
export interface GapItem {
ingredient_name: string
needed_raw: string | null
have_quantity: number | null
have_unit: string | null
covered: boolean
retailer_links: RetailerLink[]
}
export interface ShoppingList {
plan_id: number
gap_items: GapItem[]
covered_items: GapItem[]
disclosure: string | null
}
export interface PrepTask {
id: number
recipe_id: number | null
task_label: string
duration_minutes: number | null
sequence_order: number
equipment: string | null
is_parallel: boolean
notes: string | null
user_edited: boolean
}
export interface PrepSession {
id: number
plan_id: number
scheduled_date: string
status: 'draft' | 'reviewed' | 'done'
tasks: PrepTask[]
}
// --- Meal Plan API ---
export const mealPlanAPI = {
async list(): Promise<MealPlan[]> {
const resp = await api.get<MealPlan[]>('/meal-plans/')
return resp.data
},
async create(weekStart: string, mealTypes: string[]): Promise<MealPlan> {
const resp = await api.post<MealPlan>('/meal-plans/', { week_start: weekStart, meal_types: mealTypes })
return resp.data
},
async get(planId: number): Promise<MealPlan> {
const resp = await api.get<MealPlan>(`/meal-plans/${planId}`)
return resp.data
},
async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise<MealPlanSlot> {
const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
return resp.data
},
async deleteSlot(planId: number, slotId: number): Promise<void> {
await api.delete(`/meal-plans/${planId}/slots/${slotId}`)
},
async getShoppingList(planId: number): Promise<ShoppingList> {
const resp = await api.get<ShoppingList>(`/meal-plans/${planId}/shopping-list`)
return resp.data
},
async getPrepSession(planId: number): Promise<PrepSession> {
const resp = await api.get<PrepSession>(`/meal-plans/${planId}/prep-session`)
return resp.data
},
async updatePrepTask(planId: number, taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<PrepTask> {
const resp = await api.patch<PrepTask>(`/meal-plans/${planId}/prep-session/tasks/${taskId}`, data)
return resp.data
},
}
// ========== Orch Usage Types ==========
export interface OrchUsage {
calls_used: number
topup_calls: number
calls_total: number
period_start: string // ISO date YYYY-MM-DD
resets_on: string // ISO date YYYY-MM-DD
}
// ========== Browser Types ==========
export interface BrowserDomain {
@ -747,4 +863,11 @@ export const browserAPI = {
},
}
// ── Orch Usage ────────────────────────────────────────────────────────────────
export async function getOrchUsage(): Promise<OrchUsage | null> {
const resp = await api.get<OrchUsage | null>('/orch-usage')
return resp.data
}
export default api

View file

@ -0,0 +1,135 @@
// frontend/src/stores/mealPlan.ts
/**
* Meal Plan Store
*
* Manages the active week plan, shopping list, and prep session.
* Uses immutable update patterns never mutates store state in place.
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
mealPlanAPI,
type MealPlan,
type MealPlanSlot,
type ShoppingList,
type PrepSession,
type PrepTask,
} from '../services/api'
export const useMealPlanStore = defineStore('mealPlan', () => {
const plans = ref<MealPlan[]>([])
const activePlan = ref<MealPlan | null>(null)
const shoppingList = ref<ShoppingList | null>(null)
const prepSession = ref<PrepSession | null>(null)
const loading = ref(false)
const shoppingListLoading = ref(false)
const prepLoading = ref(false)
const slots = computed<MealPlanSlot[]>(() => activePlan.value?.slots ?? [])
function getSlot(dayOfWeek: number, mealType: string): MealPlanSlot | undefined {
return slots.value.find(s => s.day_of_week === dayOfWeek && s.meal_type === mealType)
}
async function loadPlans() {
loading.value = true
try {
plans.value = await mealPlanAPI.list()
} finally {
loading.value = false
}
}
async function createPlan(weekStart: string, mealTypes: string[]): Promise<MealPlan> {
const plan = await mealPlanAPI.create(weekStart, mealTypes)
plans.value = [plan, ...plans.value]
activePlan.value = plan
shoppingList.value = null
prepSession.value = null
return plan
}
async function setActivePlan(planId: number) {
loading.value = true
try {
activePlan.value = await mealPlanAPI.get(planId)
shoppingList.value = null
prepSession.value = null
} finally {
loading.value = false
}
}
async function upsertSlot(dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise<void> {
if (!activePlan.value) return
const slot = await mealPlanAPI.upsertSlot(activePlan.value.id, dayOfWeek, mealType, data)
const current = activePlan.value
const idx = current.slots.findIndex(
s => s.day_of_week === dayOfWeek && s.meal_type === mealType
)
activePlan.value = {
...current,
slots: idx >= 0
? [...current.slots.slice(0, idx), slot, ...current.slots.slice(idx + 1)]
: [...current.slots, slot],
}
shoppingList.value = null
prepSession.value = null
}
async function clearSlot(dayOfWeek: number, mealType: string): Promise<void> {
if (!activePlan.value) return
const slot = getSlot(dayOfWeek, mealType)
if (!slot) return
await mealPlanAPI.deleteSlot(activePlan.value.id, slot.id)
activePlan.value = {
...activePlan.value,
slots: activePlan.value.slots.filter(s => s.id !== slot.id),
}
shoppingList.value = null
prepSession.value = null
}
async function loadShoppingList(): Promise<void> {
if (!activePlan.value) return
shoppingListLoading.value = true
try {
shoppingList.value = await mealPlanAPI.getShoppingList(activePlan.value.id)
} finally {
shoppingListLoading.value = false
}
}
async function loadPrepSession(): Promise<void> {
if (!activePlan.value) return
prepLoading.value = true
try {
prepSession.value = await mealPlanAPI.getPrepSession(activePlan.value.id)
} finally {
prepLoading.value = false
}
}
async function updatePrepTask(taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<void> {
if (!activePlan.value || !prepSession.value) return
const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data)
const idx = prepSession.value.tasks.findIndex(t => t.id === taskId)
if (idx >= 0) {
prepSession.value = {
...prepSession.value,
tasks: [
...prepSession.value.tasks.slice(0, idx),
updated,
...prepSession.value.tasks.slice(idx + 1),
],
}
}
}
return {
plans, activePlan, shoppingList, prepSession,
loading, shoppingListLoading, prepLoading, slots,
getSlot, loadPlans, createPlan, setActivePlan,
upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
}
})

View file

@ -3,6 +3,10 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,

View file

@ -1,9 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
base: process.env.VITE_BASE_URL ?? '/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
rollupOptions: {
output: {

View file

@ -0,0 +1,116 @@
# tests/api/test_meal_plans.py
"""Integration tests for /api/v1/meal-plans/ endpoints."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.cloud_session import get_session
from app.db.session import get_store
from app.main import app
client = TestClient(app)
def _make_session(tier: str = "free") -> MagicMock:
m = MagicMock()
m.tier = tier
m.has_byok = False
return m
def _make_store() -> MagicMock:
m = MagicMock()
m.create_meal_plan.return_value = {
"id": 1, "week_start": "2026-04-14",
"meal_types": ["dinner"], "created_at": "2026-04-12T10:00:00",
}
m.list_meal_plans.return_value = []
m.get_meal_plan.return_value = None
m.get_plan_slots.return_value = []
m.upsert_slot.return_value = {
"id": 1, "plan_id": 1, "day_of_week": 0, "meal_type": "dinner",
"recipe_id": 42, "recipe_title": "Pasta", "servings": 2.0, "custom_label": None,
}
m.get_inventory.return_value = []
m.get_plan_recipes.return_value = []
m.get_prep_session_for_plan.return_value = None
m.create_prep_session.return_value = {
"id": 1, "plan_id": 1, "scheduled_date": "2026-04-13",
"status": "draft", "created_at": "2026-04-12T10:00:00",
}
m.get_prep_tasks.return_value = []
m.bulk_insert_prep_tasks.return_value = []
return m
@pytest.fixture()
def free_session():
session = _make_session("free")
store = _make_store()
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_store] = lambda: store
yield store
app.dependency_overrides.clear()
@pytest.fixture()
def paid_session():
session = _make_session("paid")
store = _make_store()
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_store] = lambda: store
yield store
app.dependency_overrides.clear()
def test_create_plan_free_tier_locks_to_dinner(free_session):
resp = client.post("/api/v1/meal-plans/", json={
"week_start": "2026-04-13", "meal_types": ["breakfast", "dinner"]
})
assert resp.status_code == 200
# Free tier forced to dinner-only regardless of request
free_session.create_meal_plan.assert_called_once_with("2026-04-13", ["dinner"])
def test_create_plan_paid_tier_respects_meal_types(paid_session):
resp = client.post("/api/v1/meal-plans/", json={
"week_start": "2026-04-13", "meal_types": ["breakfast", "lunch", "dinner"]
})
assert resp.status_code == 200
paid_session.create_meal_plan.assert_called_once_with(
"2026-04-13", ["breakfast", "lunch", "dinner"]
)
def test_list_plans_returns_200(free_session):
resp = client.get("/api/v1/meal-plans/")
assert resp.status_code == 200
assert resp.json() == []
def test_upsert_slot_returns_200(free_session):
free_session.get_meal_plan.return_value = {
"id": 1, "week_start": "2026-04-14", "meal_types": ["dinner"],
"created_at": "2026-04-12T10:00:00",
}
resp = client.put(
"/api/v1/meal-plans/1/slots/0/dinner",
json={"recipe_id": 42, "servings": 2.0},
)
assert resp.status_code == 200
def test_get_shopping_list_returns_200(free_session):
free_session.get_meal_plan.return_value = {
"id": 1, "week_start": "2026-04-14", "meal_types": ["dinner"],
"created_at": "2026-04-12T10:00:00",
}
resp = client.get("/api/v1/meal-plans/1/shopping-list")
assert resp.status_code == 200
body = resp.json()
assert "gap_items" in body
assert "covered_items" in body

View file

@ -0,0 +1,146 @@
"""Tests that orch budget gating is wired into the suggest endpoint."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.cloud_session import CloudUser, get_session
from app.main import app
def _make_session(
tier: str = "paid",
has_byok: bool = False,
license_key: str | None = "CFG-KIWI-TEST-TEST-TEST",
) -> CloudUser:
return CloudUser(
user_id="test-user",
tier=tier,
db=Path("/tmp/kiwi_test.db"),
has_byok=has_byok,
license_key=license_key,
)
@patch("app.api.endpoints.recipes._suggest_in_thread")
@patch("app.services.heimdall_orch.requests.post")
def test_orch_budget_exhausted_downgrades_to_l2(mock_post, mock_suggest):
"""When orch budget is denied, L3 request is downgraded to L2 and orch_fallback=True."""
deny_resp = MagicMock()
deny_resp.ok = True
deny_resp.json.return_value = {
"allowed": False, "calls_used": 60, "calls_total": 60,
"topup_calls": 0, "period_start": "2026-04-14", "resets_on": "2026-05-14",
}
mock_post.return_value = deny_resp
fallback_result = MagicMock()
fallback_result.suggestions = []
fallback_result.element_gaps = []
fallback_result.grocery_list = []
fallback_result.grocery_links = []
fallback_result.rate_limited = False
fallback_result.rate_limit_count = 0
fallback_result.orch_fallback = False
fallback_result.model_copy.return_value = fallback_result
mock_suggest.return_value = fallback_result
app.dependency_overrides[get_session] = lambda: _make_session(tier="paid")
client = TestClient(app)
resp = client.post("/api/v1/recipes/suggest", json={
"pantry_items": ["chicken", "rice"],
"level": 3,
"tier": "paid",
})
app.dependency_overrides.clear()
assert resp.status_code == 200
# The engine should have been called with level=2 (downgraded from 3)
called_req = mock_suggest.call_args[0][1]
assert called_req.level == 2
@patch("app.api.endpoints.recipes._suggest_in_thread")
@patch("app.services.heimdall_orch.requests.post")
def test_orch_budget_allowed_passes_l3_through(mock_post, mock_suggest):
allow_resp = MagicMock()
allow_resp.ok = True
allow_resp.json.return_value = {
"allowed": True, "calls_used": 5, "calls_total": 60,
"topup_calls": 0, "period_start": "2026-04-14", "resets_on": "2026-05-14",
}
mock_post.return_value = allow_resp
ok_result = MagicMock()
ok_result.suggestions = []
ok_result.element_gaps = []
ok_result.grocery_list = []
ok_result.grocery_links = []
ok_result.rate_limited = False
ok_result.rate_limit_count = 0
ok_result.orch_fallback = False
mock_suggest.return_value = ok_result
app.dependency_overrides[get_session] = lambda: _make_session(tier="paid")
client = TestClient(app)
resp = client.post("/api/v1/recipes/suggest", json={
"pantry_items": ["chicken", "rice"],
"level": 3,
"tier": "paid",
})
app.dependency_overrides.clear()
assert resp.status_code == 200
called_req = mock_suggest.call_args[0][1]
assert called_req.level == 3
@patch("app.api.endpoints.recipes._suggest_in_thread")
def test_no_orch_check_for_local_tier(mock_suggest):
"""Local sessions never hit the orch check."""
ok_result = MagicMock()
ok_result.suggestions = []
ok_result.element_gaps = []
ok_result.grocery_list = []
ok_result.grocery_links = []
ok_result.rate_limited = False
ok_result.rate_limit_count = 0
ok_result.orch_fallback = False
mock_suggest.return_value = ok_result
app.dependency_overrides[get_session] = lambda: _make_session(tier="local", license_key=None)
client = TestClient(app)
with patch("app.services.heimdall_orch.requests.post") as mock_check:
client.post("/api/v1/recipes/suggest", json={
"pantry_items": ["chicken"],
"level": 3,
"tier": "local",
})
mock_check.assert_not_called()
app.dependency_overrides.clear()
@patch("app.api.endpoints.recipes._suggest_in_thread")
def test_no_orch_check_when_license_key_is_none(mock_suggest):
"""Subscription users (no license_key) skip the orch check."""
ok_result = MagicMock()
ok_result.suggestions = []
ok_result.element_gaps = []
ok_result.grocery_list = []
ok_result.grocery_links = []
ok_result.rate_limited = False
ok_result.rate_limit_count = 0
ok_result.orch_fallback = False
mock_suggest.return_value = ok_result
app.dependency_overrides[get_session] = lambda: _make_session(tier="paid", license_key=None)
client = TestClient(app)
with patch("app.services.heimdall_orch.requests.post") as mock_check:
client.post("/api/v1/recipes/suggest", json={
"pantry_items": ["chicken"],
"level": 3,
"tier": "paid",
})
mock_check.assert_not_called()
app.dependency_overrides.clear()

View file

@ -0,0 +1,57 @@
"""Tests for the /orch-usage proxy endpoint."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.cloud_session import CloudUser, get_session
from app.main import app
def _make_session(license_key=None, tier="paid"):
return CloudUser(
user_id="user-1",
tier=tier,
db=Path("/tmp/kiwi_test.db"),
has_byok=False,
license_key=license_key,
)
def test_orch_usage_returns_data_for_lifetime_user():
"""GET /orch-usage with a lifetime key returns usage data."""
app.dependency_overrides[get_session] = lambda: _make_session(
license_key="CFG-KIWI-TEST-0000-0000"
)
client = TestClient(app)
with patch("app.api.endpoints.orch_usage.get_orch_usage") as mock_usage:
mock_usage.return_value = {
"calls_used": 10,
"topup_calls": 0,
"calls_total": 60,
"period_start": "2026-04-14",
"resets_on": "2026-05-14",
}
resp = client.get("/api/v1/orch-usage")
app.dependency_overrides.clear()
assert resp.status_code == 200
data = resp.json()
assert data["calls_used"] == 10
assert data["calls_total"] == 60
def test_orch_usage_returns_null_for_subscription_user():
"""GET /orch-usage with no license_key returns null."""
app.dependency_overrides[get_session] = lambda: _make_session(
license_key=None, tier="paid"
)
client = TestClient(app)
resp = client.get("/api/v1/orch-usage")
app.dependency_overrides.clear()
assert resp.status_code == 200
assert resp.json() is None

View file

@ -4,14 +4,14 @@ from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path):
def client(tmp_path, monkeypatch):
"""FastAPI test client with a seeded in-memory DB."""
import os
os.environ["KIWI_DB_PATH"] = str(tmp_path / "test.db")
db_path = tmp_path / "test.db"
os.environ["CLOUD_MODE"] = "false"
from app.main import app
# Seed DB before app imports so migrations run and data is present
from app.db.store import Store
store = Store(tmp_path / "test.db")
store = Store(db_path)
store.conn.execute(
"INSERT INTO products (name, barcode) VALUES (?,?)", ("chicken breast", None)
)
@ -25,6 +25,11 @@ def client(tmp_path):
"INSERT INTO inventory_items (product_id, location, status) VALUES (2,'pantry','available')"
)
store.conn.commit()
store.close()
# Patch the module-level DB path used by local-mode session resolution
import app.cloud_session as _cs
monkeypatch.setattr(_cs, "_LOCAL_KIWI_DB", db_path)
from app.main import app
return TestClient(app)
@ -65,7 +70,7 @@ def test_post_build_returns_recipe(client):
})
assert resp.status_code == 200
data = resp.json()
assert data["id"] == -1
assert data["id"] > 0 # persisted to DB with real integer ID
assert len(data["directions"]) > 0

View file

@ -0,0 +1,116 @@
"""Tests for the heimdall_orch service module."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
def _make_orch_response(
allowed: bool, calls_used: int = 0, calls_total: int = 60, topup_calls: int = 0
) -> MagicMock:
"""Helper to create a mock response object."""
mock = MagicMock()
mock.ok = True
mock.json.return_value = {
"allowed": allowed,
"calls_used": calls_used,
"calls_total": calls_total,
"topup_calls": topup_calls,
"period_start": "2026-04-14",
"resets_on": "2026-05-14",
}
return mock
def test_check_orch_budget_returns_allowed_when_ok() -> None:
"""check_orch_budget() returns the response when the call succeeds."""
with patch("app.services.heimdall_orch.requests.post") as mock_post:
mock_post.return_value = _make_orch_response(allowed=True, calls_used=5)
from app.services.heimdall_orch import check_orch_budget
result = check_orch_budget("CFG-KIWI-XXXX-XXXX-XXXX", "kiwi")
assert result["allowed"] is True
assert result["calls_used"] == 5
def test_check_orch_budget_returns_denied_when_exhausted() -> None:
"""check_orch_budget() returns allowed=False when budget is exhausted."""
with patch("app.services.heimdall_orch.requests.post") as mock_post:
mock_post.return_value = _make_orch_response(allowed=False, calls_used=60, calls_total=60)
from app.services.heimdall_orch import check_orch_budget
result = check_orch_budget("CFG-KIWI-XXXX-XXXX-XXXX", "kiwi")
assert result["allowed"] is False
def test_check_orch_budget_fails_open_on_network_error() -> None:
"""Network failure must never block the user — check_orch_budget fails open."""
with patch("app.services.heimdall_orch.requests.post", side_effect=Exception("timeout")):
from app.services import heimdall_orch
result = heimdall_orch.check_orch_budget("CFG-KIWI-XXXX-XXXX-XXXX", "kiwi")
assert result["allowed"] is True
def test_check_orch_budget_fails_open_on_http_error() -> None:
"""HTTP error responses fail open."""
with patch("app.services.heimdall_orch.requests.post") as mock_post:
mock_resp = MagicMock()
mock_resp.ok = False
mock_resp.status_code = 500
mock_post.return_value = mock_resp
from app.services import heimdall_orch
result = heimdall_orch.check_orch_budget("CFG-KIWI-XXXX-XXXX-XXXX", "kiwi")
assert result["allowed"] is True
def test_get_orch_usage_returns_data() -> None:
"""get_orch_usage() returns the response data on success."""
with patch("app.services.heimdall_orch.requests.get") as mock_get:
mock_resp = MagicMock()
mock_resp.ok = True
mock_resp.json.return_value = {
"calls_used": 10,
"topup_calls": 0,
"calls_total": 60,
"period_start": "2026-04-14",
"resets_on": "2026-05-14",
}
mock_get.return_value = mock_resp
from app.services.heimdall_orch import get_orch_usage
result = get_orch_usage("CFG-KIWI-XXXX-XXXX-XXXX", "kiwi")
assert result["calls_used"] == 10
def test_get_orch_usage_returns_zeros_on_error() -> None:
"""get_orch_usage() returns zeros when the call fails (non-blocking)."""
with patch("app.services.heimdall_orch.requests.get", side_effect=Exception("timeout")):
from app.services import heimdall_orch
result = heimdall_orch.get_orch_usage("CFG-KIWI-XXXX-XXXX-XXXX", "kiwi")
assert result["calls_used"] == 0
assert result["calls_total"] == 0
def test_get_orch_usage_returns_zeros_on_http_error() -> None:
"""get_orch_usage() returns zeros on HTTP errors (non-blocking)."""
with patch("app.services.heimdall_orch.requests.get") as mock_get:
mock_resp = MagicMock()
mock_resp.ok = False
mock_resp.status_code = 404
mock_get.return_value = mock_resp
from app.services import heimdall_orch
result = heimdall_orch.get_orch_usage("CFG-KIWI-XXXX-XXXX-XXXX", "kiwi")
assert result["calls_used"] == 0
assert result["calls_total"] == 0

View file

@ -0,0 +1,55 @@
# tests/services/test_meal_plan_prep_scheduler.py
"""Unit tests for prep_scheduler.py — no DB or network."""
from __future__ import annotations
import pytest
from app.services.meal_plan.prep_scheduler import PrepTask, build_prep_tasks
def _recipe(id_: int, name: str, prep_time: int | None, cook_time: int | None, equipment: str) -> dict:
return {
"id": id_, "name": name,
"prep_time": prep_time, "cook_time": cook_time,
"_equipment": equipment, # test helper field
}
def _slot(slot_id: int, recipe: dict, day: int = 0) -> dict:
return {"id": slot_id, "recipe_id": recipe["id"], "day_of_week": day,
"meal_type": "dinner", "servings": 2.0}
def test_builds_task_per_slot():
recipe = _recipe(1, "Pasta", 10, 20, "stovetop")
tasks = build_prep_tasks(
slots=[_slot(1, recipe)],
recipes=[recipe],
)
assert len(tasks) == 1
assert tasks[0].task_label == "Pasta"
assert tasks[0].duration_minutes == 30 # prep + cook
def test_oven_tasks_scheduled_first():
oven_recipe = _recipe(1, "Roast Chicken", 10, 60, "oven")
stove_recipe = _recipe(2, "Rice", 2, 20, "stovetop")
tasks = build_prep_tasks(
slots=[_slot(1, stove_recipe), _slot(2, oven_recipe)],
recipes=[stove_recipe, oven_recipe],
)
orders = {t.task_label: t.sequence_order for t in tasks}
assert orders["Roast Chicken"] < orders["Rice"]
def test_missing_corpus_time_leaves_duration_none():
recipe = _recipe(1, "Mystery Dish", None, None, "stovetop")
tasks = build_prep_tasks(slots=[_slot(1, recipe)], recipes=[recipe])
assert tasks[0].duration_minutes is None
def test_sequence_order_is_contiguous_from_one():
recipes = [_recipe(i, f"Recipe {i}", 10, 10, "stovetop") for i in range(1, 4)]
slots = [_slot(i, r) for i, r in enumerate(recipes, 1)]
tasks = build_prep_tasks(slots=slots, recipes=recipes)
orders = sorted(t.sequence_order for t in tasks)
assert orders == [1, 2, 3]

View file

@ -0,0 +1,51 @@
# tests/services/test_meal_plan_shopping_list.py
"""Unit tests for shopping_list.py — no network, no DB."""
from __future__ import annotations
import pytest
from app.services.meal_plan.shopping_list import GapItem, compute_shopping_list
def _recipe(ingredient_names: list[str], ingredients: list[str]) -> dict:
return {"ingredient_names": ingredient_names, "ingredients": ingredients}
def _inv_item(name: str, quantity: float, unit: str) -> dict:
return {"name": name, "quantity": quantity, "unit": unit}
def test_item_in_pantry_is_covered():
recipes = [_recipe(["pasta"], ["500g pasta"])]
inventory = [_inv_item("pasta", 400, "g")]
gaps, covered = compute_shopping_list(recipes, inventory)
assert len(covered) == 1
assert covered[0].ingredient_name == "pasta"
assert covered[0].covered is True
assert len(gaps) == 0
def test_item_not_in_pantry_is_gap():
recipes = [_recipe(["chicken breast"], ["300g chicken breast"])]
inventory = []
gaps, covered = compute_shopping_list(recipes, inventory)
assert len(gaps) == 1
assert gaps[0].ingredient_name == "chicken breast"
assert gaps[0].covered is False
assert gaps[0].needed_raw == "300g"
def test_duplicate_ingredient_across_recipes_deduplicates():
recipes = [
_recipe(["onion"], ["2 onions"]),
_recipe(["onion"], ["1 onion"]),
]
inventory = []
gaps, _ = compute_shopping_list(recipes, inventory)
names = [g.ingredient_name for g in gaps]
assert names.count("onion") == 1
def test_empty_plan_returns_empty_lists():
gaps, covered = compute_shopping_list([], [])
assert gaps == []
assert covered == []

View file

@ -0,0 +1,27 @@
# tests/test_meal_plan_tiers.py
from app.tiers import can_use
def test_meal_planning_is_free():
"""Basic meal planning (dinner-only, manual) is available to free tier."""
assert can_use("meal_planning", "free") is True
def test_meal_plan_config_requires_paid():
"""Configurable meal types (breakfast/lunch/snack) require Paid."""
assert can_use("meal_plan_config", "free") is False
assert can_use("meal_plan_config", "paid") is True
def test_meal_plan_llm_byok_unlockable():
"""LLM plan generation is Paid but BYOK-unlockable on Free."""
assert can_use("meal_plan_llm", "free", has_byok=False) is False
assert can_use("meal_plan_llm", "free", has_byok=True) is True
assert can_use("meal_plan_llm", "paid") is True
def test_meal_plan_llm_timing_byok_unlockable():
"""LLM time estimation is Paid but BYOK-unlockable on Free."""
assert can_use("meal_plan_llm_timing", "free", has_byok=False) is False
assert can_use("meal_plan_llm_timing", "free", has_byok=True) is True
assert can_use("meal_plan_llm_timing", "paid") is True