Compare commits
37 commits
1882116235
...
33c619b6b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 33c619b6b5 | |||
| 1ae54c370d | |||
| b4f8bde952 | |||
| bdfbc963b7 | |||
| 99e9cbb8c1 | |||
| 006582b179 | |||
| 1a6898324c | |||
| 01216b82c3 | |||
| 2071540a56 | |||
| bd73ca0b6d | |||
| 9941227fae | |||
| 3933136666 | |||
| b4f031e87d | |||
| fbae9ced72 | |||
| 19c0664637 | |||
| e52c406d0a | |||
| 4281b0ce19 | |||
| f54127a8cc | |||
| 062b5d16a1 | |||
| 5f094eb37a | |||
| 2baa8c49a9 | |||
| faaa6fbf86 | |||
| 67b521559e | |||
| a7fc441105 | |||
| 543c64ea30 | |||
| 4865498db9 | |||
| 482666907b | |||
| bfc63f1fc9 | |||
| 536eedfd6c | |||
| 98087120ac | |||
| b9dd1427de | |||
| 25027762cf | |||
| 4459b1ab7e | |||
| ffb34c9c62 | |||
| 067b0821af | |||
| 594fd3f3bf | |||
| 3235fb365f |
49 changed files with 2990 additions and 31 deletions
24
.gitleaks.toml
Normal file
24
.gitleaks.toml
Normal 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-',
|
||||
]
|
||||
294
app/api/endpoints/meal_plans.py
Normal file
294
app/api/endpoints/meal_plans.py
Normal 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)
|
||||
27
app/api/endpoints/orch_usage.py
Normal file
27
app/api/endpoints/orch_usage.py
Normal 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")
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
8
app/db/migrations/022_meal_plans.sql
Normal file
8
app/db/migrations/022_meal_plans.sql
Normal 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'))
|
||||
);
|
||||
11
app/db/migrations/023_meal_plan_slots.sql
Normal file
11
app/db/migrations/023_meal_plan_slots.sql
Normal 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)
|
||||
);
|
||||
10
app/db/migrations/024_prep_sessions.sql
Normal file
10
app/db/migrations/024_prep_sessions.sql
Normal 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'))
|
||||
);
|
||||
15
app/db/migrations/025_prep_tasks.sql
Normal file
15
app/db/migrations/025_prep_tasks.sql
Normal 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'))
|
||||
);
|
||||
159
app/db/store.py
159
app/db/store.py
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
96
app/models/schemas/meal_plan.py
Normal file
96
app/models/schemas/meal_plan.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
80
app/services/heimdall_orch.py
Normal file
80
app/services/heimdall_orch.py
Normal 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": "",
|
||||
}
|
||||
1
app/services/meal_plan/__init__.py
Normal file
1
app/services/meal_plan/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core)."""
|
||||
108
app/services/meal_plan/affiliates.py
Normal file
108
app/services/meal_plan/affiliates.py
Normal 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())
|
||||
91
app/services/meal_plan/llm_planner.py
Normal file
91
app/services/meal_plan/llm_planner.py
Normal 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 []
|
||||
96
app/services/meal_plan/llm_router.py
Normal file
96
app/services/meal_plan/llm_router.py
Normal 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)
|
||||
65
app/services/meal_plan/llm_timing.py
Normal file
65
app/services/meal_plan/llm_timing.py
Normal 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
|
||||
26
app/services/meal_plan/planner.py
Normal file
26
app/services/meal_plan/planner.py
Normal 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}
|
||||
91
app/services/meal_plan/prep_scheduler.py
Normal file
91
app/services/meal_plan/prep_scheduler.py
Normal 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)
|
||||
]
|
||||
88
app/services/meal_plan/shopping_list.py
Normal file
88
app/services/meal_plan/shopping_list.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
17
app/tiers.py
17
app/tiers.py
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
126
frontend/src/components/MealPlanGrid.vue
Normal file
126
frontend/src/components/MealPlanGrid.vue
Normal 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>
|
||||
153
frontend/src/components/MealPlanView.vue
Normal file
153
frontend/src/components/MealPlanView.vue
Normal 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>
|
||||
70
frontend/src/components/OrchUsagePill.vue
Normal file
70
frontend/src/components/OrchUsagePill.vue
Normal 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>
|
||||
115
frontend/src/components/PrepSessionView.vue
Normal file
115
frontend/src/components/PrepSessionView.vue
Normal 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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
112
frontend/src/components/ShoppingListPanel.vue
Normal file
112
frontend/src/components/ShoppingListPanel.vue
Normal 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>
|
||||
55
frontend/src/composables/useOrchUsage.ts
Normal file
55
frontend/src/composables/useOrchUsage.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
135
frontend/src/stores/mealPlan.ts
Normal file
135
frontend/src/stores/mealPlan.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
|
@ -3,6 +3,10 @@
|
|||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
116
tests/api/test_meal_plans.py
Normal file
116
tests/api/test_meal_plans.py
Normal 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
|
||||
146
tests/api/test_orch_budget_in_recipes.py
Normal file
146
tests/api/test_orch_budget_in_recipes.py
Normal 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()
|
||||
57
tests/api/test_orch_usage.py
Normal file
57
tests/api/test_orch_usage.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
116
tests/services/test_heimdall_orch.py
Normal file
116
tests/services/test_heimdall_orch.py
Normal 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
|
||||
55
tests/services/test_meal_plan_prep_scheduler.py
Normal file
55
tests/services/test_meal_plan_prep_scheduler.py
Normal 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]
|
||||
51
tests/services/test_meal_plan_shopping_list.py
Normal file
51
tests/services/test_meal_plan_shopping_list.py
Normal 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 == []
|
||||
27
tests/test_meal_plan_tiers.py
Normal file
27
tests/test_meal_plan_tiers.py
Normal 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
|
||||
Loading…
Reference in a new issue