Merge pull request 'feat: Phase 2 — saved recipes, browser, accessibility, level UX' (#69) from feature/orch-auto-lifecycle into main

This commit is contained in:
pyr0ball 2026-04-08 15:13:45 -07:00
commit 3530071187
46 changed files with 4490 additions and 365 deletions

View file

@ -75,3 +75,11 @@ DEMO_MODE=false
# FORGEJO_API_TOKEN=
# FORGEJO_REPO=Circuit-Forge/kiwi
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
# Affiliate links (optional — plain URLs are shown if unset)
# Amazon Associates tag (circuitforge_core.affiliates, retailer="amazon")
# AMAZON_ASSOCIATES_TAG=circuitforge-20
# Instacart affiliate ID (circuitforge_core.affiliates, retailer="instacart")
# INSTACART_AFFILIATE_ID=circuitforge
# Walmart Impact network affiliate ID (inline, path-based redirect)
# WALMART_AFFILIATE_ID=

3
.gitignore vendored
View file

@ -25,3 +25,6 @@ data/
# Test artifacts (MagicMock sqlite files from pytest)
<MagicMock*
# Playwright / debug screenshots
debug-screenshots/

View file

@ -6,7 +6,9 @@
Scan barcodes, photograph receipts, and get recipe ideas based on what you already have — before it expires.
**Status:** Pre-alpha · CircuitForge LLC
**LLM support is optional.** Inventory tracking, barcode scanning, expiry alerts, CSV export, and receipt upload all work without any LLM configured. AI features (receipt OCR, recipe suggestions, meal planning) activate when a backend is available and are BYOK-unlockable at any tier.
**Status:** Beta · CircuitForge LLC
---
@ -14,9 +16,14 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea
- **Inventory tracking** — add items by barcode scan, receipt upload, or manually
- **Expiry alerts** — know what's about to go bad
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier)
- **Recipe suggestions** — LLM-powered ideas based on what's expiring (Paid tier, BYOK-unlockable)
- **Recipe browser** — browse the full recipe corpus by cuisine, meal type, dietary preference, or main ingredient; pantry match percentage shown inline (Free)
- **Saved recipes** — bookmark any recipe with notes, a 05 star rating, and free-text style tags (Free); organize into named collections (Paid)
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable)
- **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable)
- **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable)
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier)
- **LLM backend config** — configure inference via `circuitforge-core` env-var system; BYOK unlocks Paid AI features at any tier
- **Feedback FAB** — in-app feedback button; status probed on load, hidden if CF feedback endpoint unreachable
## Stack
@ -52,8 +59,13 @@ cp .env.example .env
| Receipt upload | ✓ | ✓ | ✓ |
| Expiry alerts | ✓ | ✓ | ✓ |
| CSV export | ✓ | ✓ | ✓ |
| Recipe browser (domain/category) | ✓ | ✓ | ✓ |
| Save recipes + notes + star rating | ✓ | ✓ | ✓ |
| Style tags (manual, free-text) | ✓ | ✓ | ✓ |
| Receipt OCR | BYOK | ✓ | ✓ |
| Recipe suggestions | BYOK | ✓ | ✓ |
| Recipe suggestions (L1L4) | BYOK | ✓ | ✓ |
| Named recipe collections | — | ✓ | ✓ |
| LLM style auto-classifier | — | BYOK | ✓ |
| Meal planning | — | ✓ | ✓ |
| Multi-household | — | — | ✓ |
| Leftover mode | — | — | ✓ |

View file

@ -1,169 +1,9 @@
"""
Feedback endpoint creates Forgejo issues from in-app feedback.
Ported from peregrine/scripts/feedback_api.py; adapted for Kiwi context.
"""
from __future__ import annotations
import os
import platform
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
import requests
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
"""Feedback router — provided by circuitforge-core."""
from circuitforge_core.api import make_feedback_router
from app.core.config import settings
router = APIRouter()
_ROOT = Path(__file__).resolve().parents[3]
# ── Forgejo helpers ────────────────────────────────────────────────────────────
_LABEL_COLORS = {
"beta-feedback": "#0075ca",
"needs-triage": "#e4e669",
"bug": "#d73a4a",
"feature-request": "#a2eeef",
"question": "#d876e3",
}
def _forgejo_headers() -> dict:
token = os.environ.get("FORGEJO_API_TOKEN", "")
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _ensure_labels(label_names: list[str]) -> list[int]:
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
headers = _forgejo_headers()
resp = requests.get(f"{base}/repos/{repo}/labels", headers=headers, timeout=10)
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
ids: list[int] = []
for name in label_names:
if name in existing:
ids.append(existing[name])
else:
r = requests.post(
f"{base}/repos/{repo}/labels",
headers=headers,
json={"name": name, "color": _LABEL_COLORS.get(name, "#ededed")},
timeout=10,
)
if r.ok:
ids.append(r.json()["id"])
return ids
def _collect_context(tab: str) -> dict:
"""Collect lightweight app context: tab, version, platform, timestamp."""
try:
version = subprocess.check_output(
["git", "describe", "--tags", "--always"],
cwd=_ROOT, text=True, timeout=5,
).strip()
except Exception:
version = "dev"
return {
"tab": tab,
"version": version,
"demo_mode": settings.DEMO_MODE,
"cloud_mode": settings.CLOUD_MODE,
"platform": platform.platform(),
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
def _build_issue_body(form: dict, context: dict) -> str:
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
lines: list[str] = [
f"## {_TYPE_LABELS.get(form.get('type', 'other'), '💬 Other')}",
"",
form.get("description", ""),
"",
]
if form.get("type") == "bug" and form.get("repro"):
lines += ["### Reproduction Steps", "", form["repro"], ""]
lines += ["### Context", ""]
for k, v in context.items():
lines.append(f"- **{k}:** {v}")
lines.append("")
if form.get("submitter"):
lines += ["---", f"*Submitted by: {form['submitter']}*"]
return "\n".join(lines)
# ── Schemas ────────────────────────────────────────────────────────────────────
class FeedbackRequest(BaseModel):
title: str
description: str
type: Literal["bug", "feature", "other"] = "other"
repro: str = ""
tab: str = "unknown"
submitter: str = "" # optional "Name <email>" attribution
class FeedbackResponse(BaseModel):
issue_number: int
issue_url: str
# ── Routes ─────────────────────────────────────────────────────────────────────
@router.get("/status")
def feedback_status() -> dict:
"""Return whether feedback submission is configured on this instance."""
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not settings.DEMO_MODE}
@router.post("", response_model=FeedbackResponse)
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
"""
File a Forgejo issue from in-app feedback.
Silently disabled when FORGEJO_API_TOKEN is not set (demo/offline mode).
"""
token = os.environ.get("FORGEJO_API_TOKEN", "")
if not token:
raise HTTPException(
status_code=503,
detail="Feedback disabled: FORGEJO_API_TOKEN not configured.",
)
if settings.DEMO_MODE:
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
context = _collect_context(payload.tab)
form = {
"type": payload.type,
"description": payload.description,
"repro": payload.repro,
"submitter": payload.submitter,
}
body = _build_issue_body(form, context)
labels = ["beta-feedback", "needs-triage"]
labels.append({"bug": "bug", "feature": "feature-request"}.get(payload.type, "question"))
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
headers = _forgejo_headers()
label_ids = _ensure_labels(labels)
resp = requests.post(
f"{base}/repos/{repo}/issues",
headers=headers,
json={"title": payload.title, "body": body, "labels": label_ids},
timeout=15,
)
if not resp.ok:
raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
data = resp.json()
return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])
router = make_feedback_router(
repo="Circuit-Forge/kiwi",
product="kiwi",
demo_mode_fn=lambda: settings.DEMO_MODE,
)

View file

@ -0,0 +1,211 @@
"""Household management endpoints — shared pantry for Premium users."""
from __future__ import annotations
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
import sqlite3
import requests
from fastapi import APIRouter, Depends, HTTPException
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN, get_session
from app.db.store import Store
from app.models.schemas.household import (
HouseholdAcceptRequest,
HouseholdAcceptResponse,
HouseholdCreateResponse,
HouseholdInviteResponse,
HouseholdMember,
HouseholdRemoveMemberRequest,
HouseholdStatusResponse,
MessageResponse,
)
log = logging.getLogger(__name__)
router = APIRouter()
_INVITE_TTL_DAYS = 7
_KIWI_BASE_URL = os.environ.get("KIWI_BASE_URL", "https://menagerie.circuitforge.tech/kiwi")
def _require_premium(session: CloudUser = Depends(get_session)) -> CloudUser:
if session.tier not in ("premium", "ultra", "local"):
raise HTTPException(status_code=403, detail="Household features require Premium tier.")
return session
def _require_household_owner(session: CloudUser = Depends(_require_premium)) -> CloudUser:
if not session.is_household_owner or not session.household_id:
raise HTTPException(status_code=403, detail="Only the household owner can perform this action.")
return session
def _household_store(household_id: str) -> Store:
"""Open the household DB directly (used during invite acceptance).
Sets row_factory so dict-style column access works on raw conn queries.
"""
db_path = CLOUD_DATA_ROOT / f"household_{household_id}" / "kiwi.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
store = Store(db_path)
store.conn.row_factory = sqlite3.Row
return store
def _heimdall_post(path: str, body: dict) -> dict:
"""Call Heimdall admin API. Returns response dict or raises HTTPException."""
if not HEIMDALL_ADMIN_TOKEN:
log.warning("HEIMDALL_ADMIN_TOKEN not set — household Heimdall call skipped")
return {}
try:
resp = requests.post(
f"{HEIMDALL_URL}{path}",
json=body,
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
timeout=10,
)
if not resp.ok:
raise HTTPException(status_code=502, detail=f"Heimdall error: {resp.text}")
return resp.json()
except requests.RequestException as exc:
raise HTTPException(status_code=502, detail=f"Heimdall unreachable: {exc}")
@router.post("/create", response_model=HouseholdCreateResponse)
async def create_household(session: CloudUser = Depends(_require_premium)):
"""Create a new household. The calling user becomes owner."""
if session.household_id:
raise HTTPException(status_code=409, detail="You are already in a household.")
data = _heimdall_post("/admin/household/create", {"owner_user_id": session.user_id})
household_id = data.get("household_id")
if not household_id:
# Heimdall returned OK but without a household_id — treat as server error.
# Fall back to a local stub only when HEIMDALL_ADMIN_TOKEN is unset (dev mode).
if HEIMDALL_ADMIN_TOKEN:
raise HTTPException(status_code=500, detail="Heimdall did not return a household_id.")
household_id = "local-household"
return HouseholdCreateResponse(
household_id=household_id,
message="Household created. Share an invite link to add members.",
)
@router.get("/status", response_model=HouseholdStatusResponse)
async def household_status(session: CloudUser = Depends(_require_premium)):
"""Return current user's household membership status."""
if not session.household_id:
return HouseholdStatusResponse(in_household=False)
members: list[HouseholdMember] = []
if HEIMDALL_ADMIN_TOKEN:
try:
resp = requests.get(
f"{HEIMDALL_URL}/admin/household/{session.household_id}",
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
timeout=5,
)
if resp.ok:
raw = resp.json()
for m in raw.get("members", []):
members.append(HouseholdMember(
user_id=m["user_id"],
joined_at=m.get("joined_at", ""),
is_owner=m["user_id"] == raw.get("owner_user_id"),
))
except Exception as exc:
log.warning("Could not fetch household members: %s", exc)
return HouseholdStatusResponse(
in_household=True,
household_id=session.household_id,
is_owner=session.is_household_owner,
members=members,
)
@router.post("/invite", response_model=HouseholdInviteResponse)
async def create_invite(session: CloudUser = Depends(_require_household_owner)):
"""Generate a one-time invite token valid for 7 days."""
store = Store(session.db)
token = secrets.token_hex(32)
expires_at = (datetime.now(timezone.utc) + timedelta(days=_INVITE_TTL_DAYS)).isoformat()
store.conn.execute(
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
VALUES (?, ?, ?, ?)""",
(token, session.household_id, session.user_id, expires_at),
)
store.conn.commit()
invite_url = f"{_KIWI_BASE_URL}/#/join?household_id={session.household_id}&token={token}"
return HouseholdInviteResponse(token=token, invite_url=invite_url, expires_at=expires_at)
@router.post("/accept", response_model=HouseholdAcceptResponse)
async def accept_invite(
body: HouseholdAcceptRequest,
session: CloudUser = Depends(get_session),
):
"""Accept a household invite. Opens the household DB directly to validate the token."""
if session.household_id:
raise HTTPException(status_code=409, detail="You are already in a household.")
hh_store = _household_store(body.household_id)
now = datetime.now(timezone.utc).isoformat()
row = hh_store.conn.execute(
"""SELECT token, expires_at, used_at FROM household_invites
WHERE token = ? AND household_id = ?""",
(body.token, body.household_id),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Invite not found.")
if row["used_at"] is not None:
raise HTTPException(status_code=410, detail="Invite already used.")
if row["expires_at"] < now:
raise HTTPException(status_code=410, detail="Invite has expired.")
hh_store.conn.execute(
"UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?",
(now, session.user_id, body.token),
)
hh_store.conn.commit()
_heimdall_post("/admin/household/add-member", {
"household_id": body.household_id,
"user_id": session.user_id,
})
return HouseholdAcceptResponse(
message="You have joined the household. Reload the app to switch to the shared pantry.",
household_id=body.household_id,
)
@router.post("/leave", response_model=MessageResponse)
async def leave_household(session: CloudUser = Depends(_require_premium)) -> MessageResponse:
"""Leave the current household (non-owners only)."""
if not session.household_id:
raise HTTPException(status_code=400, detail="You are not in a household.")
if session.is_household_owner:
raise HTTPException(status_code=400, detail="The household owner cannot leave. Delete the household instead.")
_heimdall_post("/admin/household/remove-member", {
"household_id": session.household_id,
"user_id": session.user_id,
})
return MessageResponse(message="You have left the household. Reload the app to return to your personal pantry.")
@router.post("/remove-member", response_model=MessageResponse)
async def remove_member(
body: HouseholdRemoveMemberRequest,
session: CloudUser = Depends(_require_household_owner),
) -> MessageResponse:
"""Remove a member from the household (owner only)."""
if body.user_id == session.user_id:
raise HTTPException(status_code=400, detail="Use /leave to remove yourself.")
_heimdall_post("/admin/household/remove-member", {
"household_id": session.household_id,
"user_id": body.user_id,
})
return MessageResponse(message=f"Member {body.user_id} removed from household.")

View file

@ -16,6 +16,9 @@ from app.db.session import get_store
from app.db.store import Store
from app.models.schemas.inventory import (
BarcodeScanResponse,
BulkAddByNameRequest,
BulkAddByNameResponse,
BulkAddItemResult,
InventoryItemCreate,
InventoryItemResponse,
InventoryItemUpdate,
@ -130,6 +133,34 @@ async def create_inventory_item(body: InventoryItemCreate, store: Store = Depend
return InventoryItemResponse.model_validate(item)
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depends(get_store)):
"""Create pantry items from a list of ingredient names (no barcode required).
Uses get_or_create_product so re-adding an existing product is idempotent.
"""
results: list[BulkAddItemResult] = []
for entry in body.items:
try:
product, _ = await asyncio.to_thread(
store.get_or_create_product, entry.name, None, source="shopping"
)
item = await asyncio.to_thread(
store.add_inventory_item,
product["id"],
entry.location,
quantity=entry.quantity,
unit=entry.unit,
source="shopping",
)
results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"]))
except Exception as exc:
results.append(BulkAddItemResult(name=entry.name, ok=False, error=str(exc)))
added = sum(1 for r in results if r.ok)
return BulkAddByNameResponse(added=added, failed=len(results) - added, results=results)
@router.get("/items", response_model=List[InventoryItemResponse])
async def list_inventory_items(
location: Optional[str] = None,

View file

@ -1,14 +1,21 @@
"""Recipe suggestion endpoints."""
"""Recipe suggestion and browser endpoints."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from app.cloud_session import CloudUser, get_session
from app.db.store import Store
from app.models.schemas.recipe import RecipeRequest, RecipeResult
from app.services.recipe.browser_domains import (
DOMAINS,
get_category_names,
get_domain_labels,
get_keywords_for_category,
)
from app.services.recipe.recipe_engine import RecipeEngine
from app.tiers import can_use
@ -52,6 +59,90 @@ async def suggest_recipes(
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
@router.get("/browse/domains")
async def list_browse_domains(
session: CloudUser = Depends(get_session),
) -> list[dict]:
"""Return available domain schemas for the recipe browser."""
return get_domain_labels()
@router.get("/browse/{domain}")
async def list_browse_categories(
domain: str,
session: CloudUser = Depends(get_session),
) -> list[dict]:
"""Return categories with recipe counts for a given domain."""
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
keywords_by_category = {
cat: get_keywords_for_category(domain, cat)
for cat in get_category_names(domain)
}
def _get(db_path: Path) -> list[dict]:
store = Store(db_path)
try:
return store.get_browser_categories(domain, keywords_by_category)
finally:
store.close()
return await asyncio.to_thread(_get, session.db)
@router.get("/browse/{domain}/{category}")
async def browse_recipes(
domain: str,
category: str,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
pantry_items: Annotated[str | None, Query()] = None,
session: CloudUser = Depends(get_session),
) -> dict:
"""Return a paginated list of recipes for a domain/category.
Pass pantry_items as a comma-separated string to receive match_pct
badges on each result.
"""
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
keywords = get_keywords_for_category(domain, category)
if not keywords:
raise HTTPException(
status_code=404,
detail=f"Unknown category '{category}' in domain '{domain}'.",
)
pantry_list = (
[p.strip() for p in pantry_items.split(",") if p.strip()]
if pantry_items
else None
)
def _browse(db_path: Path) -> dict:
store = Store(db_path)
try:
result = store.browse_recipes(
keywords=keywords,
page=page,
page_size=page_size,
pantry_items=pantry_list,
)
store.log_browser_telemetry(
domain=domain,
category=category,
page=page,
result_count=result["total"],
)
return result
finally:
store.close()
return await asyncio.to_thread(_browse, session.db)
@router.get("/{recipe_id}")
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
def _get(db_path: Path, rid: int) -> dict | None:

View file

@ -0,0 +1,186 @@
"""Saved recipe bookmark endpoints."""
from __future__ import annotations
import asyncio
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from app.cloud_session import CloudUser, get_session
from app.db.store import Store
from app.models.schemas.saved_recipe import (
CollectionMemberRequest,
CollectionRequest,
CollectionSummary,
SavedRecipeSummary,
SaveRecipeRequest,
UpdateSavedRecipeRequest,
)
from app.tiers import can_use
router = APIRouter()
def _in_thread(db_path: Path, fn):
"""Run a Store operation in a worker thread with its own connection."""
store = Store(db_path)
try:
return fn(store)
finally:
store.close()
def _to_summary(row: dict, store: Store) -> SavedRecipeSummary:
collection_ids = store.get_saved_recipe_collection_ids(row["id"])
return SavedRecipeSummary(
id=row["id"],
recipe_id=row["recipe_id"],
title=row.get("title", ""),
saved_at=row["saved_at"],
notes=row.get("notes"),
rating=row.get("rating"),
style_tags=row.get("style_tags") or [],
collection_ids=collection_ids,
)
# ── save / unsave ─────────────────────────────────────────────────────────────
@router.post("", response_model=SavedRecipeSummary)
async def save_recipe(
req: SaveRecipeRequest,
session: CloudUser = Depends(get_session),
) -> SavedRecipeSummary:
def _run(store: Store) -> SavedRecipeSummary:
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
return _to_summary(row, store)
return await asyncio.to_thread(_in_thread, session.db, _run)
@router.delete("/{recipe_id}", status_code=204)
async def unsave_recipe(
recipe_id: int,
session: CloudUser = Depends(get_session),
) -> None:
await asyncio.to_thread(
_in_thread, session.db, lambda s: s.unsave_recipe(recipe_id)
)
@router.patch("/{recipe_id}", response_model=SavedRecipeSummary)
async def update_saved_recipe(
recipe_id: int,
req: UpdateSavedRecipeRequest,
session: CloudUser = Depends(get_session),
) -> SavedRecipeSummary:
def _run(store: Store) -> SavedRecipeSummary:
if not store.is_recipe_saved(recipe_id):
raise HTTPException(status_code=404, detail="Recipe not saved.")
row = store.update_saved_recipe(
recipe_id, req.notes, req.rating, req.style_tags
)
return _to_summary(row, store)
return await asyncio.to_thread(_in_thread, session.db, _run)
@router.get("", response_model=list[SavedRecipeSummary])
async def list_saved_recipes(
sort_by: str = "saved_at",
collection_id: int | None = None,
session: CloudUser = Depends(get_session),
) -> list[SavedRecipeSummary]:
def _run(store: Store) -> list[SavedRecipeSummary]:
rows = store.get_saved_recipes(sort_by=sort_by, collection_id=collection_id)
return [_to_summary(r, store) for r in rows]
return await asyncio.to_thread(_in_thread, session.db, _run)
# ── collections (Paid) ────────────────────────────────────────────────────────
@router.get("/collections", response_model=list[CollectionSummary])
async def list_collections(
session: CloudUser = Depends(get_session),
) -> list[CollectionSummary]:
rows = await asyncio.to_thread(
_in_thread, session.db, lambda s: s.get_collections()
)
return [CollectionSummary(**r) for r in rows]
@router.post("/collections", response_model=CollectionSummary)
async def create_collection(
req: CollectionRequest,
session: CloudUser = Depends(get_session),
) -> CollectionSummary:
if not can_use("recipe_collections", session.tier):
raise HTTPException(
status_code=403,
detail="Collections require Paid tier.",
)
row = await asyncio.to_thread(
_in_thread, session.db,
lambda s: s.create_collection(req.name, req.description),
)
return CollectionSummary(**row)
@router.delete("/collections/{collection_id}", status_code=204)
async def delete_collection(
collection_id: int,
session: CloudUser = Depends(get_session),
) -> None:
if not can_use("recipe_collections", session.tier):
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
await asyncio.to_thread(
_in_thread, session.db, lambda s: s.delete_collection(collection_id)
)
@router.patch("/collections/{collection_id}", response_model=CollectionSummary)
async def rename_collection(
collection_id: int,
req: CollectionRequest,
session: CloudUser = Depends(get_session),
) -> CollectionSummary:
if not can_use("recipe_collections", session.tier):
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
row = await asyncio.to_thread(
_in_thread, session.db,
lambda s: s.rename_collection(collection_id, req.name, req.description),
)
if not row:
raise HTTPException(status_code=404, detail="Collection not found.")
return CollectionSummary(**row)
@router.post("/collections/{collection_id}/members", status_code=204)
async def add_to_collection(
collection_id: int,
req: CollectionMemberRequest,
session: CloudUser = Depends(get_session),
) -> None:
if not can_use("recipe_collections", session.tier):
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
await asyncio.to_thread(
_in_thread, session.db,
lambda s: s.add_to_collection(collection_id, req.saved_recipe_id),
)
@router.delete(
"/collections/{collection_id}/members/{saved_recipe_id}", status_code=204
)
async def remove_from_collection(
collection_id: int,
saved_recipe_id: int,
session: CloudUser = Depends(get_session),
) -> None:
if not can_use("recipe_collections", session.tier):
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
await asyncio.to_thread(
_in_thread, session.db,
lambda s: s.remove_from_collection(collection_id, saved_recipe_id),
)

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes
api_router = APIRouter()
@ -11,4 +11,6 @@ api_router.include_router(inventory.router, prefix="/inventory", tags=["invento
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(household.router, prefix="/household", tags=["household"])
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])

View file

@ -76,7 +76,7 @@ def _is_bypass_ip(ip: str) -> bool:
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
_TIER_CACHE: dict[str, tuple[str, float]] = {}
_TIER_CACHE: dict[str, tuple[dict, float]] = {}
_TIER_CACHE_TTL = 300 # 5 minutes
TIERS = ["free", "paid", "premium", "ultra"]
@ -90,6 +90,8 @@ class CloudUser:
tier: str # free | paid | premium | ultra | local
db: Path # per-user SQLite DB path
has_byok: bool # True if a configured LLM backend is present in llm.yaml
household_id: str | None = None
is_household_owner: bool = False
# ── JWT validation ─────────────────────────────────────────────────────────────
@ -130,14 +132,16 @@ def _ensure_provisioned(user_id: str) -> None:
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
def _fetch_cloud_tier(user_id: str) -> str:
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]:
"""Returns (tier, household_id | None, is_household_owner)."""
now = time.monotonic()
cached = _TIER_CACHE.get(user_id)
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
return cached[0]
entry = cached[0]
return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False)
if not HEIMDALL_ADMIN_TOKEN:
return "free"
return "free", None, False
try:
resp = requests.post(
f"{HEIMDALL_URL}/admin/cloud/resolve",
@ -145,17 +149,23 @@ def _fetch_cloud_tier(user_id: str) -> str:
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
timeout=5,
)
tier = resp.json().get("tier", "free") if resp.ok else "free"
data = resp.json() if resp.ok else {}
tier = data.get("tier", "free")
household_id = data.get("household_id")
is_owner = data.get("is_household_owner", False)
except Exception as exc:
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
tier = "free"
tier, household_id, is_owner = "free", None, False
_TIER_CACHE[user_id] = (tier, now)
return tier
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner}, now)
return tier, household_id, is_owner
def _user_db_path(user_id: str) -> Path:
path = CLOUD_DATA_ROOT / user_id / "kiwi.db"
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
if household_id:
path = CLOUD_DATA_ROOT / f"household_{household_id}" / "kiwi.db"
else:
path = CLOUD_DATA_ROOT / user_id / "kiwi.db"
path.parent.mkdir(parents=True, exist_ok=True)
return path
@ -198,8 +208,6 @@ def get_session(request: Request) -> CloudUser:
if not CLOUD_MODE:
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
# Prefer X-Real-IP (set by nginx from the actual client address) over the
# TCP peer address (which is nginx's container IP when behind the proxy).
# Prefer X-Real-IP (set by nginx from the actual client address) over the
# TCP peer address (which is nginx's container IP when behind the proxy).
client_ip = (
@ -225,8 +233,15 @@ def get_session(request: Request) -> CloudUser:
user_id = validate_session_jwt(token)
_ensure_provisioned(user_id)
tier = _fetch_cloud_tier(user_id)
return CloudUser(user_id=user_id, tier=tier, db=_user_db_path(user_id), has_byok=has_byok)
tier, household_id, is_household_owner = _fetch_cloud_tier(user_id)
return CloudUser(
user_id=user_id,
tier=tier,
db=_user_db_path(user_id, household_id=household_id),
has_byok=has_byok,
household_id=household_id,
is_household_owner=is_household_owner,
)
def require_tier(min_tier: str):

View file

@ -0,0 +1,10 @@
-- 017_household_invites.sql
CREATE TABLE IF NOT EXISTS household_invites (
token TEXT PRIMARY KEY,
household_id TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
used_at TEXT,
used_by TEXT
);

View file

@ -0,0 +1,14 @@
-- Migration 018: saved recipes bookmarks.
CREATE TABLE saved_recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
saved_at TEXT NOT NULL DEFAULT (datetime('now')),
notes TEXT,
rating INTEGER CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
style_tags TEXT NOT NULL DEFAULT '[]',
UNIQUE (recipe_id)
);
CREATE INDEX idx_saved_recipes_saved_at ON saved_recipes (saved_at DESC);
CREATE INDEX idx_saved_recipes_rating ON saved_recipes (rating);

View file

@ -0,0 +1,16 @@
-- Migration 019: recipe collections (Paid tier organisation).
CREATE TABLE recipe_collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE recipe_collection_members (
collection_id INTEGER NOT NULL REFERENCES recipe_collections(id) ON DELETE CASCADE,
saved_recipe_id INTEGER NOT NULL REFERENCES saved_recipes(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (collection_id, saved_recipe_id)
);

View file

@ -0,0 +1,13 @@
-- Migration 020: recipe browser navigation telemetry.
-- Used to determine whether category nesting depth needs increasing.
-- Review: if any category has page > 5 and result_count > 100 consistently,
-- consider adding a third nesting level for that category.
CREATE TABLE browser_telemetry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
category TEXT NOT NULL,
page INTEGER NOT NULL,
result_count INTEGER NOT NULL,
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View file

@ -35,7 +35,9 @@ class Store:
"warnings",
# recipe columns
"ingredients", "ingredient_names", "directions",
"keywords", "element_coverage"):
"keywords", "element_coverage",
# saved recipe columns
"style_tags"):
if key in d and isinstance(d[key], str):
try:
d[key] = json.loads(d[key])
@ -735,3 +737,265 @@ class Store:
int(approved), int(opted_in),
))
self.conn.commit()
# ── saved recipes ─────────────────────────────────────────────────────
def save_recipe(
self,
recipe_id: int,
notes: str | None,
rating: int | None,
) -> dict:
return self._insert_returning(
"""
INSERT INTO saved_recipes (recipe_id, notes, rating)
VALUES (?, ?, ?)
ON CONFLICT(recipe_id) DO UPDATE SET
notes = excluded.notes,
rating = excluded.rating
RETURNING *
""",
(recipe_id, notes, rating),
)
def unsave_recipe(self, recipe_id: int) -> None:
self.conn.execute(
"DELETE FROM saved_recipes WHERE recipe_id = ?", (recipe_id,)
)
self.conn.commit()
def is_recipe_saved(self, recipe_id: int) -> bool:
row = self._fetch_one(
"SELECT id FROM saved_recipes WHERE recipe_id = ?", (recipe_id,)
)
return row is not None
def update_saved_recipe(
self,
recipe_id: int,
notes: str | None,
rating: int | None,
style_tags: list[str],
) -> dict:
self.conn.execute(
"""
UPDATE saved_recipes
SET notes = ?, rating = ?, style_tags = ?
WHERE recipe_id = ?
""",
(notes, rating, self._dump(style_tags), recipe_id),
)
self.conn.commit()
row = self._fetch_one(
"SELECT * FROM saved_recipes WHERE recipe_id = ?", (recipe_id,)
)
return row # type: ignore[return-value]
def get_saved_recipes(
self,
sort_by: str = "saved_at",
collection_id: int | None = None,
) -> list[dict]:
order = {
"saved_at": "sr.saved_at DESC",
"rating": "sr.rating DESC",
"title": "r.title ASC",
}.get(sort_by, "sr.saved_at DESC")
if collection_id is not None:
return self._fetch_all(
f"""
SELECT sr.*, r.title
FROM saved_recipes sr
JOIN recipes r ON r.id = sr.recipe_id
JOIN recipe_collection_members rcm ON rcm.saved_recipe_id = sr.id
WHERE rcm.collection_id = ?
ORDER BY {order}
""",
(collection_id,),
)
return self._fetch_all(
f"""
SELECT sr.*, r.title
FROM saved_recipes sr
JOIN recipes r ON r.id = sr.recipe_id
ORDER BY {order}
""",
)
def get_saved_recipe_collection_ids(self, saved_recipe_id: int) -> list[int]:
rows = self._fetch_all(
"SELECT collection_id FROM recipe_collection_members WHERE saved_recipe_id = ?",
(saved_recipe_id,),
)
return [r["collection_id"] for r in rows]
# ── recipe collections ────────────────────────────────────────────────
def create_collection(self, name: str, description: str | None) -> dict:
return self._insert_returning(
"INSERT INTO recipe_collections (name, description) VALUES (?, ?) RETURNING *",
(name, description),
)
def delete_collection(self, collection_id: int) -> None:
self.conn.execute(
"DELETE FROM recipe_collections WHERE id = ?", (collection_id,)
)
self.conn.commit()
def rename_collection(
self, collection_id: int, name: str, description: str | None
) -> dict:
self.conn.execute(
"""
UPDATE recipe_collections
SET name = ?, description = ?, updated_at = datetime('now')
WHERE id = ?
""",
(name, description, collection_id),
)
self.conn.commit()
row = self._fetch_one(
"SELECT * FROM recipe_collections WHERE id = ?", (collection_id,)
)
return row # type: ignore[return-value]
def get_collections(self) -> list[dict]:
return self._fetch_all(
"""
SELECT rc.*,
COUNT(rcm.saved_recipe_id) AS member_count
FROM recipe_collections rc
LEFT JOIN recipe_collection_members rcm ON rcm.collection_id = rc.id
GROUP BY rc.id
ORDER BY rc.created_at ASC
"""
)
def add_to_collection(self, collection_id: int, saved_recipe_id: int) -> None:
self.conn.execute(
"""
INSERT OR IGNORE INTO recipe_collection_members (collection_id, saved_recipe_id)
VALUES (?, ?)
""",
(collection_id, saved_recipe_id),
)
self.conn.commit()
def remove_from_collection(
self, collection_id: int, saved_recipe_id: int
) -> None:
self.conn.execute(
"""
DELETE FROM recipe_collection_members
WHERE collection_id = ? AND saved_recipe_id = ?
""",
(collection_id, saved_recipe_id),
)
self.conn.commit()
# ── recipe browser ────────────────────────────────────────────────────
def get_browser_categories(
self, domain: str, keywords_by_category: dict[str, list[str]]
) -> list[dict]:
"""Return [{category, recipe_count}] for each category in the domain.
keywords_by_category maps category name to the keyword list used to
match against recipes.category and recipes.keywords.
"""
results = []
for category, keywords in keywords_by_category.items():
count = self._count_recipes_for_keywords(keywords)
results.append({"category": category, "recipe_count": count})
return results
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
if not keywords:
return 0
conditions = " OR ".join(
["lower(category) LIKE ?"] * len(keywords)
+ ["lower(keywords) LIKE ?"] * len(keywords)
)
params = tuple(f"%{kw.lower()}%" for kw in keywords) * 2
row = self.conn.execute(
f"SELECT count(*) AS n FROM recipes WHERE {conditions}", params
).fetchone()
return row[0] if row else 0
def browse_recipes(
self,
keywords: list[str],
page: int,
page_size: int,
pantry_items: list[str] | None = None,
) -> dict:
"""Return a page of recipes matching the keyword set.
Each recipe row includes match_pct (float | None) when pantry_items
is provided. match_pct is the fraction of ingredient_names covered by
the pantry set computed deterministically, no LLM needed.
"""
if not keywords:
return {"recipes": [], "total": 0, "page": page}
conditions = " OR ".join(
["lower(category) LIKE ?"] * len(keywords)
+ ["lower(keywords) LIKE ?"] * len(keywords)
)
like_params = tuple(f"%{kw.lower()}%" for kw in keywords) * 2
offset = (page - 1) * page_size
total_row = self.conn.execute(
f"SELECT count(*) AS n FROM recipes WHERE {conditions}", like_params
).fetchone()
total = total_row[0] if total_row else 0
rows = self._fetch_all(
f"""
SELECT id, title, category, keywords, ingredient_names,
calories, fat_g, protein_g, sodium_mg, source_url
FROM recipes
WHERE {conditions}
ORDER BY title ASC
LIMIT ? OFFSET ?
""",
like_params + (page_size, offset),
)
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
recipes = []
for r in rows:
entry = {
"id": r["id"],
"title": r["title"],
"category": r["category"],
"match_pct": None,
}
if pantry_set:
names = r.get("ingredient_names") or []
if names:
matched = sum(
1 for n in names if n.lower() in pantry_set
)
entry["match_pct"] = round(matched / len(names), 3)
recipes.append(entry)
return {"recipes": recipes, "total": total, "page": page}
def log_browser_telemetry(
self,
domain: str,
category: str,
page: int,
result_count: int,
) -> None:
self.conn.execute(
"""
INSERT INTO browser_telemetry (domain, category, page, result_count)
VALUES (?, ?, ?, ?)
""",
(domain, category, page, result_count),
)
self.conn.commit()

View file

@ -0,0 +1,47 @@
"""Pydantic schemas for household management endpoints."""
from __future__ import annotations
from pydantic import BaseModel, Field
class HouseholdCreateResponse(BaseModel):
household_id: str
message: str
class HouseholdMember(BaseModel):
user_id: str
joined_at: str
is_owner: bool
class HouseholdStatusResponse(BaseModel):
in_household: bool
household_id: str | None = None
is_owner: bool = False
members: list[HouseholdMember] = Field(default_factory=list)
max_seats: int = 4
class HouseholdInviteResponse(BaseModel):
invite_url: str
token: str
expires_at: str
class HouseholdAcceptRequest(BaseModel):
household_id: str
token: str
class HouseholdAcceptResponse(BaseModel):
message: str
household_id: str
class HouseholdRemoveMemberRequest(BaseModel):
user_id: str
class MessageResponse(BaseModel):
message: str

View file

@ -133,6 +133,32 @@ class BarcodeScanResponse(BaseModel):
message: str
# ── Bulk add by name ─────────────────────────────────────────────────────────
class BulkAddItem(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
quantity: float = Field(default=1.0, gt=0)
unit: str = "count"
location: str = "pantry"
class BulkAddByNameRequest(BaseModel):
items: List[BulkAddItem] = Field(..., min_length=1)
class BulkAddItemResult(BaseModel):
name: str
ok: bool
item_id: Optional[int] = None
error: Optional[str] = None
class BulkAddByNameResponse(BaseModel):
added: int
failed: int
results: List[BulkAddItemResult]
# ── Stats ─────────────────────────────────────────────────────────────────────
class InventoryStats(BaseModel):

View file

@ -32,6 +32,7 @@ class RecipeSuggestion(BaseModel):
match_count: int
element_coverage: dict[str, float] = Field(default_factory=dict)
swap_candidates: list[SwapCandidate] = Field(default_factory=list)
matched_ingredients: list[str] = Field(default_factory=list)
missing_ingredients: list[str] = Field(default_factory=list)
directions: list[str] = Field(default_factory=list)
prep_notes: list[str] = Field(default_factory=list)
@ -39,6 +40,7 @@ class RecipeSuggestion(BaseModel):
level: int = 1
is_wildcard: bool = False
nutrition: NutritionPanel | None = None
source_url: str | None = None
class GroceryLink(BaseModel):
@ -79,3 +81,4 @@ class RecipeRequest(BaseModel):
allergies: list[str] = Field(default_factory=list)
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
excluded_ids: list[int] = Field(default_factory=list)
shopping_mode: bool = False

View file

@ -0,0 +1,44 @@
"""Pydantic schemas for saved recipes and collections."""
from __future__ import annotations
from pydantic import BaseModel, Field
class SaveRecipeRequest(BaseModel):
recipe_id: int
notes: str | None = None
rating: int | None = Field(None, ge=0, le=5)
class UpdateSavedRecipeRequest(BaseModel):
notes: str | None = None
rating: int | None = Field(None, ge=0, le=5)
style_tags: list[str] = Field(default_factory=list)
class SavedRecipeSummary(BaseModel):
id: int
recipe_id: int
title: str
saved_at: str
notes: str | None
rating: int | None
style_tags: list[str]
collection_ids: list[int] = Field(default_factory=list)
class CollectionSummary(BaseModel):
id: int
name: str
description: str | None
member_count: int
created_at: str
class CollectionRequest(BaseModel):
name: str
description: str | None = None
class CollectionMemberRequest(BaseModel):
saved_recipe_id: int

View file

@ -33,7 +33,7 @@ def _try_docuvision(image_path: str | Path) -> str | None:
if not cf_orch_url:
return None
try:
from circuitforge_core.resources import CFOrchClient
from circuitforge_orch.client import CFOrchClient
from app.services.ocr.docuvision_client import DocuvisionClient
client = CFOrchClient(cf_orch_url)

View file

@ -248,6 +248,8 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"zucchini", "mushroom", "corn", "onion", "bean sprout",
"cabbage", "spinach", "asparagus",
]),
# Starch base required — prevents this from firing on any pantry with vegetables
AssemblyRole("starch base", ["rice", "noodle", "pasta", "ramen", "cauliflower rice"]),
],
optional=[
AssemblyRole("protein", [
@ -257,7 +259,6 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"soy sauce", "teriyaki", "oyster sauce", "hoisin",
"stir fry sauce", "sesame",
]),
AssemblyRole("starch base", ["rice", "noodle", "pasta", "ramen"]),
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
AssemblyRole("oil", ["oil", "sesame"]),
],
@ -381,9 +382,10 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
id=-8,
title="Soup / Stew",
required=[
AssemblyRole("broth or liquid base", [
"broth", "stock", "bouillon",
"tomato sauce", "coconut milk", "cream of",
# Narrow to dedicated soup bases — tomato sauce and coconut milk are
# pantry staples used in too many non-soup dishes to serve as anchors.
AssemblyRole("broth or stock", [
"broth", "stock", "bouillon", "cream of",
]),
],
optional=[
@ -572,6 +574,12 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"egg", "cornstarch", "custard powder", "gelatin",
"agar", "tapioca", "arrowroot",
]),
# Require a clear dessert-intent signal — milk + eggs alone is too generic
# (also covers white sauce, quiche, etc.)
AssemblyRole("sweetener or flavouring", [
"sugar", "honey", "maple syrup", "condensed milk",
"vanilla", "chocolate", "cocoa", "caramel", "custard powder",
]),
],
optional=[
AssemblyRole("sweetener", ["sugar", "honey", "maple syrup", "condensed milk"]),
@ -605,14 +613,20 @@ def match_assembly_templates(
pantry_items: list[str],
pantry_set: set[str],
excluded_ids: list[int],
expiring_set: set[str] | None = None,
) -> list[RecipeSuggestion]:
"""Return assembly-dish suggestions whose required roles are all satisfied.
Titles are personalized with specific pantry items (deterministically chosen
from the pantry contents so the same pantry always produces the same title).
Skips templates whose id is in excluded_ids (dismiss/load-more support).
expiring_set: expanded pantry set of items close to expiry. Templates that
use an expiring item in a required role get +2 added to match_count so they
rank higher when the caller sorts the combined result list.
"""
excluded = set(excluded_ids)
expiring = expiring_set or set()
seed = _pantry_hash(pantry_set)
results: list[RecipeSuggestion] = []
@ -620,20 +634,40 @@ def match_assembly_templates(
if tmpl.id in excluded:
continue
# All required roles must be satisfied
if any(not _matches_role(role, pantry_set) for role in tmpl.required):
# All required roles must be satisfied; collect matched items for required roles
required_matches: list[str] = []
skip = False
for role in tmpl.required:
hits = _matches_role(role, pantry_set)
if not hits:
skip = True
break
required_matches.append(_pick_one(hits, seed + tmpl.id))
if skip:
continue
optional_hit_count = sum(
1 for role in tmpl.optional if _matches_role(role, pantry_set)
)
# Collect matched items for optional roles (one representative per matched role)
optional_matches: list[str] = []
for role in tmpl.optional:
hits = _matches_role(role, pantry_set)
if hits:
optional_matches.append(_pick_one(hits, seed + tmpl.id))
matched = required_matches + optional_matches
# Expiry boost: +2 if any required ingredient is in the expiring set,
# so time-sensitive templates surface first in the merged ranking.
expiry_bonus = 2 if expiring and any(
item.lower() in expiring for item in required_matches
) else 0
results.append(RecipeSuggestion(
id=tmpl.id,
title=_personalized_title(tmpl, pantry_set, seed + tmpl.id),
match_count=len(tmpl.required) + optional_hit_count,
match_count=len(matched) + expiry_bonus,
element_coverage={},
swap_candidates=[],
matched_ingredients=matched,
missing_ingredients=[],
directions=tmpl.directions,
notes=tmpl.notes,

View file

@ -0,0 +1,89 @@
"""
Recipe browser domain schemas.
Each domain provides a two-level category hierarchy for browsing the recipe corpus.
Keyword matching is case-insensitive against the recipes.category column and the
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
These are starter mappings based on the food.com dataset structure. Run:
SELECT category, count(*) FROM recipes
GROUP BY category ORDER BY count(*) DESC LIMIT 50;
against the corpus to verify coverage and refine keyword lists before the first
production deploy.
"""
from __future__ import annotations
DOMAINS: dict[str, dict] = {
"cuisine": {
"label": "Cuisine",
"categories": {
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
},
},
"meal_type": {
"label": "Meal Type",
"categories": {
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
},
},
"dietary": {
"label": "Dietary",
"categories": {
"Vegetarian": ["vegetarian"],
"Vegan": ["vegan", "plant-based", "plant based"],
"Gluten-Free": ["gluten-free", "gluten free", "celiac"],
"Low-Carb": ["low-carb", "low carb", "keto", "ketogenic"],
"High-Protein": ["high protein", "high-protein"],
"Low-Fat": ["low-fat", "low fat", "light"],
"Dairy-Free": ["dairy-free", "dairy free", "lactose"],
},
},
"main_ingredient": {
"label": "Main Ingredient",
"categories": {
"Chicken": ["chicken", "poultry", "turkey"],
"Beef": ["beef", "ground beef", "steak", "brisket", "pot roast"],
"Pork": ["pork", "bacon", "ham", "sausage", "prosciutto"],
"Fish": ["fish", "salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood"],
"Pasta": ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"],
"Vegetables": ["vegetable", "veggie", "cauliflower", "broccoli", "zucchini", "eggplant"],
"Eggs": ["egg", "frittata", "omelette", "omelet", "quiche"],
"Legumes": ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"],
"Grains": ["rice", "quinoa", "barley", "farro", "oat", "grain"],
"Cheese": ["cheese", "ricotta", "mozzarella", "parmesan", "cheddar"],
},
},
}
def get_domain_labels() -> list[dict]:
"""Return [{id, label}] for all available domains."""
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
def get_keywords_for_category(domain: str, category: str) -> list[str]:
"""Return the keyword list for a domain/category pair, or [] if not found."""
domain_data = DOMAINS.get(domain, {})
categories = domain_data.get("categories", {})
return categories.get(category, [])
def get_category_names(domain: str) -> list[str]:
"""Return category names for a domain, or [] if domain unknown."""
domain_data = DOMAINS.get(domain, {})
return list(domain_data.get("categories", {}).keys())

View file

@ -1,69 +1,76 @@
"""
GroceryLinkBuilder affiliate deeplinks for missing ingredient grocery lists.
Free tier: URL construction only (Amazon Fresh, Walmart, Instacart).
Paid+: live product search API (stubbed future task).
Delegates URL wrapping to circuitforge_core.affiliates.wrap_url, which handles
the full resolution chain: opt-out BYOK id CF env var plain URL.
Config (env vars, all optional missing = retailer disabled):
AMAZON_AFFILIATE_TAG e.g. "circuitforge-20"
INSTACART_AFFILIATE_ID e.g. "circuitforge"
WALMART_AFFILIATE_ID e.g. "circuitforge" (Impact affiliate network)
Registered programs (via cf-core):
amazon Amazon Associates (env: AMAZON_ASSOCIATES_TAG)
instacart Instacart (env: INSTACART_AFFILIATE_ID)
Walmart is kept inline until cf-core adds Impact network support:
env: WALMART_AFFILIATE_ID
Links are always generated (plain URLs are useful even without affiliate IDs).
Walmart links only appear when WALMART_AFFILIATE_ID is set.
"""
from __future__ import annotations
import logging
import os
from urllib.parse import quote_plus
from circuitforge_core.affiliates import wrap_url
from app.models.schemas.recipe import GroceryLink
logger = logging.getLogger(__name__)
def _amazon_link(ingredient: str, tag: str) -> GroceryLink:
def _amazon_fresh_link(ingredient: str) -> GroceryLink:
q = quote_plus(ingredient)
url = f"https://www.amazon.com/s?k={q}&i=amazonfresh&tag={tag}"
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=url)
base = f"https://www.amazon.com/s?k={q}&i=amazonfresh"
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=wrap_url(base, "amazon"))
def _instacart_link(ingredient: str) -> GroceryLink:
q = quote_plus(ingredient)
base = f"https://www.instacart.com/store/s?k={q}"
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart"))
def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
q = quote_plus(ingredient)
# Walmart Impact affiliate deeplink pattern
url = f"https://goto.walmart.com/c/{affiliate_id}/walmart?u=https://www.walmart.com/search?q={q}"
# Walmart uses Impact network — affiliate ID is in the redirect path, not a param
url = (
f"https://goto.walmart.com/c/{affiliate_id}/walmart"
f"?u=https://www.walmart.com/search?q={q}"
)
return GroceryLink(ingredient=ingredient, retailer="Walmart Grocery", url=url)
def _instacart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
q = quote_plus(ingredient)
url = f"https://www.instacart.com/store/s?k={q}&aff={affiliate_id}"
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=url)
class GroceryLinkBuilder:
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
self._tier = tier
self._has_byok = has_byok
self._amazon_tag = os.environ.get("AMAZON_AFFILIATE_TAG", "")
self._instacart_id = os.environ.get("INSTACART_AFFILIATE_ID", "")
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "")
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
def build_links(self, ingredient: str) -> list[GroceryLink]:
"""Build affiliate deeplinks for a single ingredient.
"""Build grocery deeplinks for a single ingredient.
Free tier: URL construction only.
Paid+: would call live product search APIs (stubbed).
Amazon Fresh and Instacart links are always included; wrap_url handles
affiliate ID injection (or returns a plain URL if none is configured).
Walmart requires WALMART_AFFILIATE_ID to be set (Impact network uses a
path-based redirect that doesn't degrade cleanly to a plain URL).
"""
if not ingredient.strip():
return []
links: list[GroceryLink] = []
if self._amazon_tag:
links.append(_amazon_link(ingredient, self._amazon_tag))
links: list[GroceryLink] = [
_amazon_fresh_link(ingredient),
_instacart_link(ingredient),
]
if self._walmart_id:
links.append(_walmart_link(ingredient, self._walmart_id))
if self._instacart_id:
links.append(_instacart_link(ingredient, self._instacart_id))
# Paid+: live API stub (future task)
# if self._tier in ("paid", "premium") and not self._has_byok:
# links.extend(self._search_kroger_api(ingredient))
return links

View file

@ -143,7 +143,7 @@ class LLMRecipeGenerator:
cf_orch_url = os.environ.get("CF_ORCH_URL")
if cf_orch_url:
try:
from circuitforge_core.resources import CFOrchClient
from circuitforge_orch.client import CFOrchClient
client = CFOrchClient(cf_orch_url)
return client.allocate(
service="vllm",
@ -160,28 +160,41 @@ class LLMRecipeGenerator:
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
calls the OpenAI-compatible API directly against the allocated service URL.
Without CF_ORCH_URL: falls back to LLMRouter using its configured backends.
Allocation failure falls through to LLMRouter rather than silently returning "".
Without CF_ORCH_URL: uses LLMRouter directly.
"""
ctx = self._get_llm_context()
alloc = None
try:
with self._get_llm_context() as alloc:
if alloc is not None:
base_url = alloc.url.rstrip("/") + "/v1"
client = OpenAI(base_url=base_url, api_key="any")
model = alloc.model or "__auto__"
if model == "__auto__":
model = client.models.list().data[0].id
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content or ""
else:
from circuitforge_core.llm.router import LLMRouter
router = LLMRouter()
return router.complete(prompt)
alloc = ctx.__enter__()
except Exception as exc:
logger.debug("cf-orch allocation failed, falling back to LLMRouter: %s", exc)
ctx = None # __enter__ raised — do not call __exit__
try:
if alloc is not None:
base_url = alloc.url.rstrip("/") + "/v1"
client = OpenAI(base_url=base_url, api_key="any")
model = alloc.model or "__auto__"
if model == "__auto__":
model = client.models.list().data[0].id
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content or ""
else:
from circuitforge_core.llm.router import LLMRouter
return LLMRouter().complete(prompt)
except Exception as exc:
logger.error("LLM call failed: %s", exc)
return ""
finally:
if ctx is not None:
try:
ctx.__exit__(None, None, None)
except Exception:
pass
# Strips markdown bold/italic markers so "**Directions:**" parses like "Directions:"
_MD_BOLD = re.compile(r"\*{1,2}([^*]+)\*{1,2}")

View file

@ -367,6 +367,163 @@ def _pantry_creative_swap(required: str, pantry_items: set[str]) -> str | None:
return best
# ---------------------------------------------------------------------------
# Functional-category swap table (Level 2 only)
# ---------------------------------------------------------------------------
# Maps cleaned ingredient names → functional category label. Used as a
# fallback when _pantry_creative_swap returns None (which always happens for
# single-token ingredients, because that function requires ≥2 shared tokens).
# A pantry item that belongs to the same category is offered as a substitute.
_FUNCTIONAL_SWAP_CATEGORIES: dict[str, str] = {
# Solid fats
"butter": "solid_fat",
"margarine": "solid_fat",
"shortening": "solid_fat",
"lard": "solid_fat",
"ghee": "solid_fat",
# Liquid/neutral cooking oils
"oil": "liquid_fat",
"vegetable oil": "liquid_fat",
"olive oil": "liquid_fat",
"canola oil": "liquid_fat",
"sunflower oil": "liquid_fat",
"avocado oil": "liquid_fat",
# Sweeteners
"sugar": "sweetener",
"brown sugar": "sweetener",
"honey": "sweetener",
"maple syrup": "sweetener",
"agave": "sweetener",
"molasses": "sweetener",
"stevia": "sweetener",
"powdered sugar": "sweetener",
# All-purpose flours and baking bases
"flour": "flour",
"all-purpose flour": "flour",
"whole wheat flour": "flour",
"bread flour": "flour",
"self-rising flour": "flour",
"cake flour": "flour",
# Dairy and non-dairy milk
"milk": "dairy_milk",
"whole milk": "dairy_milk",
"skim milk": "dairy_milk",
"2% milk": "dairy_milk",
"oat milk": "dairy_milk",
"almond milk": "dairy_milk",
"soy milk": "dairy_milk",
"rice milk": "dairy_milk",
# Heavy/whipping creams
"cream": "heavy_cream",
"heavy cream": "heavy_cream",
"whipping cream": "heavy_cream",
"double cream": "heavy_cream",
"coconut cream": "heavy_cream",
# Cultured dairy (acid + thick)
"sour cream": "cultured_dairy",
"greek yogurt": "cultured_dairy",
"yogurt": "cultured_dairy",
"buttermilk": "cultured_dairy",
# Starch thickeners
"cornstarch": "thickener",
"arrowroot": "thickener",
"tapioca starch": "thickener",
"potato starch": "thickener",
"rice flour": "thickener",
# Egg binders
"egg": "egg_binder",
"eggs": "egg_binder",
# Acids
"vinegar": "acid",
"apple cider vinegar": "acid",
"white vinegar": "acid",
"red wine vinegar": "acid",
"lemon juice": "acid",
"lime juice": "acid",
# Stocks and broths
"broth": "stock",
"stock": "stock",
"chicken broth": "stock",
"beef broth": "stock",
"vegetable broth": "stock",
"chicken stock": "stock",
"beef stock": "stock",
"bouillon": "stock",
# Hard cheeses (grating / melting interchangeable)
"parmesan": "hard_cheese",
"romano": "hard_cheese",
"pecorino": "hard_cheese",
"asiago": "hard_cheese",
# Melting cheeses
"cheddar": "melting_cheese",
"mozzarella": "melting_cheese",
"swiss": "melting_cheese",
"gouda": "melting_cheese",
"monterey jack": "melting_cheese",
"colby": "melting_cheese",
"provolone": "melting_cheese",
# Canned tomato products
"tomato sauce": "canned_tomato",
"tomato paste": "canned_tomato",
"crushed tomatoes": "canned_tomato",
"diced tomatoes": "canned_tomato",
"marinara": "canned_tomato",
}
def _category_swap(ingredient: str, pantry_items: set[str]) -> str | None:
"""Level-2 fallback: find a same-category pantry substitute for a single-token ingredient.
_pantry_creative_swap requires 2 shared content tokens, so it always returns
None for single-word ingredients like 'butter' or 'flour'. This function looks
up the ingredient's functional category and returns any pantry item in that
same category, enabling swaps like butter ghee, milk oat milk.
"""
clean = _strip_quantity(ingredient).lower()
category = _FUNCTIONAL_SWAP_CATEGORIES.get(clean)
if not category:
return None
for item in pantry_items:
if item.lower() == clean:
continue
item_lower = item.lower()
# Direct match: pantry item name is a known member of the same category
if _FUNCTIONAL_SWAP_CATEGORIES.get(item_lower) == category:
return item
# Substring match: handles "organic oat milk" containing "oat milk"
for known_ing, cat in _FUNCTIONAL_SWAP_CATEGORIES.items():
if cat == category and known_ing in item_lower and item_lower != clean:
return item
return None
# Assembly template caps by tier — prevents flooding results with templates
# when a well-stocked pantry satisfies every required role.
_SOURCE_URL_BUILDERS: dict[str, str] = {
"foodcom": "https://www.food.com/recipe/{id}",
}
def _build_source_url(row: dict) -> str | None:
"""Construct a canonical source URL from DB row fields, or None for generated recipes."""
source = row.get("source") or ""
external_id = row.get("external_id")
template = _SOURCE_URL_BUILDERS.get(source)
if not template or not external_id:
return None
try:
return template.format(id=int(float(external_id)))
except (ValueError, TypeError):
return None
_ASSEMBLY_TIER_LIMITS: dict[str, int] = {
"free": 2,
"paid": 4,
"premium": 6,
}
# Method complexity classification patterns
_EASY_METHODS = re.compile(
r"\b(microwave|mix|stir|blend|toast|assemble|heat)\b", re.IGNORECASE
@ -468,15 +625,21 @@ class RecipeEngine:
# When covered, collect any prep-state annotations (e.g. "melted butter"
# → note "Melt the butter before starting.") to surface separately.
swap_candidates: list[SwapCandidate] = []
matched: list[str] = []
missing: list[str] = []
prep_note_set: set[str] = set()
for n in ingredient_names:
if _ingredient_in_pantry(n, pantry_set):
matched.append(_strip_quantity(n))
note = _prep_note_for(n)
if note:
prep_note_set.add(note)
continue
swap_item = _pantry_creative_swap(n, pantry_set)
# L2: also try functional-category swap for single-token ingredients
# that _pantry_creative_swap can't match (requires ≥2 shared tokens).
if swap_item is None and req.level == 2:
swap_item = _category_swap(n, pantry_set)
if swap_item:
swap_candidates.append(SwapCandidate(
original_name=n,
@ -488,8 +651,8 @@ class RecipeEngine:
else:
missing.append(n)
# Filter by max_missing (pantry swaps don't count as missing)
if req.max_missing is not None and len(missing) > req.max_missing:
# Filter by max_missing — skipped in shopping mode (user is willing to buy)
if not req.shopping_mode and req.max_missing is not None and len(missing) > req.max_missing:
continue
# Filter by hard_day_mode
@ -547,20 +710,38 @@ class RecipeEngine:
match_count=int(row.get("match_count") or 0),
element_coverage=coverage_raw,
swap_candidates=swap_candidates,
matched_ingredients=matched,
missing_ingredients=missing,
prep_notes=sorted(prep_note_set),
level=req.level,
nutrition=nutrition if has_nutrition else None,
source_url=_build_source_url(row),
))
# Prepend assembly-dish templates (burrito, stir fry, omelette, etc.)
# These fire regardless of corpus coverage — any pantry can make a burrito.
# Assembly-dish templates (burrito, fried rice, pasta, etc.)
# Expiry boost: when expiry_first, the pantry_items list is already sorted
# by expiry urgency — treat the first slice as the "expiring" set so templates
# that use those items bubble up in the merged ranking.
expiring_set: set[str] = set()
if req.expiry_first:
expiring_set = _expand_pantry_set(req.pantry_items[:10])
assembly = match_assembly_templates(
pantry_items=req.pantry_items,
pantry_set=pantry_set,
excluded_ids=req.excluded_ids or [],
expiring_set=expiring_set,
)
suggestions = assembly + suggestions
# Cap by tier — lifted in shopping mode since missing-ingredient templates
# are desirable there (each fires an affiliate link opportunity).
if not req.shopping_mode:
assembly_limit = _ASSEMBLY_TIER_LIMITS.get(req.tier, 3)
assembly = assembly[:assembly_limit]
# Interleave: sort templates and corpus recipes together by match_count so
# assembly dishes earn their position rather than always winning position 0-N.
suggestions = sorted(assembly + suggestions, key=lambda s: s.match_count, reverse=True)
# Build grocery list — deduplicated union of all missing ingredients
seen: set[str] = set()

View file

@ -15,6 +15,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"recipe_suggestions",
"expiry_llm_matching",
"receipt_ocr",
"style_classifier",
})
# Feature → minimum tier required
@ -35,6 +36,8 @@ KIWI_FEATURES: dict[str, str] = {
"meal_planning": "paid",
"dietary_profiles": "paid",
"style_picker": "paid",
"recipe_collections": "paid",
"style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
# Premium tier
"multi_household": "premium",

View file

@ -13,10 +13,15 @@ services:
environment:
CLOUD_MODE: "true"
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
KIWI_BASE_URL: https://menagerie.circuitforge.tech/kiwi
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
# Production deployments must NOT set this. Leave blank or omit entirely.
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
# cf-orch: route LLM calls through the coordinator for managed GPU inference
CF_ORCH_URL: http://host.docker.internal:7700
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
# LLM config — shared with other CF products; read-only in container

View file

@ -2,6 +2,12 @@
# Not used in cloud or demo stacks (those use compose.cloud.yml / compose.demo.yml directly).
services:
api:
volumes:
# Symlink /data/kiwi.db → /Library/Assets/kiwi/kiwi.db; mount the NAS path so
# Docker can follow the symlink inside the container.
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
# cf-orch agent sidecar: registers kiwi as a GPU node with the coordinator.
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
# agent makes kiwi's VRAM usage visible on the orchestrator dashboard.

View file

@ -16,6 +16,18 @@
</div>
<nav class="sidebar-nav">
<button :class="['sidebar-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<!-- Ramen bowl: chopsticks, rim, body, wavy noodles -->
<line x1="9" y1="2" x2="11" y2="9"/>
<line x1="15" y1="2" x2="13" y2="9"/>
<path d="M4 9 Q4 6 12 6 Q20 6 20 9"/>
<path d="M4 9 L4.5 17 Q4.5 21 12 21 Q19.5 21 19.5 17 L20 9"/>
<path d="M7 14 Q9 12 11 14 Q13 16 15 14 Q17 12 19 14"/>
</svg>
<span class="sidebar-label">Recipes</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="4" rx="1"/>
@ -34,14 +46,6 @@
<span class="sidebar-label">Receipts</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2C9 2 7 5 7 8c0 2.5 1 4.5 3 5.5V20h4v-6.5c2-1 3-3 3-5.5 0-3-2-6-5-6z"/>
<line x1="9" y1="12" x2="15" y2="12"/>
</svg>
<span class="sidebar-label">Recipes</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
@ -81,6 +85,16 @@
<!-- Mobile bottom nav only -->
<nav class="bottom-nav" role="navigation" aria-label="Main navigation">
<button :class="['nav-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')" aria-label="Recipes">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="9" y1="2" x2="11" y2="9"/>
<line x1="15" y1="2" x2="13" y2="9"/>
<path d="M4 9 Q4 6 12 6 Q20 6 20 9"/>
<path d="M4 9 L4.5 17 Q4.5 21 12 21 Q19.5 21 19.5 17 L20 9"/>
<path d="M7 14 Q9 12 11 14 Q13 16 15 14 Q17 12 19 14"/>
</svg>
<span class="nav-label">Recipes</span>
</button>
<button :class="['nav-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')" aria-label="Pantry">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="4" rx="1"/>
@ -97,13 +111,6 @@
</svg>
<span class="nav-label">Receipts</span>
</button>
<button :class="['nav-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')" aria-label="Recipes">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2C9 2 7 5 7 8c0 2.5 1 4.5 3 5.5V20h4v-6.5c2-1 3-3 3-5.5 0-3-2-6-5-6z"/>
<line x1="9" y1="12" x2="15" y2="12"/>
</svg>
<span class="nav-label">Recipes</span>
</button>
<button :class="['nav-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')" aria-label="Settings">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
@ -151,7 +158,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import InventoryList from './components/InventoryList.vue'
import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue'
@ -159,6 +166,7 @@ import SettingsView from './components/SettingsView.vue'
import FeedbackButton from './components/FeedbackButton.vue'
import { useInventoryStore } from './stores/inventory'
import { useEasterEggs } from './composables/useEasterEggs'
import { householdAPI } from './services/api'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
@ -188,6 +196,31 @@ async function switchTab(tab: Tab) {
await inventoryStore.fetchItems()
}
}
onMounted(async () => {
// Handle household invite links: /#/join?household_id=xxx&token=yyy
const hash = window.location.hash
if (hash.includes('/join')) {
const params = new URLSearchParams(hash.split('?')[1] ?? '')
const householdId = params.get('household_id')
const token = params.get('token')
if (householdId && token) {
try {
const result = await householdAPI.accept(householdId, token)
alert(result.message)
// Clear the invite params from URL and reload
window.location.hash = '/'
window.location.reload()
} catch (err: unknown) {
const msg = (err instanceof Object && 'response' in err)
? ((err as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? 'Could not join household.')
: 'Could not join household.'
alert(`Failed to join: ${msg}`)
window.location.hash = '/'
}
}
}
})
</script>
<style>
@ -224,6 +257,9 @@ body {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
.sidebar { display: none; }

View file

@ -105,9 +105,9 @@
<div class="form-group scan-qty-group">
<label class="form-label">Qty</label>
<div class="quantity-control">
<button class="btn-qty" @click="scannerQuantity = Math.max(0.1, scannerQuantity - 1)" type="button"></button>
<input v-model.number="scannerQuantity" type="number" min="0.1" step="0.1" class="qty-input" />
<button class="btn-qty" @click="scannerQuantity += 1" type="button">+</button>
<button class="btn-qty" @click="scannerQuantity = Math.max(0.1, scannerQuantity - 1)" type="button" aria-label="Decrease quantity"></button>
<input v-model.number="scannerQuantity" type="number" min="0.1" step="0.1" class="qty-input" aria-label="Quantity" />
<button class="btn-qty" @click="scannerQuantity += 1" type="button" aria-label="Increase quantity">+</button>
</div>
</div>
</div>
@ -160,9 +160,9 @@
<div class="form-group scan-qty-group">
<label class="form-label">Qty</label>
<div class="quantity-control">
<button class="btn-qty" @click="barcodeQuantity = Math.max(0.1, barcodeQuantity - 1)" type="button"></button>
<input v-model.number="barcodeQuantity" type="number" min="0.1" step="0.1" class="qty-input" />
<button class="btn-qty" @click="barcodeQuantity += 1" type="button">+</button>
<button class="btn-qty" @click="barcodeQuantity = Math.max(0.1, barcodeQuantity - 1)" type="button" aria-label="Decrease quantity"></button>
<input v-model.number="barcodeQuantity" type="number" min="0.1" step="0.1" class="qty-input" aria-label="Quantity" />
<button class="btn-qty" @click="barcodeQuantity += 1" type="button" aria-label="Increase quantity">+</button>
</div>
</div>
</div>
@ -209,9 +209,9 @@
<div class="form-group scan-qty-group">
<label class="form-label">Qty</label>
<div class="quantity-control">
<button class="btn-qty" @click="manualForm.quantity = Math.max(0.1, manualForm.quantity - 1)" type="button"></button>
<input v-model.number="manualForm.quantity" type="number" min="0.1" step="0.1" required class="qty-input" />
<button class="btn-qty" @click="manualForm.quantity += 1" type="button">+</button>
<button class="btn-qty" @click="manualForm.quantity = Math.max(0.1, manualForm.quantity - 1)" type="button" aria-label="Decrease quantity"></button>
<input v-model.number="manualForm.quantity" type="number" min="0.1" step="0.1" required class="qty-input" aria-label="Quantity" />
<button class="btn-qty" @click="manualForm.quantity += 1" type="button" aria-label="Increase quantity">+</button>
</div>
</div>
</div>
@ -765,7 +765,8 @@ function getItemClass(item: InventoryItem): string {
flex-direction: column;
gap: var(--spacing-md);
padding: var(--spacing-xs) 0 0;
overflow-x: hidden; /* prevent item rows from expanding page width on mobile */
overflow-x: hidden;
width: 100%; /* Firefox: explicit width stops flex column from auto-sizing to content */
}
/* ============================================
@ -989,6 +990,9 @@ function getItemClass(item: InventoryItem): string {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
.inventory-header {
@ -1016,6 +1020,8 @@ function getItemClass(item: InventoryItem): string {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
width: 100%;
max-width: 100%;
}
.inv-row {
@ -1024,6 +1030,8 @@ function getItemClass(item: InventoryItem): string {
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border-left: 3px solid var(--color-border);
max-width: 100%;
box-sizing: border-box;
background: var(--color-bg-card);
transition: background 0.15s ease;
min-height: 52px;

View file

@ -0,0 +1,310 @@
<template>
<div class="browser-panel">
<!-- Domain picker -->
<div class="domain-picker flex flex-wrap gap-sm mb-md">
<button
v-for="domain in domains"
:key="domain.id"
:class="['btn', activeDomain === domain.id ? 'btn-primary' : 'btn-secondary']"
@click="selectDomain(domain.id)"
>
{{ domain.label }}
</button>
</div>
<div v-if="loadingDomains" class="text-secondary text-sm">Loading</div>
<div v-else-if="activeDomain" class="browser-body">
<!-- Category list -->
<div class="category-list mb-md flex flex-wrap gap-xs">
<button
v-for="cat in categories"
:key="cat.category"
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === cat.category }]"
@click="selectCategory(cat.category)"
>
{{ cat.category }}
<span class="cat-count">{{ cat.recipe_count }}</span>
</button>
</div>
<!-- Recipe grid -->
<template v-if="activeCategory">
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes</div>
<template v-else>
<div class="results-header flex-between mb-sm">
<span class="text-sm text-secondary">
{{ total }} recipes
<span v-if="pantryCount > 0"> pantry match shown</span>
</span>
<div class="pagination flex gap-xs">
<button
class="btn btn-secondary btn-xs"
:disabled="page <= 1"
@click="changePage(page - 1)"
> Prev</button>
<span class="text-sm text-secondary page-indicator">{{ page }} / {{ totalPages }}</span>
<button
class="btn btn-secondary btn-xs"
:disabled="page >= totalPages"
@click="changePage(page + 1)"
>Next </button>
</div>
</div>
<div v-if="recipes.length === 0" class="text-secondary text-sm">No recipes found in this category.</div>
<div class="recipe-grid">
<div
v-for="recipe in recipes"
:key="recipe.id"
class="card-sm recipe-row flex-between gap-sm"
>
<button
class="recipe-title-btn text-left"
@click="$emit('open-recipe', recipe.id)"
>
{{ recipe.title }}
</button>
<div class="recipe-row-actions flex gap-xs flex-shrink-0">
<!-- Pantry match badge -->
<span
v-if="recipe.match_pct !== null"
class="match-badge status-badge"
:class="matchBadgeClass(recipe.match_pct)"
>
{{ Math.round(recipe.match_pct * 100) }}%
</span>
<!-- Save toggle -->
<button
class="btn btn-secondary btn-xs"
:class="{ 'btn-saved': savedStore.isSaved(recipe.id) }"
@click="toggleSave(recipe)"
:aria-label="savedStore.isSaved(recipe.id) ? 'Edit saved recipe: ' + recipe.title : 'Save recipe: ' + recipe.title"
>
{{ savedStore.isSaved(recipe.id) ? '★' : '☆' }}
</button>
</div>
</div>
</div>
</template>
</template>
<div v-else class="text-secondary text-sm">Select a category above to browse recipes.</div>
</div>
<div v-else-if="!loadingDomains" class="text-secondary text-sm">Select a domain to start browsing.</div>
<!-- Save modal -->
<SaveRecipeModal
v-if="savingRecipe"
:recipe-id="savingRecipe.id"
:recipe-title="savingRecipe.title"
@close="savingRecipe = null"
@saved="savingRecipe = null"
@unsave="savingRecipe && doUnsave(savingRecipe.id)"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserRecipe } from '../services/api'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import { useInventoryStore } from '../stores/inventory'
import SaveRecipeModal from './SaveRecipeModal.vue'
defineEmits<{
(e: 'open-recipe', recipeId: number): void
}>()
const savedStore = useSavedRecipesStore()
const inventoryStore = useInventoryStore()
const domains = ref<BrowserDomain[]>([])
const activeDomain = ref<string | null>(null)
const categories = ref<BrowserCategory[]>([])
const activeCategory = ref<string | null>(null)
const recipes = ref<BrowserRecipe[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const loadingDomains = ref(false)
const loadingRecipes = ref(false)
const savingRecipe = ref<BrowserRecipe | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const pantryItems = computed(() =>
inventoryStore.items
.filter((i) => i.status === 'available' && i.product_name)
.map((i) => i.product_name as string)
)
const pantryCount = computed(() => pantryItems.value.length)
function matchBadgeClass(pct: number): string {
if (pct >= 0.8) return 'status-success'
if (pct >= 0.5) return 'status-warning'
return 'status-secondary'
}
onMounted(async () => {
loadingDomains.value = true
try {
domains.value = await browserAPI.listDomains()
if (domains.value.length > 0) selectDomain(domains.value[0]!.id)
} finally {
loadingDomains.value = false
}
// Ensure pantry is loaded for match badges
if (inventoryStore.items.length === 0) inventoryStore.fetchItems()
if (!savedStore.savedIds.size) savedStore.load()
})
async function selectDomain(domainId: string) {
activeDomain.value = domainId
activeCategory.value = null
recipes.value = []
total.value = 0
page.value = 1
categories.value = await browserAPI.listCategories(domainId)
}
async function selectCategory(category: string) {
activeCategory.value = category
page.value = 1
await loadRecipes()
}
async function changePage(newPage: number) {
page.value = newPage
await loadRecipes()
}
async function loadRecipes() {
if (!activeDomain.value || !activeCategory.value) return
loadingRecipes.value = true
try {
const result = await browserAPI.browse(
activeDomain.value,
activeCategory.value,
{
page: page.value,
page_size: pageSize,
pantry_items: pantryItems.value.length > 0
? pantryItems.value.join(',')
: undefined,
}
)
recipes.value = result.recipes
total.value = result.total
} finally {
loadingRecipes.value = false
}
}
function toggleSave(recipe: BrowserRecipe) {
if (savedStore.isSaved(recipe.id)) {
savingRecipe.value = recipe // open edit modal
} else {
savingRecipe.value = recipe // open save modal
}
}
async function doUnsave(recipeId: number) {
savingRecipe.value = null
await savedStore.unsave(recipeId)
}
</script>
<style scoped>
.browser-panel {
padding: var(--spacing-sm) 0;
}
.cat-btn {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-sm);
}
.cat-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.cat-count {
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
padding: 0 5px;
font-size: var(--font-size-xs, 0.72rem);
color: var(--color-text-secondary);
margin-left: var(--spacing-xs);
}
.cat-btn.active .cat-count {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.recipe-grid {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.recipe-row {
align-items: center;
}
.recipe-title-btn {
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-primary);
padding: 0;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipe-title-btn:hover {
text-decoration: underline;
}
.match-badge {
font-size: var(--font-size-xs, 0.72rem);
white-space: nowrap;
}
.status-secondary {
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-saved {
color: var(--color-warning);
border-color: var(--color-warning);
}
.btn-xs {
padding: 2px var(--spacing-xs);
font-size: var(--font-size-xs, 0.75rem);
}
.page-indicator {
align-self: center;
}
.flex-shrink-0 {
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,802 @@
<template>
<Teleport to="body">
<!-- Backdrop click outside to close -->
<div class="detail-overlay" @click.self="$emit('close')">
<div ref="dialogRef" class="detail-panel" role="dialog" aria-modal="true" :aria-label="recipe.title" tabindex="-1">
<!-- Sticky header -->
<div class="detail-header">
<div class="header-badges">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard</span>
</div>
<div class="header-row">
<h2 class="detail-title">{{ recipe.title }}</h2>
<div class="header-actions flex gap-sm">
<button
class="btn btn-secondary btn-save"
:class="{ 'btn-saved': isSaved }"
@click="showSaveModal = true"
:aria-label="isSaved ? 'Edit saved recipe' : 'Save recipe'"
>{{ isSaved ? '★ Saved' : '☆ Save' }}</button>
<button class="btn-close" @click="$emit('close')" aria-label="Close panel"></button>
</div>
</div>
<p v-if="recipe.notes" class="detail-notes">{{ recipe.notes }}</p>
<a
v-if="recipe.source_url"
:href="recipe.source_url"
target="_blank"
rel="noopener noreferrer"
class="source-link"
>View original </a>
</div>
<!-- Scrollable body -->
<div class="detail-body">
<!-- Ingredients: have vs. need in a two-column layout -->
<div class="ingredients-grid">
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
<h3 class="col-label col-label-have">From your pantry</h3>
<ul class="ingredient-list">
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
<span class="ing-icon ing-icon-have"></span>
<span>{{ ing }}</span>
</li>
</ul>
</div>
<div v-if="recipe.missing_ingredients?.length > 0" class="ingredient-col ingredient-col-need">
<div class="col-header-row">
<h3 class="col-label col-label-need">Still needed</h3>
<div class="col-header-actions">
<button class="share-btn" @click="shareList" :title="shareCopied ? 'Copied!' : 'Copy / share list'">
{{ shareCopied ? '✓ Copied' : 'Share' }}
</button>
</div>
</div>
<ul class="ingredient-list">
<li v-for="ing in recipe.missing_ingredients" :key="ing" class="ing-row">
<label class="ing-check-label">
<input
type="checkbox"
class="ing-check"
:checked="checkedIngredients.has(ing)"
@change="toggleIngredient(ing)"
/>
<span class="ing-name">{{ ing }}</span>
</label>
<a
v-if="groceryLinkFor(ing)"
:href="groceryLinkFor(ing)!.url"
target="_blank"
rel="noopener noreferrer"
class="buy-link"
>Buy </a>
</li>
</ul>
<button
v-if="recipe.missing_ingredients.length > 1"
class="select-all-btn"
@click="toggleSelectAll"
>{{ checkedIngredients.size === recipe.missing_ingredients.length ? 'Deselect all' : 'Select all' }}</button>
</div>
</div>
<!-- Swap candidates -->
<details v-if="recipe.swap_candidates.length > 0" class="detail-collapsible">
<summary class="detail-collapsible-summary">
Possible swaps ({{ recipe.swap_candidates.length }})
</summary>
<div class="card-secondary mt-xs">
<div
v-for="swap in recipe.swap_candidates"
:key="swap.original_name + swap.substitute_name"
class="swap-row text-sm"
>
<span class="font-semibold">{{ swap.original_name }}</span>
<span class="text-muted"> </span>
<span class="font-semibold">{{ swap.substitute_name }}</span>
<span v-if="swap.constraint_label" class="status-badge status-info ml-xs">{{ swap.constraint_label }}</span>
<p v-if="swap.explanation" class="text-muted mt-xs">{{ swap.explanation }}</p>
</div>
</div>
</details>
<!-- Nutrition panel -->
<div v-if="recipe.nutrition" class="detail-section">
<h3 class="section-label">Nutrition</h3>
<div class="nutrition-chips">
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">🔥 {{ Math.round(recipe.nutrition.calories) }} kcal</span>
<span v-if="recipe.nutrition.fat_g != null" class="nutrition-chip">🧈 {{ recipe.nutrition.fat_g.toFixed(1) }}g fat</span>
<span v-if="recipe.nutrition.protein_g != null" class="nutrition-chip">💪 {{ recipe.nutrition.protein_g.toFixed(1) }}g protein</span>
<span v-if="recipe.nutrition.carbs_g != null" class="nutrition-chip">🌾 {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs</span>
<span v-if="recipe.nutrition.fiber_g != null" class="nutrition-chip">🌿 {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber</span>
<span v-if="recipe.nutrition.sugar_g != null" class="nutrition-chip nutrition-chip-sugar">🍬 {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar</span>
<span v-if="recipe.nutrition.sodium_mg != null" class="nutrition-chip">🧂 {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium</span>
<span v-if="recipe.nutrition.servings != null" class="nutrition-chip nutrition-chip-servings">
🍽 {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
</span>
<span v-if="recipe.nutrition.estimated" class="nutrition-chip nutrition-chip-estimated" title="Estimated from ingredient profiles">~ estimated</span>
</div>
</div>
<!-- Prep notes -->
<div v-if="recipe.prep_notes.length > 0" class="detail-section">
<h3 class="section-label">Before you start</h3>
<ul class="prep-list">
<li v-for="note in recipe.prep_notes" :key="note" class="text-sm prep-item">{{ note }}</li>
</ul>
</div>
<!-- Directions -->
<div v-if="recipe.directions.length > 0" class="detail-section">
<h3 class="section-label">Steps</h3>
<ol class="directions-list">
<li v-for="(step, i) in recipe.directions" :key="i" class="text-sm direction-step">{{ step }}</li>
</ol>
</div>
<!-- Bottom padding so last step isn't hidden behind sticky footer -->
<div style="height: var(--spacing-xl)" />
</div>
<!-- Sticky footer -->
<div class="detail-footer">
<div v-if="cookDone" class="cook-success">
<span class="cook-success-icon"></span>
Enjoy your meal! Recipe dismissed from suggestions.
<button class="btn btn-secondary btn-sm mt-xs" @click="$emit('close')">Close</button>
</div>
<template v-else>
<button class="btn btn-secondary" @click="$emit('close')">Back</button>
<button
:class="['btn-bookmark-panel', { active: recipesStore.isBookmarked(recipe.id) }]"
@click="recipesStore.toggleBookmark(recipe)"
:aria-label="recipesStore.isBookmarked(recipe.id) ? `Remove bookmark: ${recipe.title}` : `Bookmark: ${recipe.title}`"
>{{ recipesStore.isBookmarked(recipe.id) ? '★' : '☆' }}</button>
<template v-if="checkedCount > 0">
<div class="add-pantry-col">
<p v-if="addError" role="alert" aria-live="assertive" class="add-error text-xs">{{ addError }}</p>
<p v-if="addedToPantry" role="status" aria-live="polite" class="add-success text-xs"> Added to pantry!</p>
<button
class="btn btn-accent flex-1"
:disabled="addingToPantry"
@click="addToPantry"
>
<span v-if="addingToPantry">Adding</span>
<span v-else>Add {{ checkedCount }} to pantry</span>
</button>
</div>
</template>
<button v-else class="btn btn-primary flex-1" @click="handleCook">
I cooked this
</button>
</template>
</div>
</div>
</div>
</Teleport>
<SaveRecipeModal
v-if="showSaveModal"
:recipe-id="recipe.id"
:recipe-title="recipe.title"
@close="showSaveModal = false"
@saved="showSaveModal = false"
@unsave="savedStore.unsave(recipe.id); showSaveModal = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRecipesStore } from '../stores/recipes'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import { inventoryAPI } from '../services/api'
import type { RecipeSuggestion, GroceryLink } from '../services/api'
import SaveRecipeModal from './SaveRecipeModal.vue'
const dialogRef = ref<HTMLElement | null>(null)
let previousFocus: HTMLElement | null = null
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
const focusable = dialogRef.value?.querySelector<HTMLElement>(
'button:not([disabled]), [href], input'
)
;(focusable ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
const recipesStore = useRecipesStore()
const savedStore = useSavedRecipesStore()
const props = defineProps<{
recipe: RecipeSuggestion
groceryLinks: GroceryLink[]
}>()
const emit = defineEmits<{
close: []
cooked: [recipe: RecipeSuggestion]
}>()
const showSaveModal = ref(false)
const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
const cookDone = ref(false)
const shareCopied = ref(false)
// Shopping: add purchased ingredients to pantry
const checkedIngredients = ref<Set<string>>(new Set())
const addingToPantry = ref(false)
const addedToPantry = ref(false)
const addError = ref<string | null>(null)
const checkedCount = computed(() => checkedIngredients.value.size)
function toggleIngredient(name: string) {
const next = new Set(checkedIngredients.value)
if (next.has(name)) {
next.delete(name)
} else {
next.add(name)
}
checkedIngredients.value = next
}
function toggleSelectAll() {
if (checkedIngredients.value.size === props.recipe.missing_ingredients.length) {
checkedIngredients.value = new Set()
} else {
checkedIngredients.value = new Set(props.recipe.missing_ingredients)
}
}
async function addToPantry() {
if (!checkedIngredients.value.size || addingToPantry.value) return
addingToPantry.value = true
addError.value = null
try {
const items = [...checkedIngredients.value].map((name) => ({ name, location: 'pantry' }))
const result = await inventoryAPI.bulkAddByName(items)
if (result.failed > 0 && result.added === 0) {
addError.value = 'Failed to add items. Please try again.'
} else {
addedToPantry.value = true
checkedIngredients.value = new Set()
}
} catch {
addError.value = 'Could not reach the pantry. Please try again.'
} finally {
addingToPantry.value = false
}
}
async function shareList() {
const items = props.recipe.missing_ingredients
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)
shareCopied.value = true
setTimeout(() => { shareCopied.value = false }, 2000)
}
}
function groceryLinkFor(ingredient: string): GroceryLink | undefined {
const needle = ingredient.toLowerCase()
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
}
function handleCook() {
cookDone.value = true
emit('cooked', props.recipe)
}
</script>
<style scoped>
/* ── Overlay / bottom-sheet shell ──────────────────────── */
.detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 400; /* above bottom-nav (200) and app-header (100) */
display: flex;
align-items: flex-end;
}
.detail-panel {
width: 100%;
max-height: 92dvh;
background: var(--color-bg-card);
border-radius: var(--radius-lg, 12px) var(--radius-lg, 12px) 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.2);
}
/* Centered modal on wider screens */
@media (min-width: 640px) {
.detail-overlay {
align-items: center;
justify-content: center;
padding: var(--spacing-md);
}
.detail-panel {
max-width: 680px;
max-height: 85dvh;
border-radius: var(--radius-lg, 12px);
}
}
/* ── Header ─────────────────────────────────────────────── */
.detail-header {
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.header-badges {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
margin-bottom: var(--spacing-xs);
}
.header-row {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
}
.detail-title {
font-size: var(--font-size-lg);
font-weight: 700;
flex: 1;
line-height: 1.3;
color: var(--color-text-primary);
}
.header-actions {
align-items: center;
flex-shrink: 0;
}
.btn-save {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-sm);
}
.btn-saved {
color: var(--color-warning);
border-color: var(--color-warning);
}
.btn-close {
background: transparent;
border: none;
cursor: pointer;
padding: 4px 8px;
font-size: 16px;
color: var(--color-text-muted);
border-radius: var(--radius-sm, 4px);
flex-shrink: 0;
line-height: 1;
transition: color 0.15s, background 0.15s;
}
.btn-close:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
}
.detail-notes {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-top: var(--spacing-xs);
line-height: 1.5;
}
/* ── Scrollable body ────────────────────────────────────── */
.detail-body {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
-webkit-overflow-scrolling: touch;
}
/* ── Ingredients grid ───────────────────────────────────── */
.ingredients-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
/* Stack single column if only have or only need */
.ingredients-grid:has(.ingredient-col:only-child) {
grid-template-columns: 1fr;
}
@media (max-width: 420px) {
.ingredients-grid {
grid-template-columns: 1fr;
}
}
.ingredient-col {
padding: var(--spacing-sm);
border-radius: var(--radius-md, 8px);
}
.ingredient-col-have {
background: var(--color-success-bg, #dcfce7);
}
.ingredient-col-need {
background: var(--color-warning-bg, #fef9c3);
}
.col-label {
font-size: var(--font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: var(--spacing-xs);
}
.col-label-have {
color: var(--color-success, #16a34a);
}
.col-label-need {
color: var(--color-warning, #ca8a04);
}
.ingredient-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.ing-row {
display: flex;
align-items: baseline;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
line-height: 1.4;
}
.ing-icon {
font-size: 11px;
flex-shrink: 0;
font-weight: 700;
}
.ing-icon-have {
color: var(--color-success, #16a34a);
}
.ing-icon-need {
color: var(--color-warning, #ca8a04);
}
.ing-name {
flex: 1;
}
.source-link {
display: inline-block;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-decoration: none;
margin-top: var(--spacing-xs);
}
.source-link:hover {
color: var(--color-primary);
text-decoration: underline;
}
.col-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-xs);
}
.col-header-row .col-label {
margin-bottom: 0;
}
.col-header-actions {
display: flex;
gap: var(--spacing-xs);
align-items: center;
}
/* Ingredient checkboxes */
.ing-check-label {
display: flex;
align-items: center;
flex-shrink: 0;
cursor: pointer;
}
.ing-check {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--color-warning, #ca8a04);
}
.select-all-btn {
background: transparent;
border: none;
color: var(--color-warning, #ca8a04);
font-size: var(--font-size-xs);
cursor: pointer;
padding: var(--spacing-xs) 0;
text-decoration: underline;
display: block;
margin-top: var(--spacing-xs);
}
.select-all-btn:hover {
opacity: 0.8;
transform: none;
}
/* Add to pantry footer state */
.add-pantry-col {
display: flex;
flex-direction: column;
flex: 1;
gap: 2px;
}
.add-error {
color: var(--color-error, #dc2626);
}
.add-success {
color: var(--color-success, #16a34a);
font-weight: 600;
}
.btn-accent {
background: var(--color-success, #16a34a);
color: #fff;
border: none;
border-radius: var(--radius-md, 8px);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-accent:hover {
opacity: 0.9;
transform: none;
}
.btn-accent:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.share-btn {
background: transparent;
border: 1px solid var(--color-warning, #ca8a04);
color: var(--color-warning, #ca8a04);
border-radius: var(--radius-sm, 4px);
font-size: var(--font-size-xs);
padding: 2px 8px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.share-btn:hover {
background: var(--color-warning-bg);
}
.buy-link {
font-size: var(--font-size-xs);
color: var(--color-primary);
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.buy-link:hover {
text-decoration: underline;
}
/* ── Generic detail sections ────────────────────────────── */
.detail-section {
margin-bottom: var(--spacing-md);
}
.section-label {
font-size: var(--font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-secondary);
margin-bottom: var(--spacing-xs);
}
/* ── Collapsible swaps ──────────────────────────────────── */
.detail-collapsible {
border-top: 1px solid var(--color-border);
padding: var(--spacing-sm) 0;
margin-bottom: var(--spacing-md);
}
.detail-collapsible-summary {
font-size: var(--font-size-sm);
font-weight: 600;
cursor: pointer;
list-style: none;
color: var(--color-primary);
}
.detail-collapsible-summary::-webkit-details-marker {
display: none;
}
.swap-row {
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
}
.swap-row:last-child {
border-bottom: none;
}
/* ── Nutrition ──────────────────────────────────────────── */
.nutrition-chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.nutrition-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-xs);
background: var(--color-bg-secondary, #f5f5f5);
color: var(--color-text-secondary);
white-space: nowrap;
}
.nutrition-chip-sugar {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.nutrition-chip-servings {
background: var(--color-info-bg);
color: var(--color-info-light);
}
.nutrition-chip-estimated {
font-style: italic;
opacity: 0.7;
}
/* ── Prep + Directions ──────────────────────────────────── */
.prep-list {
padding-left: var(--spacing-lg);
list-style-type: disc;
}
.prep-item {
margin-bottom: var(--spacing-xs);
line-height: 1.5;
color: var(--color-text-secondary);
}
.directions-list {
padding-left: var(--spacing-lg);
}
.direction-step {
margin-bottom: var(--spacing-sm);
line-height: 1.6;
}
/* ── Sticky footer ──────────────────────────────────────── */
.detail-footer {
padding: var(--spacing-md);
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--spacing-sm);
align-items: center;
background: var(--color-bg-card);
flex-shrink: 0;
}
.btn-bookmark-panel {
background: var(--color-bg-secondary, #f5f5f5);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
padding: 6px 12px;
font-size: 16px;
line-height: 1;
color: var(--color-text-muted);
flex-shrink: 0;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.btn-bookmark-panel:hover,
.btn-bookmark-panel.active {
color: var(--color-warning, #ca8a04);
background: var(--color-warning-bg, #fef9c3);
border-color: var(--color-warning, #ca8a04);
transform: none;
}
.cook-success {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
text-align: center;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-success, #16a34a);
gap: var(--spacing-xs);
}
.cook-success-icon {
font-size: 24px;
display: block;
}
.inline-spinner {
display: inline-block;
vertical-align: middle;
margin-right: var(--spacing-xs);
}
.mt-xs {
margin-top: var(--spacing-xs);
}
.ml-xs {
margin-left: var(--spacing-xs);
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
</style>

View file

@ -1,28 +1,65 @@
<template>
<div class="recipes-view">
<!-- Tab bar: Find / Browse / Saved -->
<div role="tablist" aria-label="Recipe sections" class="tab-bar flex gap-xs mb-md">
<button
v-for="tab in tabs"
:key="tab.id"
:id="`tab-${tab.id}`"
role="tab"
:aria-selected="activeTab === tab.id"
:tabindex="activeTab === tab.id ? 0 : -1"
:class="['btn', 'tab-btn', activeTab === tab.id ? 'btn-primary' : 'btn-secondary']"
@click="activeTab = tab.id"
@keydown="onTabKeydown"
>{{ tab.label }}</button>
</div>
<!-- Browse tab -->
<RecipeBrowserPanel
v-if="activeTab === 'browse'"
role="tabpanel"
aria-labelledby="tab-browse"
@open-recipe="openRecipeById"
/>
<!-- Saved tab -->
<SavedRecipesPanel
v-else-if="activeTab === 'saved'"
role="tabpanel"
aria-labelledby="tab-saved"
@open-recipe="openRecipeById"
/>
<!-- Find tab (existing search UI) -->
<div v-else role="tabpanel" aria-labelledby="tab-find">
<!-- Controls Panel -->
<div class="card mb-controls">
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
<!-- Level Selector -->
<div class="form-group">
<label class="form-label">Creativity Level</label>
<label class="form-label">How far should we stretch?</label>
<div class="flex flex-wrap gap-sm">
<button
v-for="lvl in levels"
:key="lvl.value"
:class="['btn', 'btn-secondary', { active: recipesStore.level === lvl.value }]"
@click="recipesStore.level = lvl.value"
:title="lvl.description"
>
{{ lvl.label }}
</button>
</div>
<p v-if="activeLevel" class="level-description text-sm text-secondary mt-xs">
{{ activeLevel.description }}
</p>
</div>
<!-- Wildcard warning -->
<!-- Surprise Me confirmation -->
<div v-if="recipesStore.level === 4" class="status-badge status-warning wildcard-warning">
Wildcard mode uses LLM to generate creative recipes with whatever you have. Results may be
unusual.
The AI will freestyle recipes from whatever you have. Results can be unusual that's part of the fun.
<label class="flex-start gap-sm mt-xs">
<input type="checkbox" v-model="recipesStore.wildcardConfirmed" />
<span>I understand, go for it</span>
@ -39,7 +76,7 @@
class="tag-chip status-badge status-info"
>
{{ tag }}
<button class="chip-remove" @click="removeConstraint(tag)" aria-label="Remove">×</button>
<button class="chip-remove" @click="removeConstraint(tag)" :aria-label="'Remove constraint: ' + tag">×</button>
</span>
</div>
<input
@ -61,7 +98,7 @@
class="tag-chip status-badge status-error"
>
{{ tag }}
<button class="chip-remove" @click="removeAllergy(tag)" aria-label="Remove">×</button>
<button class="chip-remove" @click="removeAllergy(tag)" :aria-label="'Remove allergy: ' + tag">×</button>
</span>
</div>
<input
@ -84,8 +121,19 @@
</p>
</div>
<!-- Max Missing -->
<!-- Shopping Mode -->
<div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
<input type="checkbox" v-model="recipesStore.shoppingMode" />
<span class="form-label" style="margin-bottom: 0;">Open to buying missing ingredients</span>
</label>
<p v-if="recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
All recipes shown regardless of missing ingredients. Affiliate links appear for anything you'd need to buy.
</p>
</div>
<!-- Max Missing hidden in shopping mode (it's lifted automatically) -->
<div v-if="!recipesStore.shoppingMode" class="form-group">
<label class="form-label">Max Missing Ingredients (optional)</label>
<input
type="number"
@ -210,6 +258,13 @@
{{ recipesStore.error }}
</div>
<!-- Screen reader announcement when results load -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<span v-if="recipesStore.result && !recipesStore.loading">
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
</span>
</div>
<!-- Results -->
<div v-if="recipesStore.result" class="results-section fade-in">
<!-- Rate limit warning -->
@ -233,18 +288,55 @@
</div>
</div>
<!-- Filter bar -->
<div v-if="recipesStore.result.suggestions.length > 0" class="filter-bar mb-md">
<input
class="form-input filter-search"
v-model="filterText"
placeholder="Search recipes or ingredients…"
aria-label="Filter recipes"
/>
<div class="filter-chips">
<template v-if="availableLevels.length > 1">
<button
v-for="lvl in availableLevels"
:key="lvl"
:class="['filter-chip', { active: filterLevel === lvl }]"
@click="filterLevel = filterLevel === lvl ? null : lvl"
>Lv{{ lvl }}</button>
</template>
<button
:class="['filter-chip', { active: filterMissing === 0 }]"
@click="filterMissing = filterMissing === 0 ? null : 0"
>Can make now</button>
<button
:class="['filter-chip', { active: filterMissing === 2 }]"
@click="filterMissing = filterMissing === 2 ? null : 2"
>2 missing</button>
<button
v-if="hasActiveFilters"
class="filter-chip filter-chip-clear"
@click="clearFilters"
> Clear</button>
</div>
</div>
<!-- No suggestions -->
<div
v-if="recipesStore.result.suggestions.length === 0"
v-if="filteredSuggestions.length === 0"
class="card text-center text-muted"
>
<p>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p>
<template v-if="hasActiveFilters">
<p>No recipes match your filters.</p>
<button class="btn btn-ghost btn-sm mt-xs" @click="clearFilters">Clear filters</button>
</template>
<p v-else>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p>
</div>
<!-- Recipe Cards -->
<div class="grid-auto mb-md">
<div
v-for="recipe in recipesStore.result.suggestions"
v-for="recipe in filteredSuggestions"
:key="recipe.id"
class="card slide-up"
>
@ -255,12 +347,17 @@
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard</span>
<button
v-if="recipe.id"
:class="['btn-bookmark', { active: recipesStore.isBookmarked(recipe.id) }]"
@click="recipesStore.toggleBookmark(recipe)"
:aria-label="recipesStore.isBookmarked(recipe.id) ? 'Remove bookmark: ' + recipe.title : 'Bookmark: ' + recipe.title"
>{{ recipesStore.isBookmarked(recipe.id) ? '★' : '☆' }}</button>
<button
v-if="recipe.id"
class="btn-dismiss"
@click="recipesStore.dismiss(recipe.id)"
title="Hide this recipe"
aria-label="Dismiss recipe"
:aria-label="'Hide recipe: ' + recipe.title"
></button>
</div>
</div>
@ -268,6 +365,18 @@
<!-- Notes -->
<p v-if="recipe.notes" class="text-sm text-secondary mb-sm">{{ recipe.notes }}</p>
<!-- Matched ingredients (what you already have) -->
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-section mb-sm">
<p class="text-sm font-semibold ingredient-label ingredient-label-have">From your pantry:</p>
<div class="flex flex-wrap gap-xs mt-xs">
<span
v-for="ing in recipe.matched_ingredients"
:key="ing"
class="ingredient-chip ingredient-chip-have"
>{{ ing }}</span>
</div>
</div>
<!-- Nutrition chips -->
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm">
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">
@ -358,17 +467,22 @@
</ul>
</div>
<!-- Directions collapsible -->
<details v-if="recipe.directions.length > 0" class="collapsible">
<summary class="text-sm font-semibold collapsible-summary">
Directions ({{ recipe.directions.length }} steps)
</summary>
<!-- Directions always visible; this is the content people came for -->
<div v-if="recipe.directions.length > 0" class="directions-section">
<p class="text-sm font-semibold directions-label">Steps</p>
<ol class="directions-list mt-xs">
<li v-for="(step, idx) in recipe.directions" :key="idx" class="text-sm direction-step">
{{ step }}
</li>
</ol>
</details>
</div>
<!-- Primary action: open detail panel -->
<div class="card-actions">
<button class="btn btn-primary btn-make" @click="openRecipe(recipe)">
Make this
</button>
</div>
</div>
</div>
@ -386,21 +500,17 @@
</button>
</div>
<!-- Grocery list summary -->
<div v-if="recipesStore.result.grocery_list.length > 0" class="card card-info">
<h3 class="text-lg font-bold mb-sm">Shopping List</h3>
<ul class="grocery-list">
<li
v-for="item in recipesStore.result.grocery_list"
:key="item"
class="text-sm grocery-item"
>
{{ item }}
</li>
</ul>
</div>
</div>
<!-- Recipe detail panel mounts as a full-screen overlay -->
<RecipeDetailPanel
v-if="selectedRecipe"
:recipe="selectedRecipe"
:grocery-links="selectedGroceryLinks"
@close="selectedRecipe = null"
@cooked="(recipe) => { onCooked(recipe); selectedRecipe = null }"
/>
<!-- Empty state when no results yet and pantry has items -->
<div
v-if="!recipesStore.result && !recipesStore.loading && pantryItems.length > 0"
@ -413,6 +523,17 @@
</svg>
<p class="mt-xs">Tap "Suggest Recipes" to find recipes using your pantry items.</p>
</div>
</div><!-- end Find tab -->
<!-- Detail panel for browser/saved recipe lookups -->
<RecipeDetailPanel
v-if="browserSelectedRecipe"
:recipe="browserSelectedRecipe"
:grocery-links="[]"
@close="browserSelectedRecipe = null"
@cooked="browserSelectedRecipe = null"
/>
</div>
</template>
@ -420,24 +541,120 @@
import { ref, computed, onMounted } from 'vue'
import { useRecipesStore } from '../stores/recipes'
import { useInventoryStore } from '../stores/inventory'
import RecipeDetailPanel from './RecipeDetailPanel.vue'
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
import SavedRecipesPanel from './SavedRecipesPanel.vue'
import type { RecipeSuggestion, GroceryLink } from '../services/api'
import { recipesAPI } from '../services/api'
const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore()
// Tab state
type TabId = 'find' | 'browse' | 'saved'
const tabs: Array<{ id: TabId; label: string }> = [
{ id: 'find', label: 'Find' },
{ id: 'browse', label: 'Browse' },
{ id: 'saved', label: 'Saved' },
]
const activeTab = ref<TabId>('find')
function onTabKeydown(e: KeyboardEvent) {
const tabIds: TabId[] = ['find', 'browse', 'saved']
const current = tabIds.indexOf(activeTab.value)
if (e.key === 'ArrowRight') {
e.preventDefault()
activeTab.value = tabIds[(current + 1) % tabIds.length]!
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
activeTab.value = tabIds[(current - 1 + tabIds.length) % tabIds.length]!
}
}
// Browser/saved tab recipe detail panel (fetches full recipe from API)
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)
async function openRecipeById(recipeId: number) {
try {
browserSelectedRecipe.value = await recipesAPI.getRecipe(recipeId)
} catch {
// silently ignore recipe may not exist
}
}
// Local input state for tags
const constraintInput = ref('')
const allergyInput = ref('')
const categoryInput = ref('')
const isLoadingMore = ref(false)
// Recipe detail panel (Find tab)
const selectedRecipe = ref<RecipeSuggestion | null>(null)
// Filter state (#21)
const filterText = ref('')
const filterLevel = ref<number | null>(null)
const filterMissing = ref<number | null>(null)
const availableLevels = computed(() => {
if (!recipesStore.result) return []
return [...new Set(recipesStore.result.suggestions.map((r) => r.level))].sort()
})
const filteredSuggestions = computed(() => {
if (!recipesStore.result) return []
let items = recipesStore.result.suggestions
const q = filterText.value.trim().toLowerCase()
if (q) {
items = items.filter((r) =>
r.title.toLowerCase().includes(q) ||
r.matched_ingredients.some((i) => i.toLowerCase().includes(q)) ||
r.missing_ingredients.some((i) => i.toLowerCase().includes(q))
)
}
if (filterLevel.value !== null) {
items = items.filter((r) => r.level === filterLevel.value)
}
if (filterMissing.value !== null) {
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
}
return items
})
const hasActiveFilters = computed(
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null
)
function clearFilters() {
filterText.value = ''
filterLevel.value = null
filterMissing.value = null
}
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
if (!selectedRecipe.value || !recipesStore.result) return []
const missing = new Set(selectedRecipe.value.missing_ingredients.map((s) => s.toLowerCase()))
return recipesStore.result.grocery_links.filter((l) => missing.has(l.ingredient.toLowerCase()))
})
function openRecipe(recipe: RecipeSuggestion) {
selectedRecipe.value = recipe
}
function onCooked(recipe: RecipeSuggestion) {
recipesStore.logCook(recipe.id, recipe.title)
recipesStore.dismiss(recipe.id)
}
const levels = [
{ value: 1, label: '1 — From Pantry' },
{ value: 2, label: '2 — Creative Swaps' },
{ value: 3, label: '3 — AI Scaffold' },
{ value: 4, label: '4 — Wildcard 🎲' },
{ value: 1, label: 'Use What I Have', description: 'Finds recipes you can make right now using exactly what\'s in your pantry.' },
{ value: 2, label: 'Allow Swaps', description: 'Same as above, plus recipes where one or two ingredients can be substituted.' },
{ value: 3, label: 'Get Creative', description: 'AI builds recipes in your chosen cuisine style from what you have. Requires paid tier.' },
{ value: 4, label: 'Surprise Me 🎲', description: 'Fully AI-generated — open-ended and occasionally unexpected. Requires paid tier.' },
]
const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level))
const cuisineStyles = [
{ id: 'italian', label: 'Italian' },
{ id: 'mediterranean', label: 'Mediterranean' },
@ -554,6 +771,16 @@ onMounted(async () => {
</script>
<style scoped>
.tab-bar {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-sm);
}
.tab-btn {
border-radius: var(--radius-md) var(--radius-md) 0 0;
border-bottom: none;
}
.mb-controls {
margin-bottom: var(--spacing-md);
}
@ -574,6 +801,11 @@ onMounted(async () => {
margin-left: var(--spacing-xs);
}
.level-description {
font-style: italic;
line-height: 1.4;
}
.wildcard-warning {
display: block;
margin-bottom: var(--spacing-md);
@ -585,6 +817,11 @@ onMounted(async () => {
user-select: none;
}
.shopping-toggle {
cursor: pointer;
user-select: none;
}
.tag-chip {
display: inline-flex;
align-items: center;
@ -643,6 +880,116 @@ onMounted(async () => {
transform: none;
}
.btn-bookmark {
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
font-size: 14px;
line-height: 1;
color: var(--color-text-muted);
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.btn-bookmark:hover,
.btn-bookmark.active {
color: var(--color-warning, #ca8a04);
background: var(--color-warning-bg, #fef9c3);
transform: none;
}
/* Saved recipes section */
.saved-header {
user-select: none;
}
.saved-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.saved-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
}
.saved-row:last-child {
border-bottom: none;
}
.saved-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-primary);
}
.saved-title:hover {
text-decoration: underline;
}
/* Filter bar */
.filter-bar {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.filter-search {
width: 100%;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.filter-chip {
background: var(--color-bg-secondary, #f5f5f5);
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 2px 10px;
font-size: var(--font-size-xs);
cursor: pointer;
color: var(--color-text-secondary);
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.filter-chip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-bg-secondary);
transform: none;
}
.filter-chip.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.filter-chip-clear {
border-color: var(--color-error, #dc2626);
color: var(--color-error, #dc2626);
}
.filter-chip-clear:hover {
background: var(--color-error-bg, #fee2e2);
border-color: var(--color-error, #dc2626);
color: var(--color-error, #dc2626);
}
.suggest-row {
display: flex;
align-items: center;
@ -721,13 +1068,54 @@ details[open] .collapsible-summary::before {
color: var(--color-text-secondary);
}
.ingredient-section {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
}
.ingredient-label {
margin-bottom: 0;
}
.ingredient-label-have {
color: var(--color-success, #16a34a);
}
.ingredient-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ingredient-chip-have {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
}
.directions-section {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
margin-top: var(--spacing-xs);
}
.directions-label {
color: var(--color-text-secondary);
text-transform: uppercase;
font-size: var(--font-size-xs);
letter-spacing: 0.05em;
margin-bottom: var(--spacing-xs);
}
.directions-list {
padding-left: var(--spacing-lg);
}
.direction-step {
margin-bottom: var(--spacing-xs);
line-height: 1.5;
margin-bottom: var(--spacing-sm);
line-height: 1.6;
}
.grocery-link {
@ -740,12 +1128,17 @@ details[open] .collapsible-summary::before {
opacity: 0.8;
}
.grocery-list {
padding-left: var(--spacing-lg);
.card-actions {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-sm);
margin-top: var(--spacing-sm);
display: flex;
justify-content: flex-end;
}
.grocery-item {
margin-bottom: var(--spacing-xs);
.btn-make {
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-md);
}
.results-section {

View file

@ -0,0 +1,294 @@
<template>
<Teleport to="body">
<div class="modal-overlay" @click.self="$emit('close')">
<div ref="dialogRef" class="modal-panel card" role="dialog" aria-modal="true" aria-label="Save recipe" tabindex="-1">
<div class="flex-between mb-md">
<h3 class="section-title">{{ isEditing ? 'Edit saved recipe' : 'Save recipe' }}</h3>
<button class="btn-close" @click="$emit('close')" aria-label="Close"></button>
</div>
<p class="recipe-title-label text-sm text-secondary mb-md">{{ recipeTitle }}</p>
<!-- Star rating -->
<div class="form-group">
<label id="rating-label" class="form-label">Rating</label>
<div role="group" aria-labelledby="rating-label" class="stars-row flex gap-xs">
<button
v-for="n in 5"
:key="n"
class="star-btn"
:class="{ filled: n <= (hoverRating ?? localRating ?? 0) }"
@mouseenter="hoverRating = n"
@mouseleave="hoverRating = null"
@click="toggleRating(n)"
:aria-label="`${n} star${n !== 1 ? 's' : ''}`"
:aria-pressed="n <= (localRating ?? 0)"
></button>
<button
v-if="localRating !== null"
class="btn btn-secondary btn-xs ml-xs"
@click="localRating = null"
>Clear</button>
</div>
</div>
<!-- Notes -->
<div class="form-group">
<label class="form-label" for="save-notes">Notes</label>
<textarea
id="save-notes"
class="form-input"
v-model="localNotes"
rows="3"
placeholder="e.g. loved with extra garlic, halve the salt next time"
/>
</div>
<!-- Style tags -->
<div class="form-group">
<label class="form-label">Style tags</label>
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in localTags"
:key="tag"
class="tag-chip status-badge status-info"
>
{{ tag }}
<button class="chip-remove" @click="removeTag(tag)" :aria-label="`Remove tag: ${tag}`">×</button>
</span>
</div>
<input
class="form-input"
v-model="tagInput"
placeholder="e.g. comforting, hands-off, quick — press Enter or comma"
@keydown="onTagKey"
@blur="commitTagInput"
/>
<div class="tag-suggestions flex flex-wrap gap-xs mt-xs">
<button
v-for="s in unusedSuggestions"
:key="s"
class="btn btn-secondary btn-xs"
@click="addTag(s)"
>+ {{ s }}</button>
</div>
</div>
<div class="flex gap-sm mt-md">
<button class="btn btn-primary" :disabled="saving" @click="submit">
{{ saving ? 'Saving…' : (isEditing ? 'Update' : 'Save') }}
</button>
<button v-if="isEditing" class="btn btn-danger" @click="$emit('unsave')">Remove</button>
<button class="btn btn-secondary" @click="$emit('close')">Cancel</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useSavedRecipesStore } from '../stores/savedRecipes'
const SUGGESTED_TAGS = [
'comforting', 'light', 'spicy', 'umami', 'sweet', 'savory', 'rich',
'crispy', 'creamy', 'hearty', 'quick', 'hands-off', 'meal-prep-friendly',
'fancy', 'one-pot',
]
const props = defineProps<{
recipeId: number
recipeTitle: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved'): void
(e: 'unsave'): void
}>()
const dialogRef = ref<HTMLElement | null>(null)
let previousFocus: HTMLElement | null = null
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
const focusable = dialogRef.value?.querySelector<HTMLElement>(
'button:not([disabled]), input, textarea'
)
;(focusable ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
const store = useSavedRecipesStore()
const existing = computed(() => store.getSaved(props.recipeId))
const isEditing = computed(() => !!existing.value)
const localRating = ref<number | null>(existing.value?.rating ?? null)
const localNotes = ref<string>(existing.value?.notes ?? '')
const localTags = ref<string[]>([...(existing.value?.style_tags ?? [])])
const hoverRating = ref<number | null>(null)
const tagInput = ref('')
const saving = ref(false)
const unusedSuggestions = computed(() =>
SUGGESTED_TAGS.filter((s) => !localTags.value.includes(s))
)
function toggleRating(n: number) {
localRating.value = localRating.value === n ? null : n
}
function addTag(tag: string) {
const clean = tag.trim().toLowerCase()
if (clean && !localTags.value.includes(clean)) {
localTags.value = [...localTags.value, clean]
}
}
function removeTag(tag: string) {
localTags.value = localTags.value.filter((t) => t !== tag)
}
function commitTagInput() {
if (tagInput.value.trim()) {
addTag(tagInput.value)
tagInput.value = ''
}
}
function onTagKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
commitTagInput()
}
}
async function submit() {
saving.value = true
try {
if (isEditing.value) {
await store.update(props.recipeId, {
notes: localNotes.value || null,
rating: localRating.value,
style_tags: localTags.value,
})
} else {
await store.save(props.recipeId, localNotes.value || undefined, localRating.value ?? undefined)
if (localTags.value.length > 0 || localNotes.value) {
await store.update(props.recipeId, {
notes: localNotes.value || null,
rating: localRating.value,
style_tags: localTags.value,
})
}
}
emit('saved')
emit('close')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.recipe-title-label {
font-style: italic;
}
.stars-row {
align-items: center;
}
.star-btn {
background: none;
border: none;
font-size: 1.6rem;
color: var(--color-border);
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.1s ease, transform 0.1s ease;
}
.star-btn.filled {
color: var(--color-warning);
}
.star-btn:hover {
transform: scale(1.15);
}
.btn-xs {
padding: 2px var(--spacing-xs);
font-size: var(--font-size-xs, 0.75rem);
}
.btn-danger {
background: var(--color-error);
color: white;
border-color: var(--color-error);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
}
.chip-remove {
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
opacity: 0.7;
padding: 0;
}
.chip-remove:hover {
opacity: 1;
}
.btn-close {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-secondary);
padding: var(--spacing-xs);
line-height: 1;
}
.btn-close:hover {
color: var(--color-text-primary);
}
</style>

View file

@ -0,0 +1,289 @@
<template>
<div class="saved-panel">
<!-- Empty state -->
<div v-if="!store.loading && store.saved.length === 0" class="empty-state card text-center">
<p class="text-secondary">No saved recipes yet.</p>
<p class="text-sm text-secondary mt-xs">Bookmark a recipe from Find or Browse and it will appear here.</p>
</div>
<template v-else>
<!-- Controls -->
<div class="saved-controls flex-between flex-wrap gap-sm mb-md">
<div class="flex gap-sm flex-wrap">
<!-- Collection filter -->
<label class="sr-only" for="collection-filter">Filter by collection</label>
<select id="collection-filter" class="form-input sort-select" v-model="activeCollectionId" @change="reload">
<option :value="null">All saved</option>
<option v-for="col in store.collections" :key="col.id" :value="col.id">
{{ col.name }} ({{ col.member_count }})
</option>
</select>
<!-- Sort -->
<label class="sr-only" for="sort-order">Sort by</label>
<select id="sort-order" class="form-input sort-select" v-model="store.sortBy" @change="reload">
<option value="saved_at">Recently saved</option>
<option value="rating">Highest rated</option>
<option value="title">AZ</option>
</select>
</div>
<button class="btn btn-secondary btn-sm" @click="showNewCollection = true">
+ New collection
</button>
</div>
<!-- Loading -->
<div v-if="store.loading" class="text-secondary text-sm">Loading</div>
<!-- Recipe cards -->
<div class="saved-list flex-col gap-sm">
<div
v-for="recipe in store.saved"
:key="recipe.id"
class="card-sm saved-card"
:class="{ 'card-success': recipe.rating !== null && recipe.rating >= 4 }"
>
<div class="flex-between gap-sm">
<button
class="recipe-title-btn text-left"
@click="$emit('open-recipe', recipe.recipe_id)"
>
{{ recipe.title }}
</button>
<!-- Stars display -->
<div v-if="recipe.rating !== null" class="stars-display flex gap-xs" aria-label="Rating">
<span
v-for="n in 5"
:key="n"
class="star-pip"
:class="{ filled: n <= recipe.rating }"
></span>
</div>
</div>
<!-- Tags -->
<div v-if="recipe.style_tags.length > 0" class="flex flex-wrap gap-xs mt-xs">
<span
v-for="tag in recipe.style_tags"
:key="tag"
class="tag-chip status-badge status-info"
>{{ tag }}</span>
</div>
<!-- Notes preview -->
<p v-if="recipe.notes" class="notes-preview text-sm text-secondary mt-xs">
{{ recipe.notes }}
</p>
<!-- Actions -->
<div class="flex gap-xs mt-sm">
<button class="btn btn-secondary btn-xs" @click="editRecipe(recipe)">Edit</button>
<button class="btn btn-secondary btn-xs" @click="unsave(recipe)">Remove</button>
</div>
</div>
</div>
</template>
<!-- New collection modal -->
<Teleport to="body" v-if="showNewCollection">
<div class="modal-overlay" @click.self="showNewCollection = false">
<div ref="newColDialogRef" class="modal-panel card" role="dialog" aria-modal="true" aria-label="New collection" tabindex="-1">
<h3 class="section-title mb-md">New collection</h3>
<div class="form-group">
<label class="form-label" for="col-name">Name</label>
<input id="col-name" class="form-input" v-model="newColName" placeholder="e.g. Weeknight meals" />
</div>
<div class="form-group">
<label class="form-label" for="col-desc">Description (optional)</label>
<input id="col-desc" class="form-input" v-model="newColDesc" placeholder="Optional description" />
</div>
<div class="flex gap-sm mt-md">
<button class="btn btn-primary" :disabled="!newColName.trim() || creatingCol" @click="createCollection">
{{ creatingCol ? 'Creating…' : 'Create' }}
</button>
<button class="btn btn-secondary" @click="showNewCollection = false">Cancel</button>
</div>
</div>
</div>
</Teleport>
<!-- Edit modal -->
<SaveRecipeModal
v-if="editingRecipe"
:recipe-id="editingRecipe.recipe_id"
:recipe-title="editingRecipe.title"
@close="editingRecipe = null"
@saved="editingRecipe = null"
@unsave="doUnsave(editingRecipe!.recipe_id)"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import type { SavedRecipe } from '../services/api'
import SaveRecipeModal from './SaveRecipeModal.vue'
defineEmits<{
(e: 'open-recipe', recipeId: number): void
}>()
const store = useSavedRecipesStore()
const editingRecipe = ref<SavedRecipe | null>(null)
const showNewCollection = ref(false)
const newColDialogRef = ref<HTMLElement | null>(null)
let newColPreviousFocus: HTMLElement | null = null
function handleNewColKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') showNewCollection.value = false
}
watch(showNewCollection, (open) => {
if (open) {
newColPreviousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleNewColKeydown)
nextTick(() => {
const focusable = newColDialogRef.value?.querySelector<HTMLElement>(
'button:not([disabled]), input'
)
;(focusable ?? newColDialogRef.value)?.focus()
})
} else {
document.removeEventListener('keydown', handleNewColKeydown)
newColPreviousFocus?.focus()
}
})
onUnmounted(() => {
document.removeEventListener('keydown', handleNewColKeydown)
})
const newColName = ref('')
const newColDesc = ref('')
const creatingCol = ref(false)
const activeCollectionId = computed({
get: () => store.activeCollectionId,
set: (v) => { store.activeCollectionId = v },
})
onMounted(() => store.load())
function reload() {
store.load()
}
function editRecipe(recipe: SavedRecipe) {
editingRecipe.value = recipe
}
async function unsave(recipe: SavedRecipe) {
await store.unsave(recipe.recipe_id)
}
async function doUnsave(recipeId: number) {
editingRecipe.value = null
await store.unsave(recipeId)
}
async function createCollection() {
if (!newColName.value.trim()) return
creatingCol.value = true
try {
await store.createCollection(newColName.value.trim(), newColDesc.value.trim() || undefined)
showNewCollection.value = false
newColName.value = ''
newColDesc.value = ''
} finally {
creatingCol.value = false
}
}
</script>
<style scoped>
.saved-panel {
padding: var(--spacing-sm) 0;
}
.sort-select {
width: auto;
min-width: 140px;
}
.saved-card {
transition: box-shadow 0.15s ease;
}
.recipe-title-btn {
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-primary);
padding: 0;
flex: 1;
}
.recipe-title-btn:hover {
text-decoration: underline;
}
.stars-display {
flex-shrink: 0;
}
.star-pip {
font-size: 1rem;
color: var(--color-border);
}
.star-pip.filled {
color: var(--color-warning);
}
.notes-preview {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.tag-chip {
display: inline-flex;
align-items: center;
font-size: var(--font-size-xs, 0.75rem);
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.btn-xs {
padding: 2px var(--spacing-xs);
font-size: var(--font-size-xs, 0.75rem);
}
.empty-state {
padding: var(--spacing-xl);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 420px;
}
</style>

View file

@ -64,14 +64,114 @@
</div>
</section>
</div>
<div class="card mt-md">
<h2 class="section-title text-xl mb-md">Cook History</h2>
<p v-if="recipesStore.cookLog.length === 0" class="text-sm text-muted">
No recipes cooked yet. Tap "I cooked this" on any recipe to log it.
</p>
<template v-else>
<div class="log-list">
<div
v-for="entry in sortedCookLog"
:key="entry.cookedAt"
class="log-entry"
>
<span class="log-title text-sm">{{ entry.title }}</span>
<span class="log-date text-xs text-muted">{{ formatCookDate(entry.cookedAt) }}</span>
</div>
</div>
<button class="btn btn-ghost btn-sm mt-sm" @click="recipesStore.clearCookLog()">
Clear history
</button>
</template>
</div>
<!-- Household (Premium) -->
<div v-if="householdVisible" class="card mt-md">
<h2 class="section-title text-xl mb-md">Household</h2>
<!-- Loading -->
<p v-if="householdLoading" class="text-sm text-muted">Loading</p>
<!-- Error -->
<p v-if="householdError" class="text-sm status-badge status-error">{{ householdError }}</p>
<!-- Not in a household -->
<template v-else-if="!householdStatus?.in_household">
<p class="text-sm text-secondary mb-md">
Create a household to share your pantry with family or housemates.
All members see and edit the same inventory.
</p>
<button class="btn btn-primary" :disabled="householdLoading" @click="handleCreateHousehold">
Create Household
</button>
</template>
<!-- In household -->
<template v-else>
<p class="text-sm text-muted mb-sm">
Household ID: <code class="household-id">{{ householdStatus.household_id }}</code>
</p>
<!-- Owner: member list + invite -->
<template v-if="householdStatus.is_owner">
<h3 class="text-base font-semibold mb-xs">Members ({{ householdStatus.members.length }}/{{ householdStatus.max_seats }})</h3>
<div class="member-list mb-md">
<div v-for="m in householdStatus.members" :key="m.user_id" class="member-row">
<span class="text-sm member-id">{{ m.user_id }}</span>
<span v-if="m.is_owner" class="status-badge status-info text-xs">Owner</span>
<button
v-else
class="btn btn-ghost btn-sm"
@click="handleRemoveMember(m.user_id)"
>Remove</button>
</div>
</div>
<!-- Invite link -->
<div v-if="inviteUrl" class="invite-row mb-sm">
<input class="form-input invite-input" :value="inviteUrl" readonly />
<button class="btn btn-secondary btn-sm" @click="copyInvite">
{{ inviteCopied ? '✓ Copied' : 'Copy' }}
</button>
</div>
<button
class="btn btn-primary btn-sm"
:disabled="householdLoading"
@click="handleInvite"
>{{ inviteUrl ? 'New invite link' : 'Generate invite link' }}</button>
</template>
<!-- Member: leave button -->
<template v-else>
<p class="text-sm text-secondary mb-md">
You are a member of this shared household pantry.
</p>
<button class="btn btn-ghost btn-sm" @click="handleLeave">Leave Household</button>
</template>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
import { useRecipesStore } from '../stores/recipes'
import { householdAPI, type HouseholdStatus } from '../services/api'
const settingsStore = useSettingsStore()
const recipesStore = useRecipesStore()
const sortedCookLog = computed(() =>
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
)
function formatCookDate(ms: number): string {
return new Date(ms).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
})
}
const equipmentInput = ref('')
@ -119,8 +219,93 @@ function commitEquipmentInput() {
}
}
// Household (#5)
const householdVisible = ref(false)
const householdLoading = ref(false)
const householdError = ref<string | null>(null)
const householdStatus = ref<HouseholdStatus | null>(null)
const inviteUrl = ref<string | null>(null)
const inviteCopied = ref(false)
async function loadHouseholdStatus() {
householdLoading.value = true
householdError.value = null
try {
householdStatus.value = await householdAPI.status()
householdVisible.value = true
} catch (err: unknown) {
// 403 = not premium hide the card silently
const status = (err as any)?.response?.status
if (status !== 403) {
householdError.value = 'Could not load household status.'
householdVisible.value = true
}
} finally {
householdLoading.value = false
}
}
async function handleCreateHousehold() {
householdLoading.value = true
try {
await householdAPI.create()
await loadHouseholdStatus()
} catch {
householdError.value = 'Could not create household. Please try again.'
} finally {
householdLoading.value = false
}
}
async function handleInvite() {
householdLoading.value = true
try {
const result = await householdAPI.invite()
inviteUrl.value = result.invite_url
} catch {
householdError.value = 'Could not generate invite link.'
} finally {
householdLoading.value = false
}
}
async function copyInvite() {
if (!inviteUrl.value) return
await navigator.clipboard.writeText(inviteUrl.value)
inviteCopied.value = true
setTimeout(() => { inviteCopied.value = false }, 2000)
}
async function handleLeave() {
if (!confirm('Leave the household? You will return to your personal pantry.')) return
householdLoading.value = true
try {
await householdAPI.leave()
await loadHouseholdStatus()
inviteUrl.value = null
} catch {
householdError.value = 'Could not leave household. Please try again.'
} finally {
householdLoading.value = false
}
}
async function handleRemoveMember(userId: string) {
if (!confirm(`Remove member ${userId}?`)) return
householdLoading.value = true
try {
await householdAPI.removeMember(userId)
await loadHouseholdStatus()
} catch {
householdError.value = 'Could not remove member.'
} finally {
householdLoading.value = false
}
}
onMounted(async () => {
await settingsStore.load()
await loadHouseholdStatus()
})
</script>
@ -159,4 +344,110 @@ onMounted(async () => {
opacity: 1;
transform: none;
}
.mt-md {
margin-top: var(--spacing-md);
}
.mt-sm {
margin-top: var(--spacing-sm);
}
.log-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.log-entry {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--spacing-sm);
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
}
.log-entry:last-child {
border-bottom: none;
}
.log-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-date {
flex-shrink: 0;
}
.btn-ghost {
background: transparent;
border: none;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
cursor: pointer;
padding: var(--spacing-xs) var(--spacing-sm);
}
.btn-ghost:hover {
color: var(--color-error, #dc2626);
background: transparent;
transform: none;
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.household-id {
font-size: var(--font-size-xs);
background: var(--color-bg-secondary);
padding: 2px 6px;
border-radius: 4px;
}
.member-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.member-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-xs) 0;
border-bottom: 1px solid var(--color-border);
gap: var(--spacing-sm);
}
.member-row:last-child {
border-bottom: none;
}
.member-id {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-secondary);
}
.invite-row {
display: flex;
gap: var(--spacing-xs);
align-items: center;
}
.invite-input {
flex: 1;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
</style>

View file

@ -266,6 +266,20 @@ export const inventoryAPI = {
return response.data
},
/**
* Bulk-add items by ingredient name (no barcode required).
* Idempotent: re-adding an existing product just creates a new inventory entry.
*/
async bulkAddByName(items: Array<{
name: string
quantity?: number
unit?: string
location?: string
}>): Promise<{ added: number; failed: number; results: Array<{ name: string; ok: boolean; item_id?: number; error?: string }> }> {
const response = await api.post('/inventory/items/bulk-add-by-name', { items })
return response.data
},
/**
* Scan barcode from image
*/
@ -433,6 +447,7 @@ export interface RecipeSuggestion {
match_count: number
element_coverage: Record<string, number>
swap_candidates: SwapCandidate[]
matched_ingredients: string[]
missing_ingredients: string[]
directions: string[]
prep_notes: string[]
@ -440,6 +455,7 @@ export interface RecipeSuggestion {
level: number
is_wildcard: boolean
nutrition: NutritionPanel | null
source_url: string | null
}
export interface NutritionFilters {
@ -477,6 +493,7 @@ export interface RecipeRequest {
wildcard_confirmed: boolean
nutrition_filters: NutritionFilters
excluded_ids: number[]
shopping_mode: boolean
}
export interface Staple {
@ -519,4 +536,159 @@ export const settingsAPI = {
},
}
// ========== Household Types ==========
export interface HouseholdMember {
user_id: string
joined_at: string
is_owner: boolean
}
export interface HouseholdStatus {
in_household: boolean
household_id: string | null
is_owner: boolean
members: HouseholdMember[]
max_seats: number
}
export interface HouseholdInvite {
invite_url: string
token: string
expires_at: string
}
// ========== Household API ==========
export const householdAPI = {
async create(): Promise<{ household_id: string; message: string }> {
const response = await api.post('/household/create')
return response.data
},
async status(): Promise<HouseholdStatus> {
const response = await api.get('/household/status')
return response.data
},
async invite(): Promise<HouseholdInvite> {
const response = await api.post('/household/invite')
return response.data
},
async accept(householdId: string, token: string): Promise<{ message: string; household_id: string }> {
const response = await api.post('/household/accept', { household_id: householdId, token })
return response.data
},
async leave(): Promise<{ message: string }> {
const response = await api.post('/household/leave')
return response.data
},
async removeMember(userId: string): Promise<{ message: string }> {
const response = await api.post('/household/remove-member', { user_id: userId })
return response.data
},
}
// ========== Saved Recipes Types ==========
export interface SavedRecipe {
id: number
recipe_id: number
title: string
saved_at: string
notes: string | null
rating: number | null
style_tags: string[]
collection_ids: number[]
}
export interface RecipeCollection {
id: number
name: string
description: string | null
member_count: number
created_at: string
}
// ========== Saved Recipes API ==========
export const savedRecipesAPI = {
async save(recipe_id: number, notes?: string, rating?: number): Promise<SavedRecipe> {
const response = await api.post('/recipes/saved', { recipe_id, notes, rating })
return response.data
},
async unsave(recipe_id: number): Promise<void> {
await api.delete(`/recipes/saved/${recipe_id}`)
},
async update(recipe_id: number, data: { notes?: string | null; rating?: number | null; style_tags?: string[] }): Promise<SavedRecipe> {
const response = await api.patch(`/recipes/saved/${recipe_id}`, data)
return response.data
},
async list(params?: { sort_by?: string; collection_id?: number }): Promise<SavedRecipe[]> {
const response = await api.get('/recipes/saved', { params })
return response.data
},
async listCollections(): Promise<RecipeCollection[]> {
const response = await api.get('/recipes/saved/collections')
return response.data
},
async createCollection(name: string, description?: string): Promise<RecipeCollection> {
const response = await api.post('/recipes/saved/collections', { name, description })
return response.data
},
async deleteCollection(id: number): Promise<void> {
await api.delete(`/recipes/saved/collections/${id}`)
},
async addToCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
await api.post(`/recipes/saved/collections/${collection_id}/members`, { saved_recipe_id })
},
async removeFromCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
await api.delete(`/recipes/saved/collections/${collection_id}/members/${saved_recipe_id}`)
},
}
// ========== Browser Types ==========
export interface BrowserDomain {
id: string
label: string
}
export interface BrowserCategory {
category: string
recipe_count: number
}
export interface BrowserRecipe {
id: number
title: string
category: string | null
match_pct: number | null
}
export interface BrowserResult {
recipes: BrowserRecipe[]
total: number
page: number
}
// ========== Browser API ==========
export const browserAPI = {
async listDomains(): Promise<BrowserDomain[]> {
const response = await api.get('/recipes/browse/domains')
return response.data
},
async listCategories(domain: string): Promise<BrowserCategory[]> {
const response = await api.get(`/recipes/browse/${domain}`)
return response.data
},
async browse(domain: string, category: string, params?: {
page?: number
page_size?: number
pantry_items?: string
}): Promise<BrowserResult> {
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
return response.data
},
}
export default api

View file

@ -143,6 +143,13 @@ export const useInventoryStore = defineStore('inventory', () => {
locationFilter.value = location
}
async function consumeItem(itemId: number) {
await inventoryAPI.consumeItem(itemId)
items.value = items.value.map((item) =>
item.id === itemId ? { ...item, status: 'consumed' } : item
)
}
function setStatusFilter(status: string) {
statusFilter.value = status
}
@ -166,6 +173,7 @@ export const useInventoryStore = defineStore('inventory', () => {
fetchStats,
updateItem,
deleteItem,
consumeItem,
scanBarcode,
setLocationFilter,
setStatusFilter,

View file

@ -12,9 +12,21 @@ import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeReques
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
const COOK_LOG_KEY = 'kiwi:cook_log'
const COOK_LOG_MAX = 200
const BOOKMARKS_KEY = 'kiwi:bookmarks'
const BOOKMARKS_MAX = 50
// [id, dismissedAtMs]
type DismissEntry = [number, number]
export interface CookLogEntry {
id: number
title: string
cookedAt: number // unix ms
}
function loadDismissed(): Set<number> {
try {
const raw = localStorage.getItem(DISMISSED_KEY)
@ -33,6 +45,32 @@ function saveDismissed(ids: Set<number>) {
localStorage.setItem(DISMISSED_KEY, JSON.stringify(entries))
}
function loadCookLog(): CookLogEntry[] {
try {
const raw = localStorage.getItem(COOK_LOG_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveCookLog(log: CookLogEntry[]) {
localStorage.setItem(COOK_LOG_KEY, JSON.stringify(log.slice(-COOK_LOG_MAX)))
}
function loadBookmarks(): RecipeSuggestion[] {
try {
const raw = localStorage.getItem(BOOKMARKS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveBookmarks(bookmarks: RecipeSuggestion[]) {
localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(bookmarks.slice(0, BOOKMARKS_MAX)))
}
export const useRecipesStore = defineStore('recipes', () => {
// Suggestion result state
const result = ref<RecipeResult | null>(null)
@ -48,6 +86,7 @@ export const useRecipesStore = defineStore('recipes', () => {
const styleId = ref<string | null>(null)
const category = ref<string | null>(null)
const wildcardConfirmed = ref(false)
const shoppingMode = ref(false)
const nutritionFilters = ref<NutritionFilters>({
max_calories: null,
max_sugar_g: null,
@ -59,6 +98,10 @@ export const useRecipesStore = defineStore('recipes', () => {
const dismissedIds = ref<Set<number>>(loadDismissed())
// Seen IDs: session-only, used by Load More to avoid repeating results
const seenIds = ref<Set<number>>(new Set())
// Cook log: persisted to localStorage, max COOK_LOG_MAX entries
const cookLog = ref<CookLogEntry[]>(loadCookLog())
// Bookmarks: full RecipeSuggestion snapshots, max BOOKMARKS_MAX
const bookmarks = ref<RecipeSuggestion[]>(loadBookmarks())
const dismissedCount = computed(() => dismissedIds.value.size)
@ -77,6 +120,7 @@ export const useRecipesStore = defineStore('recipes', () => {
wildcard_confirmed: wildcardConfirmed.value,
nutrition_filters: nutritionFilters.value,
excluded_ids: [...excluded],
shopping_mode: shoppingMode.value,
}
}
@ -144,6 +188,35 @@ export const useRecipesStore = defineStore('recipes', () => {
localStorage.removeItem(DISMISSED_KEY)
}
function logCook(id: number, title: string) {
const entry: CookLogEntry = { id, title, cookedAt: Date.now() }
cookLog.value = [...cookLog.value, entry]
saveCookLog(cookLog.value)
}
function clearCookLog() {
cookLog.value = []
localStorage.removeItem(COOK_LOG_KEY)
}
function isBookmarked(id: number): boolean {
return bookmarks.value.some((b) => b.id === id)
}
function toggleBookmark(recipe: RecipeSuggestion) {
if (isBookmarked(recipe.id)) {
bookmarks.value = bookmarks.value.filter((b) => b.id !== recipe.id)
} else {
bookmarks.value = [recipe, ...bookmarks.value]
}
saveBookmarks(bookmarks.value)
}
function clearBookmarks() {
bookmarks.value = []
localStorage.removeItem(BOOKMARKS_KEY)
}
function clearResult() {
result.value = null
error.value = null
@ -162,9 +235,17 @@ export const useRecipesStore = defineStore('recipes', () => {
styleId,
category,
wildcardConfirmed,
shoppingMode,
nutritionFilters,
dismissedIds,
dismissedCount,
cookLog,
logCook,
clearCookLog,
bookmarks,
isBookmarked,
toggleBookmark,
clearBookmarks,
suggest,
loadMore,
dismiss,

View file

@ -0,0 +1,83 @@
/**
* Saved Recipes Store
*
* Manages bookmarked recipes, ratings, style tags, and collections.
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { savedRecipesAPI, type SavedRecipe, type RecipeCollection } from '../services/api'
export const useSavedRecipesStore = defineStore('savedRecipes', () => {
const saved = ref<SavedRecipe[]>([])
const collections = ref<RecipeCollection[]>([])
const loading = ref(false)
const sortBy = ref<'saved_at' | 'rating' | 'title'>('saved_at')
const activeCollectionId = ref<number | null>(null)
const savedIds = computed(() => new Set(saved.value.map((s) => s.recipe_id)))
function isSaved(recipeId: number): boolean {
return savedIds.value.has(recipeId)
}
function getSaved(recipeId: number): SavedRecipe | undefined {
return saved.value.find((s) => s.recipe_id === recipeId)
}
async function load() {
loading.value = true
try {
const [items, cols] = await Promise.all([
savedRecipesAPI.list({ sort_by: sortBy.value, collection_id: activeCollectionId.value ?? undefined }),
savedRecipesAPI.listCollections(),
])
saved.value = items
collections.value = cols
} finally {
loading.value = false
}
}
async function save(recipeId: number, notes?: string, rating?: number): Promise<SavedRecipe> {
const result = await savedRecipesAPI.save(recipeId, notes, rating)
const idx = saved.value.findIndex((s) => s.recipe_id === recipeId)
if (idx >= 0) {
saved.value = [...saved.value.slice(0, idx), result, ...saved.value.slice(idx + 1)]
} else {
saved.value = [result, ...saved.value]
}
return result
}
async function unsave(recipeId: number): Promise<void> {
await savedRecipesAPI.unsave(recipeId)
saved.value = saved.value.filter((s) => s.recipe_id !== recipeId)
}
async function update(recipeId: number, data: { notes?: string | null; rating?: number | null; style_tags?: string[] }): Promise<SavedRecipe> {
const result = await savedRecipesAPI.update(recipeId, data)
const idx = saved.value.findIndex((s) => s.recipe_id === recipeId)
if (idx >= 0) {
saved.value = [...saved.value.slice(0, idx), result, ...saved.value.slice(idx + 1)]
}
return result
}
async function createCollection(name: string, description?: string): Promise<RecipeCollection> {
const col = await savedRecipesAPI.createCollection(name, description)
collections.value = [...collections.value, col]
return col
}
async function deleteCollection(id: number): Promise<void> {
await savedRecipesAPI.deleteCollection(id)
collections.value = collections.value.filter((c) => c.id !== id)
if (activeCollectionId.value === id) activeCollectionId.value = null
}
return {
saved, collections, loading, sortBy, activeCollectionId,
savedIds, isSaved, getSaved,
load, save, unsave, update, createCollection, deleteCollection,
}
})

View file

@ -152,11 +152,15 @@ a:hover {
color: var(--color-primary-light);
}
html {
overflow-x: hidden; /* iOS Safari: html is the true scroll container — body alone isn't enough */
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow-x: hidden; /* prevent any element from expanding the mobile viewport */
overflow-x: hidden;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}

View file

@ -5,6 +5,37 @@
* Components should use these classes instead of custom styles where possible.
*/
/* ============================================
ACCESSIBILITY UTILITIES
============================================ */
/* Visually hidden but announced by screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Keyboard focus ring — shown only for keyboard navigation, not mouse/touch */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* Form inputs use a different focus treatment (border + shadow); suppress the ring */
.form-input:focus-visible,
.form-select:focus-visible,
.form-textarea:focus-visible {
outline: none;
}
/* ============================================
LAYOUT UTILITIES - RESPONSIVE GRIDS
============================================ */
@ -639,14 +670,20 @@
}
}
/* Urgency pulse — for items expiring very soon */
@keyframes urgencyPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.pulse-urgent {
animation: urgencyPulse 1.8s ease-in-out infinite;
/* ============================================
REDUCED MOTION global guard (WCAG 2.3.3)
All animations/transitions are suppressed when
the user has requested reduced motion. This
single rule covers every animation in this file.
Do NOT add urgency/pulse animations to Kiwi
see design policy in circuitforge-plans.
============================================ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ============================================

View file

@ -9,6 +9,10 @@ COMPOSE_FILE="compose.yml"
CLOUD_COMPOSE_FILE="compose.cloud.yml"
CLOUD_PROJECT="kiwi-cloud"
# Auto-include compose.override.yml when present (local dev extras, NAS mounts, etc.)
OVERRIDE_FLAG=""
[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml"
usage() {
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test"
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}"
@ -38,23 +42,23 @@ shift || true
case "$cmd" in
start)
docker compose -f "$COMPOSE_FILE" up -d --build
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
echo "Kiwi running → http://localhost:${WEB_PORT}"
;;
stop)
docker compose -f "$COMPOSE_FILE" down
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down
;;
restart)
docker compose -f "$COMPOSE_FILE" down
docker compose -f "$COMPOSE_FILE" up -d --build
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
echo "Kiwi running → http://localhost:${WEB_PORT}"
;;
status)
docker compose -f "$COMPOSE_FILE" ps
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG ps
;;
logs)
svc="${1:-}"
docker compose -f "$COMPOSE_FILE" logs -f ${svc}
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG logs -f ${svc}
;;
open)
xdg-open "http://localhost:${WEB_PORT}" 2>/dev/null \
@ -62,10 +66,10 @@ case "$cmd" in
|| echo "Open http://localhost:${WEB_PORT} in your browser"
;;
build)
docker compose -f "$COMPOSE_FILE" build --no-cache
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG build --no-cache
;;
test)
docker compose -f "$COMPOSE_FILE" run --rm api \
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG run --rm api \
conda run -n job-seeker pytest tests/ -v
;;

View file

@ -23,7 +23,7 @@ dependencies = [
"httpx>=0.27",
"requests>=2.31",
# CircuitForge shared scaffold
"circuitforge-core>=0.6.0",
"circuitforge-core>=0.8.0",
]
[tool.setuptools.packages.find]

View file

@ -1,21 +1,34 @@
"""Tests for the /feedback endpoints."""
"""Tests for the shared feedback router (circuitforge-core) mounted in kiwi."""
from __future__ import annotations
from collections.abc import Callable
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
from circuitforge_core.api.feedback import make_feedback_router
# ── /feedback/status ──────────────────────────────────────────────────────────
# ── Test app factory ──────────────────────────────────────────────────────────
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
app = FastAPI()
router = make_feedback_router(
repo="Circuit-Forge/kiwi",
product="kiwi",
demo_mode_fn=demo_mode_fn,
)
app.include_router(router, prefix="/api/v1/feedback")
return TestClient(app)
# ── /api/v1/feedback/status ───────────────────────────────────────────────────
def test_status_disabled_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
monkeypatch.delenv("DEMO_MODE", raising=False)
client = _make_client(demo_mode_fn=lambda: False)
res = client.get("/api/v1/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
@ -23,7 +36,7 @@ def test_status_disabled_when_no_token(monkeypatch):
def test_status_enabled_when_token_set(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
client = _make_client(demo_mode_fn=lambda: False)
res = client.get("/api/v1/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": True}
@ -31,16 +44,18 @@ def test_status_enabled_when_token_set(monkeypatch):
def test_status_disabled_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
demo = True
client = _make_client(demo_mode_fn=lambda: demo)
res = client.get("/api/v1/feedback/status")
assert res.status_code == 200
assert res.json() == {"enabled": False}
# ── POST /feedback ────────────────────────────────────────────────────────────
# ── POST /api/v1/feedback ─────────────────────────────────────────────────────
def test_submit_returns_503_when_no_token(monkeypatch):
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
client = _make_client(demo_mode_fn=lambda: False)
res = client.post("/api/v1/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
@ -49,8 +64,13 @@ def test_submit_returns_503_when_no_token(monkeypatch):
def test_submit_returns_403_in_demo_mode(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
res = client.post("/api/v1/feedback", json={
demo = False
client = _make_client(demo_mode_fn=lambda: demo)
# Confirm non-demo path isn't 403 (sanity), then flip demo flag
demo = True
client2 = _make_client(demo_mode_fn=lambda: demo)
res = client2.post("/api/v1/feedback", json={
"title": "Test", "description": "desc", "type": "bug",
})
assert res.status_code == 403
@ -58,10 +78,7 @@ def test_submit_returns_403_in_demo_mode(monkeypatch):
def test_submit_creates_issue(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setenv("FORGEJO_REPO", "Circuit-Forge/kiwi")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
# Mock the two Forgejo HTTP calls: label fetch + issue create
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = [
@ -72,10 +89,15 @@ def test_submit_creates_issue(monkeypatch):
issue_response = MagicMock()
issue_response.ok = True
issue_response.json.return_value = {"number": 42, "html_url": "https://example.com/issues/42"}
issue_response.json.return_value = {
"number": 42,
"html_url": "https://example.com/issues/42",
}
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
patch("app.api.endpoints.feedback.requests.post", return_value=issue_response):
client = _make_client(demo_mode_fn=lambda: False)
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
patch("circuitforge_core.api.feedback.requests.post", return_value=issue_response):
res = client.post("/api/v1/feedback", json={
"title": "Something broke",
"description": "It broke when I tapped X",
@ -92,18 +114,23 @@ def test_submit_creates_issue(monkeypatch):
def test_submit_returns_502_on_forgejo_error(monkeypatch):
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
label_response = MagicMock()
label_response.ok = True
label_response.json.return_value = []
label_response.json.return_value = [
{"id": 1, "name": "beta-feedback"},
{"id": 2, "name": "needs-triage"},
{"id": 3, "name": "question"},
]
bad_response = MagicMock()
bad_response.ok = False
bad_response.text = "forbidden"
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
patch("app.api.endpoints.feedback.requests.post", return_value=bad_response):
client = _make_client(demo_mode_fn=lambda: False)
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
patch("circuitforge_core.api.feedback.requests.post", return_value=bad_response):
res = client.post("/api/v1/feedback", json={
"title": "Oops", "description": "desc", "type": "other",
})

112
tests/test_household.py Normal file
View file

@ -0,0 +1,112 @@
"""Tests for household session resolution in cloud_session.py."""
import os
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("CLOUD_MODE", "false")
import app.cloud_session as cs
from app.cloud_session import (
CloudUser,
_user_db_path,
)
def test_clouduser_has_household_fields():
u = CloudUser(
user_id="u1", tier="premium", db=Path("/tmp/u1.db"),
has_byok=False, household_id="hh-1", is_household_owner=True
)
assert u.household_id == "hh-1"
assert u.is_household_owner is True
def test_clouduser_household_defaults_none():
u = CloudUser(user_id="u1", tier="free", db=Path("/tmp/u1.db"), has_byok=False)
assert u.household_id is None
assert u.is_household_owner is False
def test_user_db_path_personal(tmp_path, monkeypatch):
monkeypatch.setattr(cs, "CLOUD_DATA_ROOT", tmp_path)
result = cs._user_db_path("abc123")
assert result == tmp_path / "abc123" / "kiwi.db"
def test_user_db_path_household(tmp_path, monkeypatch):
monkeypatch.setattr(cs, "CLOUD_DATA_ROOT", tmp_path)
result = cs._user_db_path("abc123", household_id="hh-xyz")
assert result == tmp_path / "household_hh-xyz" / "kiwi.db"
# ── Integration tests (require router) ─────────────────────────────────
def test_create_household_requires_premium():
"""Non-premium users cannot create a household."""
from app.main import app
from app.cloud_session import get_session
import tempfile, pathlib
db = pathlib.Path(tempfile.mktemp(suffix=".db"))
from app.db.store import Store
Store(str(db))
free_user = CloudUser(user_id="u1", tier="free", db=db, has_byok=False)
app.dependency_overrides[get_session] = lambda: free_user
client = TestClient(app)
resp = client.post("/api/v1/household/create")
assert resp.status_code == 403
app.dependency_overrides.clear()
def test_invite_generates_token():
"""Invite endpoint returns a token and URL for owner in a household."""
from app.main import app
from app.cloud_session import get_session
import tempfile, pathlib
db = pathlib.Path(tempfile.mktemp(suffix=".db"))
from app.db.store import Store
Store(str(db))
session = CloudUser(
user_id="owner-1", tier="premium", db=db,
has_byok=False, household_id="hh-test", is_household_owner=True
)
app.dependency_overrides[get_session] = lambda: session
client = TestClient(app)
resp = client.post("/api/v1/household/invite")
assert resp.status_code == 200
data = resp.json()
assert "token" in data
assert "invite_url" in data
assert len(data["token"]) == 64 # 32 bytes hex
app.dependency_overrides.clear()
def test_accept_invalid_token_returns_404(tmp_path, monkeypatch):
"""Accepting a non-existent token returns 404."""
import app.api.endpoints.household as hh_ep
import app.cloud_session as cs
monkeypatch.setattr(hh_ep, "CLOUD_DATA_ROOT", tmp_path)
monkeypatch.setattr(cs, "CLOUD_DATA_ROOT", tmp_path)
from app.main import app
from app.cloud_session import get_session
import tempfile, pathlib
db = pathlib.Path(tempfile.mktemp(suffix=".db"))
from app.db.store import Store
Store(str(db))
session = CloudUser(user_id="new-user", tier="free", db=db, has_byok=False)
app.dependency_overrides[get_session] = lambda: session
client = TestClient(app)
resp = client.post("/api/v1/household/accept", json={
"household_id": "hh-test",
"token": "deadbeef" * 8,
})
assert resp.status_code == 404
app.dependency_overrides.clear()