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,
|
get_keywords_for_category,
|
||||||
)
|
)
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
from app.services.recipe.recipe_engine import RecipeEngine
|
||||||
|
from app.services.heimdall_orch import check_orch_budget
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -68,7 +69,25 @@ async def suggest_recipes(
|
||||||
)
|
)
|
||||||
if req.style_id and not can_use("style_picker", req.tier):
|
if req.style_id and not can_use("style_picker", req.tier):
|
||||||
raise HTTPException(status_code=403, detail="Style picker requires Paid 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")
|
@router.get("/browse/domains")
|
||||||
|
|
@ -212,11 +231,27 @@ async def build_recipe(
|
||||||
for item in items
|
for item in items
|
||||||
if item.get("product_name")
|
if item.get("product_name")
|
||||||
}
|
}
|
||||||
return build_from_selection(
|
suggestion = build_from_selection(
|
||||||
template_slug=req.template_id,
|
template_slug=req.template_id,
|
||||||
role_overrides=req.role_overrides,
|
role_overrides=req.role_overrides,
|
||||||
pantry_set=pantry_set,
|
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:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
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
|
from app.api.endpoints.community import router as community_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
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(ocr.router, prefix="/receipts", tags=["ocr"])
|
||||||
api_router.include_router(export.router, tags=["export"])
|
api_router.include_router(export.router, tags=["export"])
|
||||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
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(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||||
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
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(imitate.router, prefix="/imitate", tags=["imitate"])
|
||||||
|
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)
|
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
|
has_byok: bool # True if a configured LLM backend is present in llm.yaml
|
||||||
household_id: str | None = None
|
household_id: str | None = None
|
||||||
is_household_owner: bool = False
|
is_household_owner: bool = False
|
||||||
|
license_key: str | None = None # key_display for lifetime/founders keys; None for subscription/free
|
||||||
|
|
||||||
|
|
||||||
# ── JWT validation ─────────────────────────────────────────────────────────────
|
# ── 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)
|
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]:
|
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool, str | None]:
|
||||||
"""Returns (tier, household_id | None, is_household_owner)."""
|
"""Returns (tier, household_id | None, is_household_owner, license_key | None)."""
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
cached = _TIER_CACHE.get(user_id)
|
cached = _TIER_CACHE.get(user_id)
|
||||||
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
|
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
|
||||||
entry = cached[0]
|
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:
|
if not HEIMDALL_ADMIN_TOKEN:
|
||||||
return "free", None, False
|
return "free", None, False, None
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{HEIMDALL_URL}/admin/cloud/resolve",
|
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")
|
tier = data.get("tier", "free")
|
||||||
household_id = data.get("household_id")
|
household_id = data.get("household_id")
|
||||||
is_owner = data.get("is_household_owner", False)
|
is_owner = data.get("is_household_owner", False)
|
||||||
|
license_key = data.get("key_display")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, 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)
|
_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
|
return tier, household_id, is_owner, license_key
|
||||||
|
|
||||||
|
|
||||||
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
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)
|
user_id = validate_session_jwt(token)
|
||||||
_ensure_provisioned(user_id)
|
_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(
|
return CloudUser(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
tier=tier,
|
tier=tier,
|
||||||
|
|
@ -258,6 +260,7 @@ def get_session(request: Request) -> CloudUser:
|
||||||
has_byok=has_byok,
|
has_byok=has_byok,
|
||||||
household_id=household_id,
|
household_id=household_id,
|
||||||
is_household_owner=is_household_owner,
|
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",
|
"ingredients", "ingredient_names", "directions",
|
||||||
"keywords", "element_coverage",
|
"keywords", "element_coverage",
|
||||||
# saved recipe columns
|
# saved recipe columns
|
||||||
"style_tags"):
|
"style_tags",
|
||||||
|
# meal plan columns
|
||||||
|
"meal_types"):
|
||||||
if key in d and isinstance(d[key], str):
|
if key in d and isinstance(d[key], str):
|
||||||
try:
|
try:
|
||||||
d[key] = json.loads(d[key])
|
d[key] = json.loads(d[key])
|
||||||
|
|
@ -686,6 +688,44 @@ class Store:
|
||||||
def get_recipe(self, recipe_id: int) -> dict | None:
|
def get_recipe(self, recipe_id: int) -> dict | None:
|
||||||
return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
|
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]]:
|
def get_element_profiles(self, names: list[str]) -> dict[str, list[str]]:
|
||||||
"""Return {ingredient_name: [element_tag, ...]} for the given names.
|
"""Return {ingredient_name: [element_tag, ...]} for the given names.
|
||||||
|
|
||||||
|
|
@ -1041,6 +1081,123 @@ class Store:
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
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:
|
def get_current_pseudonym(self, directus_user_id: str) -> str | None:
|
||||||
"""Return the current community pseudonym for this user, or None if not set."""
|
"""Return the current community pseudonym for this user, or None if not set."""
|
||||||
cur = self.conn.execute(
|
cur = self.conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import api_router
|
from app.api.routes import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.services.meal_plan.affiliates import register_kiwi_programs
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Starting Kiwi API...")
|
logger.info("Starting Kiwi API...")
|
||||||
settings.ensure_dirs()
|
settings.ensure_dirs()
|
||||||
|
register_kiwi_programs()
|
||||||
|
|
||||||
# Start LLM background task scheduler
|
# Start LLM background task scheduler
|
||||||
from app.tasks.scheduler import get_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)
|
grocery_links: list[GroceryLink] = Field(default_factory=list)
|
||||||
rate_limited: bool = False
|
rate_limited: bool = False
|
||||||
rate_limit_count: int = 0
|
rate_limit_count: int = 0
|
||||||
|
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
|
||||||
|
|
||||||
|
|
||||||
class NutritionFilters(BaseModel):
|
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())
|
effective_pantry = pantry_set | set(role_overrides.values())
|
||||||
title = _personalized_title(tmpl, effective_pantry, seed + tmpl.id)
|
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(
|
return RecipeSuggestion(
|
||||||
id=tmpl.id,
|
id=tmpl.id,
|
||||||
title=title,
|
title=title,
|
||||||
|
|
@ -1002,7 +1008,7 @@ def build_from_selection(
|
||||||
element_coverage={},
|
element_coverage={},
|
||||||
swap_candidates=[],
|
swap_candidates=[],
|
||||||
matched_ingredients=all_matched,
|
matched_ingredients=all_matched,
|
||||||
missing_ingredients=[],
|
missing_ingredients=missing,
|
||||||
directions=tmpl.directions,
|
directions=tmpl.directions,
|
||||||
notes=tmpl.notes,
|
notes=tmpl.notes,
|
||||||
level=1,
|
level=1,
|
||||||
|
|
|
||||||
17
app/tiers.py
17
app/tiers.py
|
|
@ -16,9 +16,21 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"expiry_llm_matching",
|
"expiry_llm_matching",
|
||||||
"receipt_ocr",
|
"receipt_ocr",
|
||||||
"style_classifier",
|
"style_classifier",
|
||||||
|
"meal_plan_llm",
|
||||||
|
"meal_plan_llm_timing",
|
||||||
"community_fork_adapt",
|
"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
|
# Feature → minimum tier required
|
||||||
KIWI_FEATURES: dict[str, str] = {
|
KIWI_FEATURES: dict[str, str] = {
|
||||||
# Free tier
|
# Free tier
|
||||||
|
|
@ -34,7 +46,10 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"receipt_ocr": "paid", # BYOK-unlockable
|
"receipt_ocr": "paid", # BYOK-unlockable
|
||||||
"recipe_suggestions": "paid", # BYOK-unlockable
|
"recipe_suggestions": "paid", # BYOK-unlockable
|
||||||
"expiry_llm_matching": "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",
|
"dietary_profiles": "paid",
|
||||||
"style_picker": "paid",
|
"style_picker": "paid",
|
||||||
"recipe_collections": "paid",
|
"recipe_collections": "paid",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,18 @@
|
||||||
<span class="sidebar-label">Receipts</span>
|
<span class="sidebar-label">Receipts</span>
|
||||||
</button>
|
</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')">
|
<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">
|
<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"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
|
@ -79,6 +91,9 @@
|
||||||
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
||||||
<SettingsView />
|
<SettingsView />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="currentTab === 'mealplan'" class="tab-content">
|
||||||
|
<MealPlanView />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,6 +133,17 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Settings</span>
|
<span class="nav-label">Settings</span>
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
<!-- 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 ReceiptsView from './components/ReceiptsView.vue'
|
||||||
import RecipesView from './components/RecipesView.vue'
|
import RecipesView from './components/RecipesView.vue'
|
||||||
import SettingsView from './components/SettingsView.vue'
|
import SettingsView from './components/SettingsView.vue'
|
||||||
|
import MealPlanView from './components/MealPlanView.vue'
|
||||||
import FeedbackButton from './components/FeedbackButton.vue'
|
import FeedbackButton from './components/FeedbackButton.vue'
|
||||||
import { useInventoryStore } from './stores/inventory'
|
import { useInventoryStore } from './stores/inventory'
|
||||||
import { useEasterEggs } from './composables/useEasterEggs'
|
import { useEasterEggs } from './composables/useEasterEggs'
|
||||||
import { householdAPI } from './services/api'
|
import { householdAPI } from './services/api'
|
||||||
|
|
||||||
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
|
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
|
||||||
|
|
||||||
const currentTab = ref<Tab>('recipes')
|
const currentTab = ref<Tab>('recipes')
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,13 @@
|
||||||
@close="phase = 'select'"
|
@close="phase = 'select'"
|
||||||
@cooked="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">
|
<div class="byo-actions mt-sm">
|
||||||
<button class="btn btn-secondary" @click="resetToTemplate">Try a different build</button>
|
<button class="btn btn-secondary" @click="resetToTemplate">Try a different build</button>
|
||||||
<button class="btn btn-secondary" @click="phase = 'wizard'">Adjust ingredients</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 buildLoading = ref(false)
|
||||||
const buildError = ref<string | null>(null)
|
const buildError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Shopping list is derived from builtRecipe.missing_ingredients (computed by backend)
|
||||||
|
|
||||||
const missingModes = [
|
const missingModes = [
|
||||||
{ label: 'Available only', value: 'hidden' },
|
{ label: 'Available only', value: 'hidden' },
|
||||||
{ label: 'Show missing', value: 'greyed' },
|
{ label: 'Show missing', value: 'greyed' },
|
||||||
|
|
@ -281,6 +290,7 @@ function toggleIngredient(name: string) {
|
||||||
const current = new Set(roleOverrides.value[role] ?? [])
|
const current = new Set(roleOverrides.value[role] ?? [])
|
||||||
current.has(name) ? current.delete(name) : current.add(name)
|
current.has(name) ? current.delete(name) : current.add(name)
|
||||||
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
|
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useCustomIngredient() {
|
function useCustomIngredient() {
|
||||||
|
|
@ -288,9 +298,27 @@ function useCustomIngredient() {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const role = currentRole.value?.display
|
const role = currentRole.value?.display
|
||||||
if (!role) return
|
if (!role) return
|
||||||
|
|
||||||
|
// Add to role overrides so it's included in the build request
|
||||||
const current = new Set(roleOverrides.value[role] ?? [])
|
const current = new Set(roleOverrides.value[role] ?? [])
|
||||||
current.add(name)
|
current.add(name)
|
||||||
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
|
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 = ''
|
filterText.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -536,4 +564,23 @@ onMounted(async () => {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-decoration: underline;
|
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>
|
</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">
|
<div class="add-pantry-col">
|
||||||
<p v-if="addError" role="alert" aria-live="assertive" class="add-error text-xs">{{ addError }}</p>
|
<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>
|
<p v-if="addedToPantry" role="status" aria-live="polite" class="add-success text-xs">✓ Added to pantry!</p>
|
||||||
<button
|
<div class="grocery-actions">
|
||||||
class="btn btn-accent flex-1"
|
<button
|
||||||
:disabled="addingToPantry"
|
class="btn btn-primary flex-1"
|
||||||
@click="addToPantry"
|
@click="copyGroceryList"
|
||||||
>
|
>{{ groceryCopied ? '✓ Copied!' : `📋 Grocery list (${checkedCount})` }}</button>
|
||||||
<span v-if="addingToPantry">Adding…</span>
|
<button
|
||||||
<span v-else>Add {{ checkedCount }} to pantry</span>
|
class="btn btn-secondary"
|
||||||
</button>
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<button v-else class="btn btn-primary flex-1" @click="handleCook">
|
<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 addingToPantry = ref(false)
|
||||||
const addedToPantry = ref(false)
|
const addedToPantry = ref(false)
|
||||||
const addError = ref<string | null>(null)
|
const addError = ref<string | null>(null)
|
||||||
|
const groceryCopied = ref(false)
|
||||||
|
|
||||||
const checkedCount = computed(() => checkedIngredients.value.size)
|
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 {
|
function groceryLinkFor(ingredient: string): GroceryLink | undefined {
|
||||||
const needle = ingredient.toLowerCase()
|
const needle = ingredient.toLowerCase()
|
||||||
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
|
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
|
||||||
|
|
@ -577,6 +598,12 @@ function handleCook() {
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grocery-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.add-error {
|
.add-error {
|
||||||
color: var(--color-error, #dc2626);
|
color: var(--color-error, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@
|
||||||
<p v-if="activeLevel" class="level-description text-sm text-secondary mt-xs">
|
<p v-if="activeLevel" class="level-description text-sm text-secondary mt-xs">
|
||||||
{{ activeLevel.description }}
|
{{ activeLevel.description }}
|
||||||
</p>
|
</p>
|
||||||
|
<!-- Orch budget pill — only visible when user has opted in (Settings toggle) -->
|
||||||
|
<OrchUsagePill class="mt-xs" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Surprise Me confirmation -->
|
<!-- Surprise Me confirmation -->
|
||||||
|
|
@ -604,6 +606,7 @@ import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
|
||||||
import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
||||||
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
||||||
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
||||||
|
import OrchUsagePill from './OrchUsagePill.vue'
|
||||||
import type { ForkResult } from '../stores/community'
|
import type { ForkResult } from '../stores/community'
|
||||||
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
||||||
import { recipesAPI } from '../services/api'
|
import { recipesAPI } from '../services/api'
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ async function submit() {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 200;
|
z-index: 500;
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,22 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
<div class="card mt-md">
|
<div class="card mt-md">
|
||||||
<h2 class="section-title text-xl mb-md">Cook History</h2>
|
<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 { useSettingsStore } from '../stores/settings'
|
||||||
import { useRecipesStore } from '../stores/recipes'
|
import { useRecipesStore } from '../stores/recipes'
|
||||||
import { householdAPI, type HouseholdStatus } from '../services/api'
|
import { householdAPI, type HouseholdStatus } from '../services/api'
|
||||||
|
import { useOrchUsage } from '../composables/useOrchUsage'
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const recipesStore = useRecipesStore()
|
const recipesStore = useRecipesStore()
|
||||||
|
const { enabled: orchPillEnabled, setEnabled: setOrchPillEnabled } = useOrchUsage()
|
||||||
|
|
||||||
const sortedCookLog = computed(() =>
|
const sortedCookLog = computed(() =>
|
||||||
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
|
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
|
||||||
|
|
@ -450,4 +468,17 @@ onMounted(async () => {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
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>
|
</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 ==========
|
// ========== Browser Types ==========
|
||||||
|
|
||||||
export interface BrowserDomain {
|
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
|
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": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
base: process.env.VITE_BASE_URL ?? '/',
|
base: process.env.VITE_BASE_URL ?? '/',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
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
|
@pytest.fixture
|
||||||
def client(tmp_path):
|
def client(tmp_path, monkeypatch):
|
||||||
"""FastAPI test client with a seeded in-memory DB."""
|
"""FastAPI test client with a seeded in-memory DB."""
|
||||||
import os
|
import os
|
||||||
os.environ["KIWI_DB_PATH"] = str(tmp_path / "test.db")
|
db_path = tmp_path / "test.db"
|
||||||
os.environ["CLOUD_MODE"] = "false"
|
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
|
from app.db.store import Store
|
||||||
store = Store(tmp_path / "test.db")
|
store = Store(db_path)
|
||||||
store.conn.execute(
|
store.conn.execute(
|
||||||
"INSERT INTO products (name, barcode) VALUES (?,?)", ("chicken breast", None)
|
"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')"
|
"INSERT INTO inventory_items (product_id, location, status) VALUES (2,'pantry','available')"
|
||||||
)
|
)
|
||||||
store.conn.commit()
|
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)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,7 +70,7 @@ def test_post_build_returns_recipe(client):
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["id"] == -1
|
assert data["id"] > 0 # persisted to DB with real integer ID
|
||||||
assert len(data["directions"]) > 0
|
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