feat(tasks): add background task scheduler for LLM expiry fallback #6
126 changed files with 1395 additions and 14378 deletions
48
.env.example
48
.env.example
|
|
@ -11,33 +11,6 @@ DATA_DIR=./data
|
||||||
# Database (defaults to DATA_DIR/kiwi.db)
|
# Database (defaults to DATA_DIR/kiwi.db)
|
||||||
# DB_PATH=./data/kiwi.db
|
# DB_PATH=./data/kiwi.db
|
||||||
|
|
||||||
# Pipeline data directory for downloaded parquets (used by download_datasets.py)
|
|
||||||
# Override to store large datasets on a separate drive or NAS
|
|
||||||
# KIWI_PIPELINE_DATA_DIR=./data/pipeline
|
|
||||||
|
|
||||||
# CF-core resource coordinator (VRAM lease management)
|
|
||||||
# Set to the coordinator URL when running alongside cf-core orchestration
|
|
||||||
# COORDINATOR_URL=http://localhost:7700
|
|
||||||
# IP this machine advertises to the coordinator (must be reachable from coordinator host)
|
|
||||||
# CF_ORCH_ADVERTISE_HOST=10.1.10.71
|
|
||||||
|
|
||||||
# CF-core hosted coordinator (managed cloud GPU inference — Paid+ tier)
|
|
||||||
# Set CF_ORCH_URL to use a hosted cf-orch coordinator instead of self-hosting.
|
|
||||||
# CF_LICENSE_KEY is read automatically by CFOrchClient for bearer auth.
|
|
||||||
# CF_ORCH_URL=https://orch.circuitforge.tech
|
|
||||||
# CF_LICENSE_KEY=CFG-KIWI-xxxx-xxxx-xxxx
|
|
||||||
|
|
||||||
# LLM backend — env-var auto-config (no llm.yaml needed for bare-metal users)
|
|
||||||
# LLMRouter checks these in priority order:
|
|
||||||
# 1. Anthropic cloud — set ANTHROPIC_API_KEY
|
|
||||||
# 2. OpenAI cloud — set OPENAI_API_KEY
|
|
||||||
# 3. Local Ollama — set OLLAMA_HOST (+ optionally OLLAMA_MODEL)
|
|
||||||
# All three are optional; leave unset to rely on a local llm.yaml instead.
|
|
||||||
# ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
# OPENAI_API_KEY=sk-...
|
|
||||||
# OLLAMA_HOST=http://localhost:11434
|
|
||||||
# OLLAMA_MODEL=llama3.2
|
|
||||||
|
|
||||||
# Processing
|
# Processing
|
||||||
USE_GPU=true
|
USE_GPU=true
|
||||||
GPU_MEMORY_LIMIT=6144
|
GPU_MEMORY_LIMIT=6144
|
||||||
|
|
@ -55,14 +28,6 @@ DEMO_MODE=false
|
||||||
# Cloud mode (set in compose.cloud.yml; also set here for reference)
|
# Cloud mode (set in compose.cloud.yml; also set here for reference)
|
||||||
# CLOUD_DATA_ROOT=/devl/kiwi-cloud-data
|
# CLOUD_DATA_ROOT=/devl/kiwi-cloud-data
|
||||||
# KIWI_DB=data/kiwi.db # local-mode DB path override
|
# KIWI_DB=data/kiwi.db # local-mode DB path override
|
||||||
# DEV ONLY: bypass JWT auth for these IPs/CIDRs (LAN testing without Caddy in the path).
|
|
||||||
# NEVER set in production.
|
|
||||||
# IMPORTANT: Docker port mapping NATs source IPs to the bridge gateway. When hitting
|
|
||||||
# localhost:8515 (host → Docker → nginx → API), nginx sees 192.168.80.1, not 127.0.0.1.
|
|
||||||
# Include the Docker bridge CIDR to allow localhost and LAN access through nginx.
|
|
||||||
# Run: docker network inspect kiwi-cloud_kiwi-cloud-net | grep Subnet
|
|
||||||
# Example: CLOUD_AUTH_BYPASS_IPS=10.1.10.0/24,127.0.0.1,::1,192.168.80.0/20
|
|
||||||
# CLOUD_AUTH_BYPASS_IPS=
|
|
||||||
|
|
||||||
# Heimdall license server (required for cloud tier resolution)
|
# Heimdall license server (required for cloud tier resolution)
|
||||||
# HEIMDALL_URL=https://license.circuitforge.tech
|
# HEIMDALL_URL=https://license.circuitforge.tech
|
||||||
|
|
@ -70,16 +35,3 @@ DEMO_MODE=false
|
||||||
|
|
||||||
# Directus JWT (must match cf-directus SECRET env var)
|
# Directus JWT (must match cf-directus SECRET env var)
|
||||||
# DIRECTUS_JWT_SECRET=
|
# DIRECTUS_JWT_SECRET=
|
||||||
|
|
||||||
# In-app feedback → Forgejo issue creation
|
|
||||||
# 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=
|
|
||||||
|
|
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -1,7 +1,4 @@
|
||||||
|
|
||||||
# CLAUDE.md — gitignored per BSL 1.1 commercial policy
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# Superpowers brainstorming artifacts
|
# Superpowers brainstorming artifacts
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
|
@ -22,9 +19,3 @@ dist/
|
||||||
|
|
||||||
# Data directories
|
# Data directories
|
||||||
data/
|
data/
|
||||||
|
|
||||||
# Test artifacts (MagicMock sqlite files from pytest)
|
|
||||||
<MagicMock*
|
|
||||||
|
|
||||||
# Playwright / debug screenshots
|
|
||||||
debug-screenshots/
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Findings suppressed here are historical false positives or already-rotated secrets.
|
|
||||||
# .env was accidentally included in the initial commit; it is now gitignored.
|
|
||||||
# Rotate DIRECTUS_JWT_SECRET if it has not been changed since 2026-03-30.
|
|
||||||
|
|
||||||
# c166e5216 (chore: initial commit) — .env included by mistake
|
|
||||||
c166e5216af532a08112ef87e8542cd51c184115:.env:generic-api-key:25
|
|
||||||
c166e5216af532a08112ef87e8542cd51c184115:.env:cf-generic-env-token:25
|
|
||||||
28
LICENSE-BSL
28
LICENSE-BSL
|
|
@ -1,28 +0,0 @@
|
||||||
Business Source License 1.1
|
|
||||||
|
|
||||||
Licensor: Circuit Forge LLC
|
|
||||||
Licensed Work: Kiwi — Pantry tracking and leftover recipe suggestions
|
|
||||||
Copyright (c) 2026 Circuit Forge LLC
|
|
||||||
Additional Use Grant: You may use the Licensed Work for personal,
|
|
||||||
non-commercial pantry tracking and recipe suggestion
|
|
||||||
purposes only.
|
|
||||||
Change Date: 2030-01-01
|
|
||||||
Change License: MIT License
|
|
||||||
|
|
||||||
For the full Business Source License 1.1 text, see:
|
|
||||||
https://mariadb.com/bsl11/
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This license applies to the following components of Kiwi:
|
|
||||||
|
|
||||||
- app/services/recipe/recipe_engine.py
|
|
||||||
- app/services/recipe/assembly_recipes.py
|
|
||||||
- app/services/recipe/llm_recipe.py
|
|
||||||
- app/services/expiration_predictor.py
|
|
||||||
- app/tasks/scheduler.py
|
|
||||||
- app/tasks/runner.py
|
|
||||||
- app/tiers.py
|
|
||||||
- app/cloud_session.py
|
|
||||||
- frontend/src/components/RecipesView.vue
|
|
||||||
- frontend/src/stores/recipes.ts
|
|
||||||
34
LICENSE-MIT
34
LICENSE-MIT
|
|
@ -1,34 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Circuit Forge LLC
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This license applies to the following components of Kiwi:
|
|
||||||
|
|
||||||
- app/api/endpoints/inventory.py
|
|
||||||
- app/api/endpoints/ocr.py
|
|
||||||
- app/db/store.py
|
|
||||||
- app/db/migrations/
|
|
||||||
- app/core/config.py
|
|
||||||
- scripts/pipeline/
|
|
||||||
- scripts/download_datasets.py
|
|
||||||
- scripts/backfill_texture_profiles.py
|
|
||||||
20
README.md
20
README.md
|
|
@ -6,9 +6,7 @@
|
||||||
|
|
||||||
Scan barcodes, photograph receipts, and get recipe ideas based on what you already have — before it expires.
|
Scan barcodes, photograph receipts, and get recipe ideas based on what you already have — before it expires.
|
||||||
|
|
||||||
**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:** Pre-alpha · CircuitForge LLC
|
||||||
|
|
||||||
**Status:** Beta · CircuitForge LLC
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -16,14 +14,9 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea
|
||||||
|
|
||||||
- **Inventory tracking** — add items by barcode scan, receipt upload, or manually
|
- **Inventory tracking** — add items by barcode scan, receipt upload, or manually
|
||||||
- **Expiry alerts** — know what's about to go bad
|
- **Expiry alerts** — know what's about to go bad
|
||||||
- **Recipe browser** — browse the full recipe corpus by cuisine, meal type, dietary preference, or main ingredient; pantry match percentage shown inline (Free)
|
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier)
|
||||||
- **Saved recipes** — bookmark any recipe with notes, a 0–5 star rating, and free-text style tags (Free); organize into named collections (Paid)
|
- **Recipe suggestions** — LLM-powered ideas based on what's expiring (Paid tier, BYOK-unlockable)
|
||||||
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable)
|
|
||||||
- **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable)
|
|
||||||
- **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable)
|
|
||||||
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier)
|
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (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
|
## Stack
|
||||||
|
|
||||||
|
|
@ -59,13 +52,8 @@ cp .env.example .env
|
||||||
| Receipt upload | ✓ | ✓ | ✓ |
|
| Receipt upload | ✓ | ✓ | ✓ |
|
||||||
| Expiry alerts | ✓ | ✓ | ✓ |
|
| Expiry alerts | ✓ | ✓ | ✓ |
|
||||||
| CSV export | ✓ | ✓ | ✓ |
|
| CSV export | ✓ | ✓ | ✓ |
|
||||||
| Recipe browser (domain/category) | ✓ | ✓ | ✓ |
|
|
||||||
| Save recipes + notes + star rating | ✓ | ✓ | ✓ |
|
|
||||||
| Style tags (manual, free-text) | ✓ | ✓ | ✓ |
|
|
||||||
| Receipt OCR | BYOK | ✓ | ✓ |
|
| Receipt OCR | BYOK | ✓ | ✓ |
|
||||||
| Recipe suggestions (L1–L4) | BYOK | ✓ | ✓ |
|
| Recipe suggestions | BYOK | ✓ | ✓ |
|
||||||
| Named recipe collections | — | ✓ | ✓ |
|
|
||||||
| LLM style auto-classifier | — | BYOK | ✓ |
|
|
||||||
| Meal planning | — | ✓ | ✓ |
|
| Meal planning | — | ✓ | ✓ |
|
||||||
| Multi-household | — | — | ✓ |
|
| Multi-household | — | — | ✓ |
|
||||||
| Leftover mode | — | — | ✓ |
|
| Leftover mode | — | — | ✓ |
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@
|
||||||
Kiwi: Pantry tracking and leftover recipe suggestions.
|
Kiwi: Pantry tracking and leftover recipe suggestions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.1.0"
|
||||||
__author__ = "Alan 'pyr0ball' Weinstock"
|
__author__ = "Alan 'pyr0ball' Weinstock"
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
"""Feedback router — provided by circuitforge-core."""
|
|
||||||
from circuitforge_core.api import make_feedback_router
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
router = make_feedback_router(
|
|
||||||
repo="Circuit-Forge/kiwi",
|
|
||||||
product="kiwi",
|
|
||||||
demo_mode_fn=lambda: settings.DEMO_MODE,
|
|
||||||
)
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
"""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.")
|
|
||||||
|
|
@ -16,9 +16,6 @@ from app.db.session import get_store
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.inventory import (
|
from app.models.schemas.inventory import (
|
||||||
BarcodeScanResponse,
|
BarcodeScanResponse,
|
||||||
BulkAddByNameRequest,
|
|
||||||
BulkAddByNameResponse,
|
|
||||||
BulkAddItemResult,
|
|
||||||
InventoryItemCreate,
|
InventoryItemCreate,
|
||||||
InventoryItemResponse,
|
InventoryItemResponse,
|
||||||
InventoryItemUpdate,
|
InventoryItemUpdate,
|
||||||
|
|
@ -133,34 +130,6 @@ async def create_inventory_item(body: InventoryItemCreate, store: Store = Depend
|
||||||
return InventoryItemResponse.model_validate(item)
|
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])
|
@router.get("/items", response_model=List[InventoryItemResponse])
|
||||||
async def list_inventory_items(
|
async def list_inventory_items(
|
||||||
location: Optional[str] = None,
|
location: Optional[str] = None,
|
||||||
|
|
@ -400,23 +369,6 @@ async def list_tags(
|
||||||
|
|
||||||
# ── Stats ─────────────────────────────────────────────────────────────────────
|
# ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/recalculate-expiry")
|
|
||||||
async def recalculate_expiry(
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> dict:
|
|
||||||
"""Re-run the expiration predictor over all available inventory items.
|
|
||||||
|
|
||||||
Uses each item's stored purchase_date and current location. Safe to call
|
|
||||||
multiple times — idempotent per session.
|
|
||||||
"""
|
|
||||||
def _run(s: Store) -> tuple[int, int]:
|
|
||||||
return s.recalculate_expiry(tier=session.tier, has_byok=session.has_byok)
|
|
||||||
|
|
||||||
updated, skipped = await asyncio.to_thread(_run, store)
|
|
||||||
return {"updated": updated, "skipped": skipped}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=InventoryStats)
|
@router.get("/stats", response_model=InventoryStats)
|
||||||
async def get_inventory_stats(store: Store = Depends(get_store)):
|
async def get_inventory_stats(store: Store = Depends(get_store)):
|
||||||
def _stats():
|
def _stats():
|
||||||
|
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
"""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, 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
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
|
||||||
"""Run recipe suggestion in a worker thread with its own Store connection.
|
|
||||||
|
|
||||||
SQLite connections cannot be shared across threads. This function creates
|
|
||||||
a fresh Store (and therefore a fresh sqlite3.Connection) in the same thread
|
|
||||||
where it will be used, avoiding ProgrammingError: SQLite objects created in
|
|
||||||
a thread can only be used in that same thread.
|
|
||||||
"""
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return RecipeEngine(store).suggest(req)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suggest", response_model=RecipeResult)
|
|
||||||
async def suggest_recipes(
|
|
||||||
req: RecipeRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> RecipeResult:
|
|
||||||
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
|
||||||
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
|
|
||||||
if req.level == 4 and not req.wildcard_confirmed:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Level 4 (Wildcard) requires wildcard_confirmed=true.",
|
|
||||||
)
|
|
||||||
if req.level in (3, 4) and not can_use("recipe_suggestions", req.tier, req.has_byok):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="LLM recipe levels require Paid tier or a configured LLM backend.",
|
|
||||||
)
|
|
||||||
if req.style_id and not can_use("style_picker", req.tier):
|
|
||||||
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
|
|
||||||
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return store.get_recipe(rid)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
|
||||||
if not recipe:
|
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
|
||||||
return recipe
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
"""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),
|
|
||||||
)
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
"""User settings endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_ALLOWED_KEYS = frozenset({"cooking_equipment"})
|
|
||||||
|
|
||||||
|
|
||||||
class SettingBody(BaseModel):
|
|
||||||
value: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{key}")
|
|
||||||
async def get_setting(
|
|
||||||
key: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> dict:
|
|
||||||
"""Return the stored value for a settings key."""
|
|
||||||
if key not in _ALLOWED_KEYS:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Unknown settings key: '{key}'.")
|
|
||||||
value = store.get_setting(key)
|
|
||||||
if value is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found.")
|
|
||||||
return {"key": key, "value": value}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{key}")
|
|
||||||
async def set_setting(
|
|
||||||
key: str,
|
|
||||||
body: SettingBody,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> dict:
|
|
||||||
"""Upsert a settings key-value pair."""
|
|
||||||
if key not in _ALLOWED_KEYS:
|
|
||||||
raise HTTPException(status_code=422, detail=f"Unknown settings key: '{key}'.")
|
|
||||||
store.set_setting(key, body.value)
|
|
||||||
return {"key": key, "value": body.value}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
"""Staple library endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
|
|
||||||
from app.services.recipe.staple_library import StapleLibrary
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
_lib = StapleLibrary()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
|
||||||
async def list_staples(dietary: str | None = None) -> list[dict]:
|
|
||||||
staples = _lib.filter_by_dietary(dietary) if dietary else _lib.list_all()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"slug": s.slug,
|
|
||||||
"name": s.name,
|
|
||||||
"description": s.description,
|
|
||||||
"dietary_labels": s.dietary_labels,
|
|
||||||
"yield_formats": list(s.yield_formats.keys()),
|
|
||||||
}
|
|
||||||
for s in staples
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{slug}")
|
|
||||||
async def get_staple(slug: str) -> dict:
|
|
||||||
staple = _lib.get(slug)
|
|
||||||
if not staple:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Staple '{slug}' not found.")
|
|
||||||
return {
|
|
||||||
"slug": staple.slug,
|
|
||||||
"name": staple.name,
|
|
||||||
"description": staple.description,
|
|
||||||
"dietary_labels": staple.dietary_labels,
|
|
||||||
"base_ingredients": staple.base_ingredients,
|
|
||||||
"base_method": staple.base_method,
|
|
||||||
"base_time_minutes": staple.base_time_minutes,
|
|
||||||
"yield_formats": staple.yield_formats,
|
|
||||||
"compatible_styles": staple.compatible_styles,
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes
|
from app.api.endpoints import health, receipts, export, inventory, ocr
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
|
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
|
||||||
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
|
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"]) # OCR endpoints under /receipts
|
||||||
api_router.include_router(export.router, tags=["export"])
|
api_router.include_router(export.router, tags=["export"]) # No prefix, uses /export in the router
|
||||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||||
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
|
||||||
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
|
||||||
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
|
||||||
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
|
||||||
|
|
@ -37,46 +37,9 @@ DIRECTUS_JWT_SECRET: str = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||||
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
|
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
|
||||||
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
|
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
|
||||||
|
|
||||||
# Dev bypass: comma-separated IPs or CIDR ranges that skip JWT auth.
|
|
||||||
# NEVER set this in production. Intended only for LAN developer testing when
|
|
||||||
# the request doesn't pass through Caddy (which normally injects X-CF-Session).
|
|
||||||
# Example: CLOUD_AUTH_BYPASS_IPS=10.1.10.0/24,127.0.0.1
|
|
||||||
import ipaddress as _ipaddress
|
|
||||||
|
|
||||||
_BYPASS_RAW: list[str] = [
|
|
||||||
e.strip()
|
|
||||||
for e in os.environ.get("CLOUD_AUTH_BYPASS_IPS", "").split(",")
|
|
||||||
if e.strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
_BYPASS_NETS: list[_ipaddress.IPv4Network | _ipaddress.IPv6Network] = []
|
|
||||||
_BYPASS_IPS: frozenset[str] = frozenset()
|
|
||||||
|
|
||||||
if _BYPASS_RAW:
|
|
||||||
_nets, _ips = [], set()
|
|
||||||
for entry in _BYPASS_RAW:
|
|
||||||
try:
|
|
||||||
_nets.append(_ipaddress.ip_network(entry, strict=False))
|
|
||||||
except ValueError:
|
|
||||||
_ips.add(entry) # treat non-parseable entries as bare IPs
|
|
||||||
_BYPASS_NETS = _nets
|
|
||||||
_BYPASS_IPS = frozenset(_ips)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_bypass_ip(ip: str) -> bool:
|
|
||||||
if not ip:
|
|
||||||
return False
|
|
||||||
if ip in _BYPASS_IPS:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
addr = _ipaddress.ip_address(ip)
|
|
||||||
return any(addr in net for net in _BYPASS_NETS)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
||||||
|
|
||||||
_TIER_CACHE: dict[str, tuple[dict, float]] = {}
|
_TIER_CACHE: dict[str, tuple[str, float]] = {}
|
||||||
_TIER_CACHE_TTL = 300 # 5 minutes
|
_TIER_CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
TIERS = ["free", "paid", "premium", "ultra"]
|
TIERS = ["free", "paid", "premium", "ultra"]
|
||||||
|
|
@ -90,8 +53,6 @@ class CloudUser:
|
||||||
tier: str # free | paid | premium | ultra | local
|
tier: str # free | paid | premium | ultra | local
|
||||||
db: Path # per-user SQLite DB path
|
db: Path # per-user SQLite DB path
|
||||||
has_byok: bool # True if a configured LLM backend is present in llm.yaml
|
has_byok: bool # True if a configured LLM backend is present in llm.yaml
|
||||||
household_id: str | None = None
|
|
||||||
is_household_owner: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
# ── JWT validation ─────────────────────────────────────────────────────────────
|
# ── JWT validation ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -132,16 +93,14 @@ def _ensure_provisioned(user_id: str) -> None:
|
||||||
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
|
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]:
|
def _fetch_cloud_tier(user_id: str) -> str:
|
||||||
"""Returns (tier, household_id | None, is_household_owner)."""
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
cached = _TIER_CACHE.get(user_id)
|
cached = _TIER_CACHE.get(user_id)
|
||||||
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
|
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
|
||||||
entry = cached[0]
|
return cached[0]
|
||||||
return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False)
|
|
||||||
|
|
||||||
if not HEIMDALL_ADMIN_TOKEN:
|
if not HEIMDALL_ADMIN_TOKEN:
|
||||||
return "free", None, False
|
return "free"
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{HEIMDALL_URL}/admin/cloud/resolve",
|
f"{HEIMDALL_URL}/admin/cloud/resolve",
|
||||||
|
|
@ -149,22 +108,16 @@ def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]:
|
||||||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
data = resp.json() if resp.ok else {}
|
tier = resp.json().get("tier", "free") if resp.ok else "free"
|
||||||
tier = data.get("tier", "free")
|
|
||||||
household_id = data.get("household_id")
|
|
||||||
is_owner = data.get("is_household_owner", False)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
|
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
|
||||||
tier, household_id, is_owner = "free", None, False
|
tier = "free"
|
||||||
|
|
||||||
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner}, now)
|
_TIER_CACHE[user_id] = (tier, now)
|
||||||
return tier, household_id, is_owner
|
return tier
|
||||||
|
|
||||||
|
|
||||||
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
def _user_db_path(user_id: str) -> Path:
|
||||||
if household_id:
|
|
||||||
path = CLOUD_DATA_ROOT / f"household_{household_id}" / "kiwi.db"
|
|
||||||
else:
|
|
||||||
path = CLOUD_DATA_ROOT / user_id / "kiwi.db"
|
path = CLOUD_DATA_ROOT / user_id / "kiwi.db"
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
@ -200,26 +153,12 @@ def get_session(request: Request) -> CloudUser:
|
||||||
|
|
||||||
Local mode: fully-privileged "local" user pointing at local DB.
|
Local mode: fully-privileged "local" user pointing at local DB.
|
||||||
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
|
||||||
Dev bypass: if CLOUD_AUTH_BYPASS_IPS is set and the client IP matches,
|
|
||||||
returns a "local" session without JWT validation (dev/LAN use only).
|
|
||||||
"""
|
"""
|
||||||
has_byok = _detect_byok()
|
has_byok = _detect_byok()
|
||||||
|
|
||||||
if not CLOUD_MODE:
|
if not CLOUD_MODE:
|
||||||
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
|
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).
|
|
||||||
client_ip = (
|
|
||||||
request.headers.get("x-real-ip", "")
|
|
||||||
or (request.client.host if request.client else "")
|
|
||||||
)
|
|
||||||
if (_BYPASS_IPS or _BYPASS_NETS) and _is_bypass_ip(client_ip):
|
|
||||||
log.debug("CLOUD_AUTH_BYPASS_IPS match for %s — returning local session", client_ip)
|
|
||||||
# Use a dev DB under CLOUD_DATA_ROOT so the container has a writable path.
|
|
||||||
dev_db = _user_db_path("local-dev")
|
|
||||||
return CloudUser(user_id="local-dev", tier="local", db=dev_db, has_byok=has_byok)
|
|
||||||
|
|
||||||
raw_header = (
|
raw_header = (
|
||||||
request.headers.get("x-cf-session", "")
|
request.headers.get("x-cf-session", "")
|
||||||
or request.headers.get("cookie", "")
|
or request.headers.get("cookie", "")
|
||||||
|
|
@ -227,21 +166,14 @@ def get_session(request: Request) -> CloudUser:
|
||||||
if not raw_header:
|
if not raw_header:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
token = _extract_session_token(raw_header) # gitleaks:allow — function name, not a secret
|
token = _extract_session_token(raw_header)
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
user_id = validate_session_jwt(token)
|
user_id = validate_session_jwt(token)
|
||||||
_ensure_provisioned(user_id)
|
_ensure_provisioned(user_id)
|
||||||
tier, household_id, is_household_owner = _fetch_cloud_tier(user_id)
|
tier = _fetch_cloud_tier(user_id)
|
||||||
return CloudUser(
|
return CloudUser(user_id=user_id, tier=tier, db=_user_db_path(user_id), has_byok=has_byok)
|
||||||
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):
|
def require_tier(min_tier: str):
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,6 @@ class Settings:
|
||||||
# Quality
|
# Quality
|
||||||
MIN_QUALITY_SCORE: float = float(os.environ.get("MIN_QUALITY_SCORE", "50.0"))
|
MIN_QUALITY_SCORE: float = float(os.environ.get("MIN_QUALITY_SCORE", "50.0"))
|
||||||
|
|
||||||
# CF-core resource coordinator (VRAM lease management)
|
|
||||||
COORDINATOR_URL: str = os.environ.get("COORDINATOR_URL", "http://localhost:7700")
|
|
||||||
|
|
||||||
# Hosted cf-orch coordinator — bearer token for managed cloud GPU inference (Paid+)
|
|
||||||
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
|
||||||
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
|
||||||
|
|
||||||
# Feature flags
|
# Feature flags
|
||||||
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ CREATE TABLE receipts_new (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
original_path TEXT NOT NULL,
|
original_path TEXT NOT NULL,
|
||||||
processed_path TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'uploaded'
|
status TEXT NOT NULL DEFAULT 'uploaded'
|
||||||
CHECK (status IN (
|
CHECK (status IN (
|
||||||
'uploaded',
|
'uploaded',
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
-- Migration 006: Ingredient element profiles + FlavorGraph molecule index.
|
|
||||||
|
|
||||||
CREATE TABLE ingredient_profiles (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
name_variants TEXT NOT NULL DEFAULT '[]', -- JSON array of aliases/alternate spellings
|
|
||||||
elements TEXT NOT NULL DEFAULT '[]', -- JSON array: ["Richness","Depth"]
|
|
||||||
-- Functional submetadata (from USDA FDC)
|
|
||||||
fat_pct REAL DEFAULT 0.0,
|
|
||||||
fat_saturated_pct REAL DEFAULT 0.0,
|
|
||||||
moisture_pct REAL DEFAULT 0.0,
|
|
||||||
protein_pct REAL DEFAULT 0.0,
|
|
||||||
starch_pct REAL DEFAULT 0.0,
|
|
||||||
binding_score INTEGER DEFAULT 0 CHECK (binding_score BETWEEN 0 AND 3),
|
|
||||||
glutamate_mg REAL DEFAULT 0.0,
|
|
||||||
ph_estimate REAL,
|
|
||||||
sodium_mg_per_100g REAL DEFAULT 0.0,
|
|
||||||
smoke_point_c REAL,
|
|
||||||
is_fermented INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_emulsifier INTEGER NOT NULL DEFAULT 0,
|
|
||||||
-- Aroma submetadata
|
|
||||||
flavor_molecule_ids TEXT NOT NULL DEFAULT '[]', -- JSON array of FlavorGraph compound IDs
|
|
||||||
heat_stable INTEGER NOT NULL DEFAULT 1,
|
|
||||||
add_timing TEXT NOT NULL DEFAULT 'any'
|
|
||||||
CHECK (add_timing IN ('early','finish','any')),
|
|
||||||
-- Brightness submetadata
|
|
||||||
acid_type TEXT CHECK (acid_type IN ('citric','acetic','lactic',NULL)),
|
|
||||||
-- Texture submetadata
|
|
||||||
texture_profile TEXT NOT NULL DEFAULT 'neutral',
|
|
||||||
water_activity REAL,
|
|
||||||
-- Source
|
|
||||||
usda_fdc_id TEXT,
|
|
||||||
source TEXT NOT NULL DEFAULT 'usda',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX idx_ingredient_profiles_name ON ingredient_profiles (name);
|
|
||||||
CREATE INDEX idx_ingredient_profiles_elements ON ingredient_profiles (elements);
|
|
||||||
|
|
||||||
CREATE TABLE flavor_molecules (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
compound_id TEXT NOT NULL UNIQUE, -- FlavorGraph node ID
|
|
||||||
compound_name TEXT NOT NULL,
|
|
||||||
ingredient_names TEXT NOT NULL DEFAULT '[]', -- JSON array of ingredient names
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_flavor_molecules_compound_id ON flavor_molecules (compound_id);
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- Migration 007: Recipe corpus index (food.com dataset).
|
|
||||||
|
|
||||||
CREATE TABLE recipes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
external_id TEXT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
ingredients TEXT NOT NULL DEFAULT '[]', -- JSON array of raw ingredient strings
|
|
||||||
ingredient_names TEXT NOT NULL DEFAULT '[]', -- JSON array of normalized names
|
|
||||||
directions TEXT NOT NULL DEFAULT '[]', -- JSON array of step strings
|
|
||||||
category TEXT,
|
|
||||||
keywords TEXT NOT NULL DEFAULT '[]', -- JSON array
|
|
||||||
calories REAL,
|
|
||||||
fat_g REAL,
|
|
||||||
protein_g REAL,
|
|
||||||
sodium_mg REAL,
|
|
||||||
-- Element coverage scores computed at import time
|
|
||||||
element_coverage TEXT NOT NULL DEFAULT '{}', -- JSON {element: 0.0-1.0}
|
|
||||||
source TEXT NOT NULL DEFAULT 'foodcom',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_recipes_title ON recipes (title);
|
|
||||||
CREATE INDEX idx_recipes_category ON recipes (category);
|
|
||||||
CREATE UNIQUE INDEX idx_recipes_external_id ON recipes (external_id);
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
-- Migration 008: Derived substitution pairs.
|
|
||||||
-- Source: diff of lishuyang/recipepairs (GPL-3.0 derivation — raw data not shipped).
|
|
||||||
|
|
||||||
CREATE TABLE substitution_pairs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
original_name TEXT NOT NULL,
|
|
||||||
substitute_name TEXT NOT NULL,
|
|
||||||
constraint_label TEXT NOT NULL, -- 'vegan'|'vegetarian'|'dairy_free'|'gluten_free'|'low_fat'|'low_sodium'
|
|
||||||
fat_delta REAL DEFAULT 0.0,
|
|
||||||
moisture_delta REAL DEFAULT 0.0,
|
|
||||||
glutamate_delta REAL DEFAULT 0.0,
|
|
||||||
protein_delta REAL DEFAULT 0.0,
|
|
||||||
occurrence_count INTEGER DEFAULT 1,
|
|
||||||
compensation_hints TEXT NOT NULL DEFAULT '[]', -- JSON [{ingredient, reason, element}]
|
|
||||||
source TEXT NOT NULL DEFAULT 'derived',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_substitution_pairs_original ON substitution_pairs (original_name);
|
|
||||||
CREATE INDEX idx_substitution_pairs_constraint ON substitution_pairs (constraint_label);
|
|
||||||
CREATE UNIQUE INDEX idx_substitution_pairs_pair
|
|
||||||
ON substitution_pairs (original_name, substitute_name, constraint_label);
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
-- Migration 009: Staple library (bulk-preparable base components).
|
|
||||||
|
|
||||||
CREATE TABLE staples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
base_ingredients TEXT NOT NULL DEFAULT '[]', -- JSON array of ingredient strings
|
|
||||||
base_method TEXT,
|
|
||||||
base_time_minutes INTEGER,
|
|
||||||
yield_formats TEXT NOT NULL DEFAULT '{}', -- JSON {format_name: {elements, shelf_days, methods, texture}}
|
|
||||||
dietary_labels TEXT NOT NULL DEFAULT '[]', -- JSON ['vegan','high-protein']
|
|
||||||
compatible_styles TEXT NOT NULL DEFAULT '[]', -- JSON [style_id]
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE user_staples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
staple_slug TEXT NOT NULL REFERENCES staples(slug) ON DELETE CASCADE,
|
|
||||||
active_format TEXT NOT NULL,
|
|
||||||
quantity_g REAL,
|
|
||||||
prepared_at TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_user_staples_slug ON user_staples (staple_slug);
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
-- Migration 010: User substitution approval log (opt-in dataset moat).
|
|
||||||
|
|
||||||
CREATE TABLE substitution_feedback (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
original_name TEXT NOT NULL,
|
|
||||||
substitute_name TEXT NOT NULL,
|
|
||||||
constraint_label TEXT,
|
|
||||||
compensation_used TEXT NOT NULL DEFAULT '[]', -- JSON array of compensation ingredient names
|
|
||||||
approved INTEGER NOT NULL DEFAULT 0,
|
|
||||||
opted_in INTEGER NOT NULL DEFAULT 0, -- user consented to anonymized sharing
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_substitution_feedback_original ON substitution_feedback (original_name);
|
|
||||||
CREATE INDEX idx_substitution_feedback_opted_in ON substitution_feedback (opted_in);
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
-- Migration 011: Daily rate limits (leftover mode: 5/day free tier).
|
|
||||||
|
|
||||||
CREATE TABLE rate_limits (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
feature TEXT NOT NULL,
|
|
||||||
window_date TEXT NOT NULL, -- YYYY-MM-DD
|
|
||||||
count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
UNIQUE (feature, window_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_rate_limits_feature_date ON rate_limits (feature, window_date);
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
-- Migration 012: User settings key-value store.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
-- Migration 014: Add macro nutrition columns to recipes and ingredient_profiles.
|
|
||||||
--
|
|
||||||
-- recipes: sugar, carbs, fiber, servings, and an estimated flag.
|
|
||||||
-- ingredient_profiles: carbs, fiber, calories, sugar per 100g (for estimation fallback).
|
|
||||||
|
|
||||||
ALTER TABLE recipes ADD COLUMN sugar_g REAL;
|
|
||||||
ALTER TABLE recipes ADD COLUMN carbs_g REAL;
|
|
||||||
ALTER TABLE recipes ADD COLUMN fiber_g REAL;
|
|
||||||
ALTER TABLE recipes ADD COLUMN servings REAL;
|
|
||||||
ALTER TABLE recipes ADD COLUMN nutrition_estimated INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
ALTER TABLE ingredient_profiles ADD COLUMN carbs_g_per_100g REAL DEFAULT 0.0;
|
|
||||||
ALTER TABLE ingredient_profiles ADD COLUMN fiber_g_per_100g REAL DEFAULT 0.0;
|
|
||||||
ALTER TABLE ingredient_profiles ADD COLUMN calories_per_100g REAL DEFAULT 0.0;
|
|
||||||
ALTER TABLE ingredient_profiles ADD COLUMN sugar_g_per_100g REAL DEFAULT 0.0;
|
|
||||||
|
|
||||||
CREATE INDEX idx_recipes_sugar_g ON recipes (sugar_g);
|
|
||||||
CREATE INDEX idx_recipes_carbs_g ON recipes (carbs_g);
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
-- Migration 015: FTS5 inverted index for recipe ingredient lookup.
|
|
||||||
--
|
|
||||||
-- Content table backed by `recipes` — stores only the inverted index, no text duplication.
|
|
||||||
-- MATCH queries replace O(N) LIKE scans with O(log N) token lookups.
|
|
||||||
--
|
|
||||||
-- One-time rebuild cost on 3.2M rows: ~15-30 seconds at startup.
|
|
||||||
-- Subsequent startups skip this migration entirely.
|
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS recipes_fts USING fts5(
|
|
||||||
ingredient_names,
|
|
||||||
content=recipes,
|
|
||||||
content_rowid=id,
|
|
||||||
tokenize="unicode61"
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO recipes_fts(recipes_fts) VALUES('rebuild');
|
|
||||||
|
|
||||||
-- Triggers to keep the FTS index in sync with the recipes table.
|
|
||||||
-- Without these, rows inserted after the initial rebuild are invisible to FTS queries.
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_ai
|
|
||||||
AFTER INSERT ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(rowid, ingredient_names)
|
|
||||||
VALUES (new.id, new.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_ad
|
|
||||||
AFTER DELETE ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(recipes_fts, rowid, ingredient_names)
|
|
||||||
VALUES ('delete', old.id, old.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_au
|
|
||||||
AFTER UPDATE ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(recipes_fts, rowid, ingredient_names)
|
|
||||||
VALUES ('delete', old.id, old.ingredient_names);
|
|
||||||
INSERT INTO recipes_fts(rowid, ingredient_names)
|
|
||||||
VALUES (new.id, new.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
-- Migration 016: Add FTS5 sync triggers for the recipes_fts content table.
|
|
||||||
--
|
|
||||||
-- Migration 015 created recipes_fts and did a one-time rebuild, but omitted
|
|
||||||
-- triggers. Without them, INSERT/UPDATE/DELETE on recipes does not update the
|
|
||||||
-- FTS index, so new rows are invisible to MATCH queries.
|
|
||||||
--
|
|
||||||
-- CREATE TRIGGER IF NOT EXISTS is idempotent — safe to re-run.
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_ai
|
|
||||||
AFTER INSERT ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(rowid, ingredient_names)
|
|
||||||
VALUES (new.id, new.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_ad
|
|
||||||
AFTER DELETE ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(recipes_fts, rowid, ingredient_names)
|
|
||||||
VALUES ('delete', old.id, old.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipes_fts_au
|
|
||||||
AFTER UPDATE ON recipes BEGIN
|
|
||||||
INSERT INTO recipes_fts(recipes_fts, rowid, ingredient_names)
|
|
||||||
VALUES ('delete', old.id, old.ingredient_names);
|
|
||||||
INSERT INTO recipes_fts(rowid, ingredient_names)
|
|
||||||
VALUES (new.id, new.ingredient_names);
|
|
||||||
END;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- 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
|
|
||||||
);
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-- 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);
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
-- 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)
|
|
||||||
);
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
-- 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'))
|
|
||||||
);
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
-- Migration 021: FTS5 inverted index for the recipe browser (category + keywords).
|
|
||||||
--
|
|
||||||
-- The browser domain queries were using LIKE '%keyword%' against category and
|
|
||||||
-- keywords columns — a leading wildcard prevents any B-tree index use, so every
|
|
||||||
-- query was a full sequential scan of 3.1M rows. This FTS5 index replaces those
|
|
||||||
-- scans with O(log N) token lookups.
|
|
||||||
--
|
|
||||||
-- Content-table backed: stores only the inverted index, no text duplication.
|
|
||||||
-- The keywords column is a JSON array; FTS5 tokenises it as plain text, stripping
|
|
||||||
-- the punctuation, which gives correct per-word matching.
|
|
||||||
--
|
|
||||||
-- One-time rebuild cost on 3.1M rows: ~20-40 seconds at first startup.
|
|
||||||
-- Subsequent startups skip this migration (IF NOT EXISTS guard).
|
|
||||||
|
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS recipe_browser_fts USING fts5(
|
|
||||||
category,
|
|
||||||
keywords,
|
|
||||||
content=recipes,
|
|
||||||
content_rowid=id,
|
|
||||||
tokenize="unicode61"
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild');
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_ai
|
|
||||||
AFTER INSERT ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords)
|
|
||||||
VALUES (new.id, new.category, new.keywords);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_ad
|
|
||||||
AFTER DELETE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords);
|
|
||||||
END;
|
|
||||||
|
|
||||||
CREATE TRIGGER IF NOT EXISTS recipe_browser_fts_au
|
|
||||||
AFTER UPDATE ON recipes BEGIN
|
|
||||||
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords)
|
|
||||||
VALUES ('delete', old.id, old.category, old.keywords);
|
|
||||||
INSERT INTO recipe_browser_fts(rowid, category, keywords)
|
|
||||||
VALUES (new.id, new.category, new.keywords);
|
|
||||||
END;
|
|
||||||
751
app/db/store.py
751
app/db/store.py
|
|
@ -14,16 +14,9 @@ from circuitforge_core.db.migrations import run_migrations
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
# Module-level cache for recipe counts by keyword set.
|
|
||||||
# The recipe corpus is static at runtime — counts are computed once per
|
|
||||||
# (db_path, keyword_set) and reused for all subsequent requests.
|
|
||||||
# Key: (db_path_str, sorted_keywords_tuple) → int
|
|
||||||
_COUNT_CACHE: dict[tuple[str, ...], int] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
class Store:
|
||||||
def __init__(self, db_path: Path, key: str = "") -> None:
|
def __init__(self, db_path: Path, key: str = "") -> None:
|
||||||
self._db_path = str(db_path)
|
|
||||||
self.conn: sqlite3.Connection = get_connection(db_path, key)
|
self.conn: sqlite3.Connection = get_connection(db_path, key)
|
||||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
self.conn.execute("PRAGMA foreign_keys=ON")
|
self.conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
@ -39,12 +32,7 @@ class Store:
|
||||||
# Deserialise any TEXT columns that contain JSON
|
# Deserialise any TEXT columns that contain JSON
|
||||||
for key in ("metadata", "nutrition_data", "source_data", "items",
|
for key in ("metadata", "nutrition_data", "source_data", "items",
|
||||||
"metrics", "improvement_suggestions", "confidence_scores",
|
"metrics", "improvement_suggestions", "confidence_scores",
|
||||||
"warnings",
|
"warnings"):
|
||||||
# recipe columns
|
|
||||||
"ingredients", "ingredient_names", "directions",
|
|
||||||
"keywords", "element_coverage",
|
|
||||||
# saved recipe columns
|
|
||||||
"style_tags"):
|
|
||||||
if key in d and isinstance(d[key], str):
|
if key in d and isinstance(d[key], str):
|
||||||
try:
|
try:
|
||||||
d[key] = json.loads(d[key])
|
d[key] = json.loads(d[key])
|
||||||
|
|
@ -241,72 +229,6 @@ class Store:
|
||||||
(str(days),),
|
(str(days),),
|
||||||
)
|
)
|
||||||
|
|
||||||
def recalculate_expiry(
|
|
||||||
self,
|
|
||||||
tier: str = "local",
|
|
||||||
has_byok: bool = False,
|
|
||||||
) -> tuple[int, int]:
|
|
||||||
"""Re-run the expiration predictor over all available inventory items.
|
|
||||||
|
|
||||||
Uses each item's existing purchase_date (falls back to today if NULL)
|
|
||||||
and its current location. Skips items that have an explicit
|
|
||||||
expiration_date from a source other than auto-prediction (i.e. items
|
|
||||||
whose expiry was found on a receipt or entered by the user) cannot be
|
|
||||||
distinguished — all available items are recalculated.
|
|
||||||
|
|
||||||
Returns (updated_count, skipped_count).
|
|
||||||
"""
|
|
||||||
from datetime import date
|
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
|
||||||
|
|
||||||
predictor = ExpirationPredictor()
|
|
||||||
rows = self._fetch_all(
|
|
||||||
"""SELECT i.id, i.location, i.purchase_date,
|
|
||||||
p.name AS product_name, p.category AS product_category
|
|
||||||
FROM inventory_items i
|
|
||||||
JOIN products p ON p.id = i.product_id
|
|
||||||
WHERE i.status = 'available'""",
|
|
||||||
(),
|
|
||||||
)
|
|
||||||
|
|
||||||
updated = skipped = 0
|
|
||||||
for row in rows:
|
|
||||||
cat = predictor.get_category_from_product(
|
|
||||||
row["product_name"] or "",
|
|
||||||
product_category=row.get("product_category"),
|
|
||||||
location=row.get("location"),
|
|
||||||
)
|
|
||||||
purchase_date_raw = row.get("purchase_date")
|
|
||||||
try:
|
|
||||||
purchase_date = (
|
|
||||||
date.fromisoformat(purchase_date_raw)
|
|
||||||
if purchase_date_raw
|
|
||||||
else date.today()
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
purchase_date = date.today()
|
|
||||||
|
|
||||||
exp = predictor.predict_expiration(
|
|
||||||
cat,
|
|
||||||
row["location"] or "pantry",
|
|
||||||
purchase_date=purchase_date,
|
|
||||||
product_name=row["product_name"],
|
|
||||||
tier=tier,
|
|
||||||
has_byok=has_byok,
|
|
||||||
)
|
|
||||||
if exp is None:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE inventory_items SET expiration_date = ?, updated_at = datetime('now') WHERE id = ?",
|
|
||||||
(str(exp), row["id"]),
|
|
||||||
)
|
|
||||||
updated += 1
|
|
||||||
|
|
||||||
self.conn.commit()
|
|
||||||
return updated, skipped
|
|
||||||
|
|
||||||
# ── receipt_data ──────────────────────────────────────────────────────
|
# ── receipt_data ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def upsert_receipt_data(self, receipt_id: int, data: dict) -> dict[str, Any]:
|
def upsert_receipt_data(self, receipt_id: int, data: dict) -> dict[str, Any]:
|
||||||
|
|
@ -338,674 +260,3 @@ class Store:
|
||||||
return self._fetch_one(
|
return self._fetch_one(
|
||||||
"SELECT * FROM receipt_data WHERE receipt_id = ?", (receipt_id,)
|
"SELECT * FROM receipt_data WHERE receipt_id = ?", (receipt_id,)
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── recipes ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _fts_ready(self) -> bool:
|
|
||||||
"""Return True if the recipes_fts virtual table exists."""
|
|
||||||
row = self._fetch_one(
|
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='recipes_fts'"
|
|
||||||
)
|
|
||||||
return row is not None
|
|
||||||
|
|
||||||
# Words that carry no recipe-ingredient signal and should be filtered
|
|
||||||
# out when tokenising multi-word product names for FTS expansion.
|
|
||||||
_FTS_TOKEN_STOPWORDS: frozenset[str] = frozenset({
|
|
||||||
# Common English stopwords
|
|
||||||
"a", "an", "the", "of", "in", "for", "with", "and", "or", "to",
|
|
||||||
"from", "at", "by", "as", "on", "into",
|
|
||||||
# Brand / marketing words that appear in product names
|
|
||||||
"lean", "cuisine", "healthy", "choice", "stouffer", "original",
|
|
||||||
"classic", "deluxe", "homestyle", "family", "style", "grade",
|
|
||||||
"premium", "select", "natural", "organic", "fresh", "lite",
|
|
||||||
"ready", "quick", "easy", "instant", "microwave", "frozen",
|
|
||||||
"brand", "size", "large", "small", "medium", "extra",
|
|
||||||
# Plant-based / alt-meat brand names
|
|
||||||
"daring", "gardein", "morningstar", "lightlife", "tofurky",
|
|
||||||
"quorn", "omni", "nuggs", "simulate", "simulate",
|
|
||||||
# Preparation states — "cut up chicken" is still chicken
|
|
||||||
"cut", "diced", "sliced", "chopped", "minced", "shredded",
|
|
||||||
"cooked", "raw", "whole", "boneless", "skinless", "trimmed",
|
|
||||||
"pre", "prepared", "marinated", "seasoned", "breaded", "battered",
|
|
||||||
"grilled", "roasted", "smoked", "canned", "dried", "dehydrated",
|
|
||||||
"pieces", "piece", "strips", "strip", "chunks", "chunk",
|
|
||||||
"fillets", "fillet", "cutlets", "cutlet", "tenders", "nuggets",
|
|
||||||
# Units / packaging
|
|
||||||
"oz", "lb", "lbs", "pkg", "pack", "box", "can", "bag", "jar",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Maps substrings found in product-label names to canonical recipe-corpus
|
|
||||||
# ingredient terms. Checked as substring matches against the lower-cased
|
|
||||||
# full product name, then against each individual token.
|
|
||||||
_FTS_SYNONYMS: dict[str, str] = {
|
|
||||||
# Ground / minced beef
|
|
||||||
"burger patt": "hamburger",
|
|
||||||
"beef patt": "hamburger",
|
|
||||||
"ground beef": "hamburger",
|
|
||||||
"ground chuck": "hamburger",
|
|
||||||
"ground round": "hamburger",
|
|
||||||
"mince": "hamburger",
|
|
||||||
"veggie burger": "hamburger",
|
|
||||||
"beyond burger": "hamburger",
|
|
||||||
"impossible burger": "hamburger",
|
|
||||||
"plant burger": "hamburger",
|
|
||||||
"chicken patt": "hamburger", # FTS match only — recipe scoring still works
|
|
||||||
# Sausages
|
|
||||||
"kielbasa": "sausage",
|
|
||||||
"bratwurst": "sausage",
|
|
||||||
"brat ": "sausage",
|
|
||||||
"frankfurter": "hotdog",
|
|
||||||
"wiener": "hotdog",
|
|
||||||
# Chicken cuts + plant-based chicken → generic chicken for broader matching
|
|
||||||
"chicken breast": "chicken",
|
|
||||||
"chicken thigh": "chicken",
|
|
||||||
"chicken drumstick": "chicken",
|
|
||||||
"chicken wing": "chicken",
|
|
||||||
"rotisserie chicken": "chicken",
|
|
||||||
"chicken tender": "chicken",
|
|
||||||
"chicken strip": "chicken",
|
|
||||||
"chicken piece": "chicken",
|
|
||||||
"fake chicken": "chicken",
|
|
||||||
"plant chicken": "chicken",
|
|
||||||
"vegan chicken": "chicken",
|
|
||||||
"daring": "chicken", # Daring Foods brand
|
|
||||||
"gardein chick": "chicken",
|
|
||||||
"quorn chick": "chicken",
|
|
||||||
"chick'n": "chicken",
|
|
||||||
"chikn": "chicken",
|
|
||||||
"not-chicken": "chicken",
|
|
||||||
"no-chicken": "chicken",
|
|
||||||
# Plant-based beef subs — map to broad "beef" not "hamburger"
|
|
||||||
# (texture varies: strips ≠ ground; let corpus handle the specific form)
|
|
||||||
"not-beef": "beef",
|
|
||||||
"no-beef": "beef",
|
|
||||||
"plant beef": "beef",
|
|
||||||
"vegan beef": "beef",
|
|
||||||
# Plant-based pork subs
|
|
||||||
"not-pork": "pork",
|
|
||||||
"no-pork": "pork",
|
|
||||||
"plant pork": "pork",
|
|
||||||
"vegan pork": "pork",
|
|
||||||
"omnipork": "pork",
|
|
||||||
"omni pork": "pork",
|
|
||||||
# Generic alt-meat catch-alls → broad "beef" (safer than hamburger)
|
|
||||||
"fake meat": "beef",
|
|
||||||
"plant meat": "beef",
|
|
||||||
"vegan meat": "beef",
|
|
||||||
"meat-free": "beef",
|
|
||||||
"meatless": "beef",
|
|
||||||
# Pork cuts
|
|
||||||
"pork chop": "pork",
|
|
||||||
"pork loin": "pork",
|
|
||||||
"pork tenderloin": "pork",
|
|
||||||
# Tomato-based sauces
|
|
||||||
"marinara": "tomato sauce",
|
|
||||||
"pasta sauce": "tomato sauce",
|
|
||||||
"spaghetti sauce": "tomato sauce",
|
|
||||||
"pizza sauce": "tomato sauce",
|
|
||||||
# Pasta shapes — map to generic "pasta" so FTS finds any pasta recipe
|
|
||||||
"macaroni": "pasta",
|
|
||||||
"noodles": "pasta",
|
|
||||||
"spaghetti": "pasta",
|
|
||||||
"penne": "pasta",
|
|
||||||
"fettuccine": "pasta",
|
|
||||||
"rigatoni": "pasta",
|
|
||||||
"linguine": "pasta",
|
|
||||||
"rotini": "pasta",
|
|
||||||
"farfalle": "pasta",
|
|
||||||
# Cheese variants → "cheese" for broad matching
|
|
||||||
"shredded cheese": "cheese",
|
|
||||||
"sliced cheese": "cheese",
|
|
||||||
"american cheese": "cheese",
|
|
||||||
"cheddar": "cheese",
|
|
||||||
"mozzarella": "cheese",
|
|
||||||
# Cream variants
|
|
||||||
"heavy cream": "cream",
|
|
||||||
"whipping cream": "cream",
|
|
||||||
"half and half": "cream",
|
|
||||||
# Buns / rolls
|
|
||||||
"burger bun": "buns",
|
|
||||||
"hamburger bun": "buns",
|
|
||||||
"hot dog bun": "buns",
|
|
||||||
"bread roll": "buns",
|
|
||||||
"dinner roll": "buns",
|
|
||||||
# Tortillas / wraps
|
|
||||||
"flour tortilla": "tortillas",
|
|
||||||
"corn tortilla": "tortillas",
|
|
||||||
"tortilla wrap": "tortillas",
|
|
||||||
"soft taco shell": "tortillas",
|
|
||||||
"taco shell": "taco shells",
|
|
||||||
"pita bread": "pita",
|
|
||||||
"flatbread": "flatbread",
|
|
||||||
# Canned beans
|
|
||||||
"black bean": "beans",
|
|
||||||
"pinto bean": "beans",
|
|
||||||
"kidney bean": "beans",
|
|
||||||
"refried bean": "beans",
|
|
||||||
"chickpea": "beans",
|
|
||||||
"garbanzo": "beans",
|
|
||||||
# Rice variants
|
|
||||||
"white rice": "rice",
|
|
||||||
"brown rice": "rice",
|
|
||||||
"jasmine rice": "rice",
|
|
||||||
"basmati rice": "rice",
|
|
||||||
"instant rice": "rice",
|
|
||||||
"microwavable rice": "rice",
|
|
||||||
# Salsa / hot sauce
|
|
||||||
"hot sauce": "salsa",
|
|
||||||
"taco sauce": "salsa",
|
|
||||||
"enchilada sauce": "salsa",
|
|
||||||
# Sour cream substitute
|
|
||||||
"greek yogurt": "sour cream",
|
|
||||||
# Prepackaged meals
|
|
||||||
"lean cuisine": "casserole",
|
|
||||||
"stouffer": "casserole",
|
|
||||||
"healthy choice": "casserole",
|
|
||||||
"marie callender": "casserole",
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_for_fts(name: str) -> list[str]:
|
|
||||||
"""Expand one pantry item to all FTS search terms it should contribute.
|
|
||||||
|
|
||||||
Returns the original name plus:
|
|
||||||
- Any synonym-map canonical terms (handles product-label → corpus name)
|
|
||||||
- Individual significant tokens from multi-word product names
|
|
||||||
(handles packaged meals like "Lean Cuisine Chicken Alfredo" → also
|
|
||||||
searches for "chicken" and "alfredo" independently)
|
|
||||||
"""
|
|
||||||
lower = name.lower().strip()
|
|
||||||
if not lower:
|
|
||||||
return []
|
|
||||||
|
|
||||||
terms: list[str] = [lower]
|
|
||||||
|
|
||||||
# Substring synonym check on full name
|
|
||||||
for pattern, canonical in Store._FTS_SYNONYMS.items():
|
|
||||||
if pattern in lower:
|
|
||||||
terms.append(canonical)
|
|
||||||
|
|
||||||
# For multi-word product names, also add individual significant tokens
|
|
||||||
if " " in lower:
|
|
||||||
for token in lower.split():
|
|
||||||
if len(token) <= 3 or token in Store._FTS_TOKEN_STOPWORDS:
|
|
||||||
continue
|
|
||||||
if token not in terms:
|
|
||||||
terms.append(token)
|
|
||||||
# Synonym-expand individual tokens too
|
|
||||||
if token in Store._FTS_SYNONYMS:
|
|
||||||
canonical = Store._FTS_SYNONYMS[token]
|
|
||||||
if canonical not in terms:
|
|
||||||
terms.append(canonical)
|
|
||||||
|
|
||||||
return terms
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_fts_query(ingredient_names: list[str]) -> str:
|
|
||||||
"""Build an FTS5 MATCH expression ORing all ingredient terms.
|
|
||||||
|
|
||||||
Each pantry item is expanded via _normalize_for_fts so that
|
|
||||||
product-label names (e.g. "burger patties") also search for their
|
|
||||||
recipe-corpus equivalents (e.g. "hamburger"), and multi-word packaged
|
|
||||||
product names contribute their individual ingredient tokens.
|
|
||||||
"""
|
|
||||||
parts: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for name in ingredient_names:
|
|
||||||
for term in Store._normalize_for_fts(name):
|
|
||||||
# Strip characters that break FTS5 query syntax
|
|
||||||
clean = term.replace('"', "").replace("'", "")
|
|
||||||
if not clean or clean in seen:
|
|
||||||
continue
|
|
||||||
seen.add(clean)
|
|
||||||
parts.append(f'"{clean}"')
|
|
||||||
return " OR ".join(parts)
|
|
||||||
|
|
||||||
def search_recipes_by_ingredients(
|
|
||||||
self,
|
|
||||||
ingredient_names: list[str],
|
|
||||||
limit: int = 20,
|
|
||||||
category: str | None = None,
|
|
||||||
max_calories: float | None = None,
|
|
||||||
max_sugar_g: float | None = None,
|
|
||||||
max_carbs_g: float | None = None,
|
|
||||||
max_sodium_mg: float | None = None,
|
|
||||||
excluded_ids: list[int] | None = None,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Find recipes containing any of the given ingredient names.
|
|
||||||
Scores by match count and returns highest-scoring first.
|
|
||||||
|
|
||||||
Uses FTS5 index (migration 015) when available — O(log N) per query.
|
|
||||||
Falls back to LIKE scans on older databases.
|
|
||||||
|
|
||||||
Nutrition filters use NULL-passthrough: rows without nutrition data
|
|
||||||
always pass (they may be estimated or absent entirely).
|
|
||||||
"""
|
|
||||||
if not ingredient_names:
|
|
||||||
return []
|
|
||||||
|
|
||||||
extra_clauses: list[str] = []
|
|
||||||
extra_params: list = []
|
|
||||||
if category:
|
|
||||||
extra_clauses.append("r.category = ?")
|
|
||||||
extra_params.append(category)
|
|
||||||
if max_calories is not None:
|
|
||||||
extra_clauses.append("(r.calories IS NULL OR r.calories <= ?)")
|
|
||||||
extra_params.append(max_calories)
|
|
||||||
if max_sugar_g is not None:
|
|
||||||
extra_clauses.append("(r.sugar_g IS NULL OR r.sugar_g <= ?)")
|
|
||||||
extra_params.append(max_sugar_g)
|
|
||||||
if max_carbs_g is not None:
|
|
||||||
extra_clauses.append("(r.carbs_g IS NULL OR r.carbs_g <= ?)")
|
|
||||||
extra_params.append(max_carbs_g)
|
|
||||||
if max_sodium_mg is not None:
|
|
||||||
extra_clauses.append("(r.sodium_mg IS NULL OR r.sodium_mg <= ?)")
|
|
||||||
extra_params.append(max_sodium_mg)
|
|
||||||
if excluded_ids:
|
|
||||||
placeholders = ",".join("?" * len(excluded_ids))
|
|
||||||
extra_clauses.append(f"r.id NOT IN ({placeholders})")
|
|
||||||
extra_params.extend(excluded_ids)
|
|
||||||
where_extra = (" AND " + " AND ".join(extra_clauses)) if extra_clauses else ""
|
|
||||||
|
|
||||||
if self._fts_ready():
|
|
||||||
return self._search_recipes_fts(
|
|
||||||
ingredient_names, limit, where_extra, extra_params
|
|
||||||
)
|
|
||||||
return self._search_recipes_like(
|
|
||||||
ingredient_names, limit, where_extra, extra_params
|
|
||||||
)
|
|
||||||
|
|
||||||
def _search_recipes_fts(
|
|
||||||
self,
|
|
||||||
ingredient_names: list[str],
|
|
||||||
limit: int,
|
|
||||||
where_extra: str,
|
|
||||||
extra_params: list,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""FTS5-backed ingredient search. Candidates fetched via inverted index;
|
|
||||||
match_count computed in Python over the small candidate set."""
|
|
||||||
fts_query = self._build_fts_query(ingredient_names)
|
|
||||||
if not fts_query:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Pull up to 10× limit candidates so ranking has enough headroom.
|
|
||||||
sql = f"""
|
|
||||||
SELECT r.*
|
|
||||||
FROM recipes_fts
|
|
||||||
JOIN recipes r ON r.id = recipes_fts.rowid
|
|
||||||
WHERE recipes_fts MATCH ?
|
|
||||||
{where_extra}
|
|
||||||
LIMIT ?
|
|
||||||
"""
|
|
||||||
rows = self._fetch_all(sql, (fts_query, *extra_params, limit * 10))
|
|
||||||
|
|
||||||
pantry_set = {n.lower().strip() for n in ingredient_names}
|
|
||||||
scored: list[dict] = []
|
|
||||||
for row in rows:
|
|
||||||
raw = row.get("ingredient_names") or []
|
|
||||||
names: list[str] = raw if isinstance(raw, list) else json.loads(raw or "[]")
|
|
||||||
match_count = sum(1 for n in names if n.lower() in pantry_set)
|
|
||||||
scored.append({**row, "match_count": match_count})
|
|
||||||
|
|
||||||
scored.sort(key=lambda r: (-r["match_count"], r["id"]))
|
|
||||||
return scored[:limit]
|
|
||||||
|
|
||||||
def _search_recipes_like(
|
|
||||||
self,
|
|
||||||
ingredient_names: list[str],
|
|
||||||
limit: int,
|
|
||||||
where_extra: str,
|
|
||||||
extra_params: list,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Legacy LIKE-based ingredient search (O(N×rows) — slow on large corpora)."""
|
|
||||||
like_params = [f'%"{n}"%' for n in ingredient_names]
|
|
||||||
like_clauses = " OR ".join(
|
|
||||||
"r.ingredient_names LIKE ?" for _ in ingredient_names
|
|
||||||
)
|
|
||||||
match_score = " + ".join(
|
|
||||||
"CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END"
|
|
||||||
for _ in ingredient_names
|
|
||||||
)
|
|
||||||
sql = f"""
|
|
||||||
SELECT r.*, ({match_score}) AS match_count
|
|
||||||
FROM recipes r
|
|
||||||
WHERE ({like_clauses})
|
|
||||||
{where_extra}
|
|
||||||
ORDER BY match_count DESC, r.id ASC
|
|
||||||
LIMIT ?
|
|
||||||
"""
|
|
||||||
all_params = like_params + like_params + extra_params + [limit]
|
|
||||||
return self._fetch_all(sql, tuple(all_params))
|
|
||||||
|
|
||||||
def get_recipe(self, recipe_id: int) -> dict | None:
|
|
||||||
return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
|
|
||||||
|
|
||||||
# ── rate limits ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def check_and_increment_rate_limit(
|
|
||||||
self, feature: str, daily_max: int
|
|
||||||
) -> tuple[bool, int]:
|
|
||||||
"""Check daily counter for feature; only increment if under the limit.
|
|
||||||
Returns (allowed, current_count). Rejected calls do not consume quota."""
|
|
||||||
from datetime import date
|
|
||||||
today = date.today().isoformat()
|
|
||||||
row = self._fetch_one(
|
|
||||||
"SELECT count FROM rate_limits WHERE feature = ? AND window_date = ?",
|
|
||||||
(feature, today),
|
|
||||||
)
|
|
||||||
current = row["count"] if row else 0
|
|
||||||
if current >= daily_max:
|
|
||||||
return (False, current)
|
|
||||||
self.conn.execute("""
|
|
||||||
INSERT INTO rate_limits (feature, window_date, count)
|
|
||||||
VALUES (?, ?, 1)
|
|
||||||
ON CONFLICT(feature, window_date) DO UPDATE SET count = count + 1
|
|
||||||
""", (feature, today))
|
|
||||||
self.conn.commit()
|
|
||||||
return (True, current + 1)
|
|
||||||
|
|
||||||
# ── user settings ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_setting(self, key: str) -> str | None:
|
|
||||||
"""Return the value for a settings key, or None if not set."""
|
|
||||||
row = self._fetch_one(
|
|
||||||
"SELECT value FROM user_settings WHERE key = ?", (key,)
|
|
||||||
)
|
|
||||||
return row["value"] if row else None
|
|
||||||
|
|
||||||
def set_setting(self, key: str, value: str) -> None:
|
|
||||||
"""Upsert a settings key-value pair."""
|
|
||||||
self.conn.execute(
|
|
||||||
"INSERT INTO user_settings (key, value) VALUES (?, ?)"
|
|
||||||
" ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
||||||
(key, value),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
# ── substitution feedback ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def log_substitution_feedback(
|
|
||||||
self,
|
|
||||||
original: str,
|
|
||||||
substitute: str,
|
|
||||||
constraint: str | None,
|
|
||||||
compensation_used: list[str],
|
|
||||||
approved: bool,
|
|
||||||
opted_in: bool,
|
|
||||||
) -> None:
|
|
||||||
self.conn.execute("""
|
|
||||||
INSERT INTO substitution_feedback
|
|
||||||
(original_name, substitute_name, constraint_label,
|
|
||||||
compensation_used, approved, opted_in)
|
|
||||||
VALUES (?,?,?,?,?,?)
|
|
||||||
""", (
|
|
||||||
original, substitute, constraint,
|
|
||||||
self._dump(compensation_used),
|
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _browser_fts_query(keywords: list[str]) -> str:
|
|
||||||
"""Build an FTS5 MATCH expression that ORs all keywords as exact phrases."""
|
|
||||||
phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords]
|
|
||||||
return " OR ".join(phrases)
|
|
||||||
|
|
||||||
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
|
|
||||||
if not keywords:
|
|
||||||
return 0
|
|
||||||
cache_key = (self._db_path, *sorted(keywords))
|
|
||||||
if cache_key in _COUNT_CACHE:
|
|
||||||
return _COUNT_CACHE[cache_key]
|
|
||||||
match_expr = self._browser_fts_query(keywords)
|
|
||||||
row = self.conn.execute(
|
|
||||||
"SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
|
|
||||||
(match_expr,),
|
|
||||||
).fetchone()
|
|
||||||
count = row[0] if row else 0
|
|
||||||
_COUNT_CACHE[cache_key] = count
|
|
||||||
return count
|
|
||||||
|
|
||||||
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}
|
|
||||||
|
|
||||||
match_expr = self._browser_fts_query(keywords)
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
|
|
||||||
# Reuse cached count — avoids a second index scan on every page turn.
|
|
||||||
total = self._count_recipes_for_keywords(keywords)
|
|
||||||
|
|
||||||
rows = self._fetch_all(
|
|
||||||
"""
|
|
||||||
SELECT r.id, r.title, r.category, r.keywords, r.ingredient_names,
|
|
||||||
r.calories, r.fat_g, r.protein_g, r.sodium_mg, r.source_url
|
|
||||||
FROM recipe_browser_fts fts
|
|
||||||
JOIN recipes r ON r.id = fts.rowid
|
|
||||||
WHERE fts MATCH ?
|
|
||||||
ORDER BY r.id ASC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
""",
|
|
||||||
(match_expr, 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()
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ async def lifespan(app: FastAPI):
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
description="Pantry tracking + leftover recipe suggestions",
|
description="Pantry tracking + leftover recipe suggestions",
|
||||||
version="0.2.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -133,32 +133,6 @@ class BarcodeScanResponse(BaseModel):
|
||||||
message: str
|
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 ─────────────────────────────────────────────────────────────────────
|
# ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class InventoryStats(BaseModel):
|
class InventoryStats(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
"""Pydantic schemas for the recipe engine API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class SwapCandidate(BaseModel):
|
|
||||||
original_name: str
|
|
||||||
substitute_name: str
|
|
||||||
constraint_label: str
|
|
||||||
explanation: str
|
|
||||||
compensation_hints: list[dict] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class NutritionPanel(BaseModel):
|
|
||||||
"""Per-recipe macro summary. All values are per-serving when servings is known,
|
|
||||||
otherwise for the full recipe. None means data is unavailable."""
|
|
||||||
calories: float | None = None
|
|
||||||
fat_g: float | None = None
|
|
||||||
protein_g: float | None = None
|
|
||||||
carbs_g: float | None = None
|
|
||||||
fiber_g: float | None = None
|
|
||||||
sugar_g: float | None = None
|
|
||||||
sodium_mg: float | None = None
|
|
||||||
servings: float | None = None
|
|
||||||
estimated: bool = False # True when nutrition was inferred from ingredient profiles
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeSuggestion(BaseModel):
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
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)
|
|
||||||
notes: str = ""
|
|
||||||
level: int = 1
|
|
||||||
is_wildcard: bool = False
|
|
||||||
nutrition: NutritionPanel | None = None
|
|
||||||
source_url: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryLink(BaseModel):
|
|
||||||
ingredient: str
|
|
||||||
retailer: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeResult(BaseModel):
|
|
||||||
suggestions: list[RecipeSuggestion]
|
|
||||||
element_gaps: list[str]
|
|
||||||
grocery_list: list[str] = Field(default_factory=list)
|
|
||||||
grocery_links: list[GroceryLink] = Field(default_factory=list)
|
|
||||||
rate_limited: bool = False
|
|
||||||
rate_limit_count: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class NutritionFilters(BaseModel):
|
|
||||||
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
|
|
||||||
max_calories: float | None = None
|
|
||||||
max_sugar_g: float | None = None
|
|
||||||
max_carbs_g: float | None = None
|
|
||||||
max_sodium_mg: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeRequest(BaseModel):
|
|
||||||
pantry_items: list[str]
|
|
||||||
level: int = Field(default=1, ge=1, le=4)
|
|
||||||
constraints: list[str] = Field(default_factory=list)
|
|
||||||
expiry_first: bool = False
|
|
||||||
hard_day_mode: bool = False
|
|
||||||
max_missing: int | None = None
|
|
||||||
style_id: str | None = None
|
|
||||||
category: str | None = None
|
|
||||||
tier: str = "free"
|
|
||||||
has_byok: bool = False
|
|
||||||
wildcard_confirmed: bool = False
|
|
||||||
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
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -5,8 +5,6 @@ This module provides functionality to detect and decode barcodes
|
||||||
from images (UPC, EAN, QR codes, etc.).
|
from images (UPC, EAN, QR codes, etc.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pyzbar import pyzbar
|
from pyzbar import pyzbar
|
||||||
|
|
@ -14,12 +12,6 @@ from pathlib import Path
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image as _PILImage
|
|
||||||
_HAS_PIL = True
|
|
||||||
except ImportError:
|
|
||||||
_HAS_PIL = False
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,7 +76,9 @@ class BarcodeScanner:
|
||||||
# 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes)
|
# 4. Try rotations if still no barcodes found (handles tilted/rotated barcodes)
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
logger.info("No barcodes found in standard orientation, trying rotations...")
|
logger.info("No barcodes found in standard orientation, trying rotations...")
|
||||||
for angle in [90, 180, 270, 45, 135]:
|
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
|
||||||
|
# 0° already tried, 180° is functionally same as 0°, 90°/270° are same axis
|
||||||
|
for angle in [30, 60, 90]:
|
||||||
rotated_gray = self._rotate_image(gray, angle)
|
rotated_gray = self._rotate_image(gray, angle)
|
||||||
rotated_color = self._rotate_image(image, angle)
|
rotated_color = self._rotate_image(image, angle)
|
||||||
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
||||||
|
|
@ -270,26 +264,6 @@ class BarcodeScanner:
|
||||||
|
|
||||||
return list(seen.values())
|
return list(seen.values())
|
||||||
|
|
||||||
def _fix_exif_orientation(self, image_bytes: bytes) -> bytes:
|
|
||||||
"""Apply EXIF orientation correction so cv2 sees an upright image.
|
|
||||||
|
|
||||||
Phone cameras embed rotation in EXIF; cv2.imdecode ignores it,
|
|
||||||
so a photo taken in portrait may arrive physically sideways in memory.
|
|
||||||
"""
|
|
||||||
if not _HAS_PIL:
|
|
||||||
return image_bytes
|
|
||||||
try:
|
|
||||||
pil = _PILImage.open(io.BytesIO(image_bytes))
|
|
||||||
pil = _PILImage.fromarray(np.array(pil)) # strips EXIF but applies orientation via PIL
|
|
||||||
# Use ImageOps.exif_transpose for proper EXIF-aware rotation
|
|
||||||
import PIL.ImageOps
|
|
||||||
pil = PIL.ImageOps.exif_transpose(pil)
|
|
||||||
buf = io.BytesIO()
|
|
||||||
pil.save(buf, format="JPEG")
|
|
||||||
return buf.getvalue()
|
|
||||||
except Exception:
|
|
||||||
return image_bytes
|
|
||||||
|
|
||||||
def scan_from_bytes(self, image_bytes: bytes) -> List[Dict[str, Any]]:
|
def scan_from_bytes(self, image_bytes: bytes) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Scan barcodes from image bytes (uploaded file).
|
Scan barcodes from image bytes (uploaded file).
|
||||||
|
|
@ -301,10 +275,6 @@ class BarcodeScanner:
|
||||||
List of detected barcodes
|
List of detected barcodes
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Apply EXIF orientation correction first (phone cameras embed rotation in EXIF;
|
|
||||||
# cv2.imdecode ignores it, causing sideways barcodes to appear rotated in memory).
|
|
||||||
image_bytes = self._fix_exif_orientation(image_bytes)
|
|
||||||
|
|
||||||
# Convert bytes to numpy array
|
# Convert bytes to numpy array
|
||||||
nparr = np.frombuffer(image_bytes, np.uint8)
|
nparr = np.frombuffer(image_bytes, np.uint8)
|
||||||
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
|
@ -330,12 +300,11 @@ class BarcodeScanner:
|
||||||
)
|
)
|
||||||
barcodes.extend(self._detect_barcodes(thresh, image))
|
barcodes.extend(self._detect_barcodes(thresh, image))
|
||||||
|
|
||||||
# 3. Try all 90° rotations + common tilt angles
|
# 3. Try rotations if still no barcodes found
|
||||||
# 90/270 catches truly sideways barcodes; 180 catches upside-down;
|
|
||||||
# 45/135 catches tilted barcodes on flat surfaces.
|
|
||||||
if not barcodes:
|
if not barcodes:
|
||||||
logger.info("No barcodes found in uploaded image, trying rotations...")
|
logger.info("No barcodes found in uploaded image, trying rotations...")
|
||||||
for angle in [90, 180, 270, 45, 135]:
|
# Try incremental angles: 30°, 60°, 90° (covers 0-90° range)
|
||||||
|
for angle in [30, 60, 90]:
|
||||||
rotated_gray = self._rotate_image(gray, angle)
|
rotated_gray = self._rotate_image(gray, angle)
|
||||||
rotated_color = self._rotate_image(image, angle)
|
rotated_color = self._rotate_image(image, angle)
|
||||||
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
detected = self._detect_barcodes(rotated_gray, rotated_color)
|
||||||
|
|
|
||||||
|
|
@ -21,29 +21,6 @@ logger = logging.getLogger(__name__)
|
||||||
class ExpirationPredictor:
|
class ExpirationPredictor:
|
||||||
"""Predict expiration dates based on product category and storage location."""
|
"""Predict expiration dates based on product category and storage location."""
|
||||||
|
|
||||||
# Canonical location names and their aliases.
|
|
||||||
# All location strings are normalised through this before table lookup.
|
|
||||||
LOCATION_ALIASES: dict[str, str] = {
|
|
||||||
'garage_freezer': 'freezer',
|
|
||||||
'chest_freezer': 'freezer',
|
|
||||||
'deep_freezer': 'freezer',
|
|
||||||
'upright_freezer': 'freezer',
|
|
||||||
'refrigerator': 'fridge',
|
|
||||||
'frig': 'fridge',
|
|
||||||
'cupboard': 'cabinet',
|
|
||||||
'shelf': 'pantry',
|
|
||||||
'counter': 'pantry',
|
|
||||||
}
|
|
||||||
|
|
||||||
# When a category has no entry for the requested location, try these
|
|
||||||
# alternatives in order — prioritising same-temperature storage first.
|
|
||||||
LOCATION_FALLBACK: dict[str, tuple[str, ...]] = {
|
|
||||||
'freezer': ('freezer', 'fridge', 'pantry', 'cabinet'),
|
|
||||||
'fridge': ('fridge', 'pantry', 'cabinet', 'freezer'),
|
|
||||||
'pantry': ('pantry', 'cabinet', 'fridge', 'freezer'),
|
|
||||||
'cabinet': ('cabinet', 'pantry', 'fridge', 'freezer'),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Default shelf life in days by category and location
|
# Default shelf life in days by category and location
|
||||||
# Sources: USDA FoodKeeper app, FDA guidelines
|
# Sources: USDA FoodKeeper app, FDA guidelines
|
||||||
SHELF_LIFE = {
|
SHELF_LIFE = {
|
||||||
|
|
@ -62,8 +39,6 @@ class ExpirationPredictor:
|
||||||
'poultry': {'fridge': 2, 'freezer': 270},
|
'poultry': {'fridge': 2, 'freezer': 270},
|
||||||
'chicken': {'fridge': 2, 'freezer': 270},
|
'chicken': {'fridge': 2, 'freezer': 270},
|
||||||
'turkey': {'fridge': 2, 'freezer': 270},
|
'turkey': {'fridge': 2, 'freezer': 270},
|
||||||
'tempeh': {'fridge': 10, 'freezer': 365},
|
|
||||||
'tofu': {'fridge': 5, 'freezer': 180},
|
|
||||||
'ground_meat': {'fridge': 2, 'freezer': 120},
|
'ground_meat': {'fridge': 2, 'freezer': 120},
|
||||||
# Seafood
|
# Seafood
|
||||||
'fish': {'fridge': 2, 'freezer': 180},
|
'fish': {'fridge': 2, 'freezer': 180},
|
||||||
|
|
@ -84,9 +59,9 @@ class ExpirationPredictor:
|
||||||
'bread': {'pantry': 5, 'freezer': 90},
|
'bread': {'pantry': 5, 'freezer': 90},
|
||||||
'bakery': {'pantry': 3, 'fridge': 7, 'freezer': 90},
|
'bakery': {'pantry': 3, 'fridge': 7, 'freezer': 90},
|
||||||
# Frozen
|
# Frozen
|
||||||
'frozen_foods': {'freezer': 180, 'fridge': 3},
|
'frozen_foods': {'freezer': 180},
|
||||||
'frozen_vegetables': {'freezer': 270, 'fridge': 4},
|
'frozen_vegetables': {'freezer': 270},
|
||||||
'frozen_fruit': {'freezer': 365, 'fridge': 4},
|
'frozen_fruit': {'freezer': 365},
|
||||||
'ice_cream': {'freezer': 60},
|
'ice_cream': {'freezer': 60},
|
||||||
# Pantry Staples
|
# Pantry Staples
|
||||||
'canned_goods': {'pantry': 730, 'cabinet': 730},
|
'canned_goods': {'pantry': 730, 'cabinet': 730},
|
||||||
|
|
@ -116,127 +91,44 @@ class ExpirationPredictor:
|
||||||
'prepared_foods': {'fridge': 4, 'freezer': 90},
|
'prepared_foods': {'fridge': 4, 'freezer': 90},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Keyword lists are checked in declaration order — most specific first.
|
|
||||||
# Rules:
|
|
||||||
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
|
||||||
# - frozen prepared foods BEFORE generic protein terms
|
|
||||||
# - multi-word phrases before single words where ambiguity exists
|
|
||||||
CATEGORY_KEYWORDS = {
|
CATEGORY_KEYWORDS = {
|
||||||
# ── Frozen prepared foods ─────────────────────────────────────────────
|
|
||||||
# Before raw protein entries so plant-based frozen products don't
|
|
||||||
# inherit 2–3 day raw-meat shelf lives.
|
|
||||||
'ice_cream': ['ice cream', 'gelato', 'frozen yogurt', 'sorbet', 'sherbet'],
|
|
||||||
'frozen_fruit': [
|
|
||||||
'frozen berries', 'frozen mango', 'frozen strawberries',
|
|
||||||
'frozen blueberries', 'frozen raspberries', 'frozen peaches',
|
|
||||||
'frozen fruit', 'frozen cherries',
|
|
||||||
],
|
|
||||||
'frozen_vegetables': [
|
|
||||||
'frozen veg', 'frozen corn', 'frozen peas', 'frozen broccoli',
|
|
||||||
'frozen spinach', 'frozen edamame', 'frozen green beans',
|
|
||||||
'frozen mixed vegetables', 'frozen carrots',
|
|
||||||
'peas & carrots', 'peas and carrots', 'mixed vegetables',
|
|
||||||
'spring rolls', 'vegetable spring rolls',
|
|
||||||
],
|
|
||||||
'frozen_foods': [
|
|
||||||
'plant-based', 'plant based', 'meatless', 'impossible',
|
|
||||||
"chik'n", 'chikn', 'veggie burger', 'veggie patty',
|
|
||||||
'nugget', 'tater tot', 'waffle fries', 'hash brown',
|
|
||||||
'onion ring', 'fish stick', 'fish fillet', 'potsticker',
|
|
||||||
'dumpling', 'egg roll', 'empanada', 'tamale', 'falafel',
|
|
||||||
'mac & cheese bite', 'cauliflower wing', 'ranchero potato',
|
|
||||||
],
|
|
||||||
# ── Canned / shelf-stable processed goods ─────────────────────────────
|
|
||||||
# Before raw protein keywords so "canned chicken", "cream of chicken",
|
|
||||||
# and "lentil soup" resolve here rather than to raw chicken/cream.
|
|
||||||
'canned_goods': [
|
|
||||||
'canned', 'can of', 'tin of', 'tinned',
|
|
||||||
'cream of ', 'condensed soup', 'condensed cream',
|
|
||||||
'baked beans', 'refried beans',
|
|
||||||
'canned beans', 'canned tomatoes', 'canned corn', 'canned peas',
|
|
||||||
'canned soup', 'canned tuna', 'canned salmon', 'canned chicken',
|
|
||||||
'canned fruit', 'canned peaches', 'canned pears',
|
|
||||||
'enchilada sauce', 'tomato sauce', 'tomato paste',
|
|
||||||
'lentil soup', 'bean soup', 'chicken noodle soup',
|
|
||||||
],
|
|
||||||
# ── Condiments & brined items ─────────────────────────────────────────
|
|
||||||
# Before produce/protein terms so brined olives, jarred peppers, etc.
|
|
||||||
# don't inherit raw vegetable shelf lives.
|
|
||||||
'ketchup': ['ketchup', 'catsup'],
|
|
||||||
'mustard': ['mustard', 'dijon', 'dijion', 'stoneground mustard'],
|
|
||||||
'mayo': ['mayo', 'mayonnaise', 'miracle whip'],
|
|
||||||
'soy_sauce': ['soy sauce', 'tamari', 'shoyu'],
|
|
||||||
'salad_dressing': ['salad dressing', 'ranch', 'italian dressing', 'vinaigrette'],
|
|
||||||
'condiments': [
|
|
||||||
# brined / jarred items
|
|
||||||
'dill chips', 'hamburger chips', 'gherkin',
|
|
||||||
'olive', 'capers', 'jalapeño', 'jalapeno', 'pepperoncini',
|
|
||||||
'pimiento', 'banana pepper', 'cornichon',
|
|
||||||
# sauces
|
|
||||||
'hot sauce', 'hot pepper sauce', 'sriracha', 'cholula',
|
|
||||||
'worcestershire', 'barbecue sauce', 'bbq sauce',
|
|
||||||
'chipotle sauce', 'chipotle mayo', 'chipotle creamy',
|
|
||||||
'salsa', 'chutney', 'relish',
|
|
||||||
'teriyaki', 'hoisin', 'oyster sauce', 'fish sauce',
|
|
||||||
'miso', 'ssamjang', 'gochujang', 'doenjang',
|
|
||||||
'soybean paste', 'fermented soybean',
|
|
||||||
# nut butters / spreads
|
|
||||||
'peanut butter', 'almond butter', 'tahini', 'hummus',
|
|
||||||
# seasoning mixes
|
|
||||||
'seasoning', 'spice blend', 'borracho',
|
|
||||||
# other shelf-stable sauces
|
|
||||||
'yuzu', 'ponzu', 'lizano',
|
|
||||||
],
|
|
||||||
# ── Soy / fermented proteins ──────────────────────────────────────────
|
|
||||||
'tempeh': ['tempeh'],
|
|
||||||
'tofu': ['tofu', 'bean curd'],
|
|
||||||
# ── Dairy ─────────────────────────────────────────────────────────────
|
|
||||||
'milk': ['milk', 'whole milk', '2% milk', 'skim milk', 'almond milk', 'oat milk', 'soy milk'],
|
'milk': ['milk', 'whole milk', '2% milk', 'skim milk', 'almond milk', 'oat milk', 'soy milk'],
|
||||||
'cheese': ['cheese', 'cheddar', 'mozzarella', 'swiss', 'parmesan', 'feta', 'gouda', 'velveeta'],
|
'cheese': ['cheese', 'cheddar', 'mozzarella', 'swiss', 'parmesan', 'feta', 'gouda'],
|
||||||
'yogurt': ['yogurt', 'greek yogurt', 'yoghurt'],
|
'yogurt': ['yogurt', 'greek yogurt', 'yoghurt'],
|
||||||
'butter': ['butter', 'margarine'],
|
'butter': ['butter', 'margarine'],
|
||||||
# Bare 'cream' removed — "cream of X" is canned_goods (matched above).
|
'cream': ['cream', 'heavy cream', 'whipping cream', 'sour cream'],
|
||||||
'cream': ['heavy cream', 'whipping cream', 'sour cream', 'crème fraîche',
|
|
||||||
'cream cheese', 'whipped topping', 'whipped cream'],
|
|
||||||
'eggs': ['eggs', 'egg'],
|
'eggs': ['eggs', 'egg'],
|
||||||
# ── Raw proteins ──────────────────────────────────────────────────────
|
'beef': ['beef', 'steak', 'roast', 'brisket', 'ribeye', 'sirloin'],
|
||||||
# After canned/frozen so "canned chicken" is already resolved above.
|
'pork': ['pork', 'bacon', 'ham', 'sausage', 'pork chop'],
|
||||||
|
'chicken': ['chicken', 'chicken breast', 'chicken thigh', 'chicken wings'],
|
||||||
|
'turkey': ['turkey', 'turkey breast', 'ground turkey'],
|
||||||
|
'ground_meat': ['ground beef', 'ground pork', 'ground chicken', 'hamburger'],
|
||||||
|
'fish': ['fish', 'cod', 'tilapia', 'halibut'],
|
||||||
'salmon': ['salmon'],
|
'salmon': ['salmon'],
|
||||||
'shrimp': ['shrimp', 'prawns'],
|
'shrimp': ['shrimp', 'prawns'],
|
||||||
'fish': ['fish', 'cod', 'tilapia', 'halibut', 'pollock'],
|
'leafy_greens': ['lettuce', 'spinach', 'kale', 'arugula', 'mixed greens', 'salad'],
|
||||||
# Specific chicken cuts only — bare 'chicken' handled in generic fallback
|
|
||||||
'chicken': ['chicken breast', 'chicken thigh', 'chicken wings', 'chicken leg',
|
|
||||||
'whole chicken', 'rotisserie chicken', 'raw chicken'],
|
|
||||||
'turkey': ['turkey breast', 'whole turkey'],
|
|
||||||
'ground_meat': ['ground beef', 'ground pork', 'ground chicken', 'ground turkey',
|
|
||||||
'ground lamb', 'ground bison'],
|
|
||||||
'pork': ['pork', 'bacon', 'ham', 'pork chop', 'pork loin'],
|
|
||||||
'beef': ['beef', 'steak', 'brisket', 'ribeye', 'sirloin', 'roast beef'],
|
|
||||||
'deli_meat': ['deli', 'sliced turkey', 'sliced ham', 'lunch meat', 'cold cuts',
|
|
||||||
'prosciutto', 'salami', 'pepperoni'],
|
|
||||||
# ── Produce ───────────────────────────────────────────────────────────
|
|
||||||
'leafy_greens': ['lettuce', 'spinach', 'kale', 'arugula', 'mixed greens'],
|
|
||||||
'berries': ['strawberries', 'blueberries', 'raspberries', 'blackberries'],
|
'berries': ['strawberries', 'blueberries', 'raspberries', 'blackberries'],
|
||||||
'apples': ['apple', 'apples'],
|
'apples': ['apple', 'apples'],
|
||||||
'bananas': ['banana', 'bananas'],
|
'bananas': ['banana', 'bananas'],
|
||||||
'citrus': ['orange', 'lemon', 'lime', 'grapefruit', 'tangerine'],
|
'citrus': ['orange', 'lemon', 'lime', 'grapefruit', 'tangerine'],
|
||||||
# ── Bakery ────────────────────────────────────────────────────────────
|
'bread': ['bread', 'loaf', 'baguette', 'roll', 'bagel', 'bun'],
|
||||||
'bakery': [
|
'bakery': ['muffin', 'croissant', 'donut', 'danish', 'pastry'],
|
||||||
'muffin', 'croissant', 'donut', 'danish', 'puff pastry', 'pastry puff',
|
'deli_meat': ['deli', 'sliced turkey', 'sliced ham', 'lunch meat', 'cold cuts'],
|
||||||
'cinnamon roll', 'dinner roll', 'parkerhouse roll', 'scone',
|
'frozen_vegetables': ['frozen veg', 'frozen corn', 'frozen peas', 'frozen broccoli'],
|
||||||
],
|
'frozen_fruit': ['frozen berries', 'frozen mango', 'frozen strawberries'],
|
||||||
'bread': ['bread', 'loaf', 'baguette', 'bagel', 'bun', 'pita', 'naan',
|
'ice_cream': ['ice cream', 'gelato', 'frozen yogurt'],
|
||||||
'english muffin', 'sourdough'],
|
'pasta': ['pasta', 'spaghetti', 'penne', 'macaroni', 'noodles'],
|
||||||
# ── Dry pantry staples ────────────────────────────────────────────────
|
'rice': ['rice', 'brown rice', 'white rice', 'jasmine'],
|
||||||
'pasta': ['pasta', 'spaghetti', 'penne', 'macaroni', 'noodles', 'couscous', 'orzo'],
|
|
||||||
'rice': ['rice', 'brown rice', 'white rice', 'jasmine rice', 'basmati',
|
|
||||||
'spanish rice', 'rice mix'],
|
|
||||||
'cereal': ['cereal', 'granola', 'oatmeal'],
|
'cereal': ['cereal', 'granola', 'oatmeal'],
|
||||||
'chips': ['chips', 'crisps', 'tortilla chips', 'pretzel', 'popcorn'],
|
'chips': ['chips', 'crisps', 'tortilla chips'],
|
||||||
'cookies': ['cookies', 'biscuits', 'crackers', 'graham cracker', 'wafer'],
|
'cookies': ['cookies', 'biscuits', 'crackers'],
|
||||||
# ── Beverages ─────────────────────────────────────────────────────────
|
'ketchup': ['ketchup', 'catsup'],
|
||||||
'juice': ['juice', 'orange juice', 'apple juice', 'lemonade'],
|
'mustard': ['mustard'],
|
||||||
'soda': ['soda', 'cola', 'sprite', 'pepsi', 'coke', 'carbonated soft drink'],
|
'mayo': ['mayo', 'mayonnaise', 'miracle whip'],
|
||||||
|
'salad_dressing': ['salad dressing', 'ranch', 'italian dressing', 'vinaigrette'],
|
||||||
|
'soy_sauce': ['soy sauce', 'tamari'],
|
||||||
|
'juice': ['juice', 'orange juice', 'apple juice'],
|
||||||
|
'soda': ['soda', 'pop', 'cola', 'sprite', 'pepsi', 'coke'],
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|
@ -284,13 +176,8 @@ class ExpirationPredictor:
|
||||||
product_name: str,
|
product_name: str,
|
||||||
product_category: Optional[str] = None,
|
product_category: Optional[str] = None,
|
||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
location: Optional[str] = None,
|
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Determine category from product name, existing category, and tags.
|
"""Determine category from product name, existing category, and tags."""
|
||||||
|
|
||||||
location is used as a last-resort hint: unknown items in the freezer
|
|
||||||
default to frozen_foods rather than dry_goods.
|
|
||||||
"""
|
|
||||||
if product_category:
|
if product_category:
|
||||||
cat = product_category.lower().strip()
|
cat = product_category.lower().strip()
|
||||||
if cat in self.SHELF_LIFE:
|
if cat in self.SHELF_LIFE:
|
||||||
|
|
@ -310,36 +197,21 @@ class ExpirationPredictor:
|
||||||
if any(kw in name for kw in keywords):
|
if any(kw in name for kw in keywords):
|
||||||
return category
|
return category
|
||||||
|
|
||||||
# Generic single-word fallbacks — checked after the keyword dict so
|
|
||||||
# multi-word phrases (e.g. "canned chicken") already matched above.
|
|
||||||
for words, fallback in [
|
for words, fallback in [
|
||||||
(['frozen'], 'frozen_foods'),
|
(['meat', 'beef', 'pork', 'chicken'], 'meat'),
|
||||||
(['canned', 'tinned'], 'canned_goods'),
|
|
||||||
# bare 'chicken' / 'sausage' / 'ham' kept here so raw-meat names
|
|
||||||
# that don't appear in the specific keyword lists still resolve.
|
|
||||||
(['chicken', 'turkey'], 'poultry'),
|
|
||||||
(['sausage', 'ham', 'bacon'], 'pork'),
|
|
||||||
(['beef', 'steak'], 'beef'),
|
|
||||||
(['meat', 'pork'], 'meat'),
|
|
||||||
(['vegetable', 'veggie', 'produce'], 'vegetables'),
|
(['vegetable', 'veggie', 'produce'], 'vegetables'),
|
||||||
(['fruit'], 'fruits'),
|
(['fruit'], 'fruits'),
|
||||||
(['dairy'], 'dairy'),
|
(['dairy'], 'dairy'),
|
||||||
|
(['frozen'], 'frozen_foods'),
|
||||||
]:
|
]:
|
||||||
if any(w in name for w in words):
|
if any(w in name for w in words):
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
# Location-aware final fallback: unknown item in a freezer → frozen_foods.
|
|
||||||
# This handles unlabelled frozen products (e.g. "Birthday Littles",
|
|
||||||
# "Pulled BBQ Crumbles") without requiring every brand name to be listed.
|
|
||||||
canon_loc = self._normalize_location(location or '')
|
|
||||||
if canon_loc == 'freezer':
|
|
||||||
return 'frozen_foods'
|
|
||||||
|
|
||||||
return 'dry_goods'
|
return 'dry_goods'
|
||||||
|
|
||||||
def get_shelf_life_info(self, category: str, location: str) -> Optional[int]:
|
def get_shelf_life_info(self, category: str, location: str) -> Optional[int]:
|
||||||
"""Shelf life in days for a given category + location, or None."""
|
"""Shelf life in days for a given category + location, or None."""
|
||||||
return self._lookup_days(category, location)
|
return self.SHELF_LIFE.get(category.lower().strip(), {}).get(location)
|
||||||
|
|
||||||
def list_categories(self) -> List[str]:
|
def list_categories(self) -> List[str]:
|
||||||
return list(self.SHELF_LIFE.keys())
|
return list(self.SHELF_LIFE.keys())
|
||||||
|
|
@ -352,18 +224,8 @@ class ExpirationPredictor:
|
||||||
|
|
||||||
# ── Private helpers ───────────────────────────────────────────────────────
|
# ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _normalize_location(self, location: str) -> str:
|
|
||||||
"""Resolve location aliases to canonical names."""
|
|
||||||
loc = location.lower().strip()
|
|
||||||
return self.LOCATION_ALIASES.get(loc, loc)
|
|
||||||
|
|
||||||
def _lookup_days(self, category: Optional[str], location: str) -> Optional[int]:
|
def _lookup_days(self, category: Optional[str], location: str) -> Optional[int]:
|
||||||
"""Pure deterministic lookup — no I/O.
|
"""Pure deterministic lookup — no I/O."""
|
||||||
|
|
||||||
Normalises location aliases (e.g. garage_freezer → freezer) and uses
|
|
||||||
a context-aware fallback order so pantry items don't accidentally get
|
|
||||||
fridge shelf-life and vice versa.
|
|
||||||
"""
|
|
||||||
if not category:
|
if not category:
|
||||||
return None
|
return None
|
||||||
cat = category.lower().strip()
|
cat = category.lower().strip()
|
||||||
|
|
@ -375,19 +237,13 @@ class ExpirationPredictor:
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
canon_loc = self._normalize_location(location)
|
days = self.SHELF_LIFE[cat].get(location)
|
||||||
shelf = self.SHELF_LIFE[cat]
|
if days is None:
|
||||||
|
for loc in ('fridge', 'pantry', 'freezer', 'cabinet'):
|
||||||
# Try the canonical location first, then work through the
|
days = self.SHELF_LIFE[cat].get(loc)
|
||||||
# context-aware fallback chain for that location type.
|
|
||||||
fallback_order = self.LOCATION_FALLBACK.get(
|
|
||||||
canon_loc, (canon_loc, 'pantry', 'fridge', 'cabinet', 'freezer')
|
|
||||||
)
|
|
||||||
for loc in fallback_order:
|
|
||||||
days = shelf.get(loc)
|
|
||||||
if days is not None:
|
if days is not None:
|
||||||
|
break
|
||||||
return days
|
return days
|
||||||
return None
|
|
||||||
|
|
||||||
def _llm_predict_days(
|
def _llm_predict_days(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"""Thin HTTP client for the cf-docuvision document vision service."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DocuvisionResult:
|
|
||||||
text: str
|
|
||||||
confidence: float | None = None
|
|
||||||
raw: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class DocuvisionClient:
|
|
||||||
"""Thin client for the cf-docuvision service."""
|
|
||||||
|
|
||||||
def __init__(self, base_url: str) -> None:
|
|
||||||
self._base_url = base_url.rstrip("/")
|
|
||||||
|
|
||||||
def extract_text(self, image_path: str | Path) -> DocuvisionResult:
|
|
||||||
"""Send an image to docuvision and return extracted text."""
|
|
||||||
image_bytes = Path(image_path).read_bytes()
|
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
|
||||||
|
|
||||||
with httpx.Client(timeout=30.0) as client:
|
|
||||||
resp = client.post(
|
|
||||||
f"{self._base_url}/extract",
|
|
||||||
json={"image": b64},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
return DocuvisionResult(
|
|
||||||
text=data.get("text", ""),
|
|
||||||
confidence=data.get("confidence"),
|
|
||||||
raw=data,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def extract_text_async(self, image_path: str | Path) -> DocuvisionResult:
|
|
||||||
"""Async version."""
|
|
||||||
image_bytes = Path(image_path).read_bytes()
|
|
||||||
b64 = base64.b64encode(image_bytes).decode()
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{self._base_url}/extract",
|
|
||||||
json={"image": b64},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
return DocuvisionResult(
|
|
||||||
text=data.get("text", ""),
|
|
||||||
confidence=data.get("confidence"),
|
|
||||||
raw=data,
|
|
||||||
)
|
|
||||||
|
|
@ -8,7 +8,6 @@ OCR with understanding of receipt structure to extract structured JSON data.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
@ -27,32 +26,6 @@ from app.core.config import settings
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _try_docuvision(image_path: str | Path) -> str | None:
|
|
||||||
"""Try to extract text via cf-docuvision. Returns None if unavailable."""
|
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
|
||||||
if not cf_orch_url:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
from circuitforge_orch.client import CFOrchClient
|
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
|
||||||
|
|
||||||
client = CFOrchClient(cf_orch_url)
|
|
||||||
with client.allocate(
|
|
||||||
service="cf-docuvision",
|
|
||||||
model_candidates=["cf-docuvision"],
|
|
||||||
ttl_s=60.0,
|
|
||||||
caller="kiwi-ocr",
|
|
||||||
) as alloc:
|
|
||||||
if alloc is None:
|
|
||||||
return None
|
|
||||||
doc_client = DocuvisionClient(alloc.url)
|
|
||||||
result = doc_client.extract_text(image_path)
|
|
||||||
return result.text if result.text else None
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("cf-docuvision fast-path failed, falling back: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class VisionLanguageOCR:
|
class VisionLanguageOCR:
|
||||||
"""Vision-Language Model for receipt OCR and structured extraction."""
|
"""Vision-Language Model for receipt OCR and structured extraction."""
|
||||||
|
|
||||||
|
|
@ -67,7 +40,7 @@ class VisionLanguageOCR:
|
||||||
self.processor = None
|
self.processor = None
|
||||||
self.device = "cuda" if torch.cuda.is_available() and settings.USE_GPU else "cpu"
|
self.device = "cuda" if torch.cuda.is_available() and settings.USE_GPU else "cpu"
|
||||||
self.use_quantization = use_quantization
|
self.use_quantization = use_quantization
|
||||||
self.model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
|
self.model_name = "Qwen/Qwen2-VL-2B-Instruct"
|
||||||
|
|
||||||
logger.info(f"Initializing VisionLanguageOCR with device: {self.device}")
|
logger.info(f"Initializing VisionLanguageOCR with device: {self.device}")
|
||||||
|
|
||||||
|
|
@ -139,18 +112,6 @@ class VisionLanguageOCR:
|
||||||
"warnings": [...]
|
"warnings": [...]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# Try docuvision fast path first (skips heavy local VLM if available)
|
|
||||||
docuvision_text = _try_docuvision(image_path)
|
|
||||||
if docuvision_text is not None:
|
|
||||||
parsed = self._parse_json_from_text(docuvision_text)
|
|
||||||
# Only accept the docuvision result if it yielded meaningful content;
|
|
||||||
# an empty-skeleton dict (no items, no merchant) means the text was
|
|
||||||
# garbled and we should fall through to the local VLM instead.
|
|
||||||
if parsed.get("items") or parsed.get("merchant"):
|
|
||||||
parsed["raw_text"] = docuvision_text
|
|
||||||
return self._validate_result(parsed)
|
|
||||||
# Parsed result has no meaningful content — fall through to local VLM
|
|
||||||
|
|
||||||
self._load_model()
|
self._load_model()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,681 +0,0 @@
|
||||||
"""
|
|
||||||
Assembly-dish template matcher for Level 1/2.
|
|
||||||
|
|
||||||
Assembly dishes (burritos, stir fry, fried rice, omelettes, sandwiches, etc.)
|
|
||||||
are defined by structural roles -- container + filler + sauce -- not by a fixed
|
|
||||||
ingredient list. The corpus can never fully cover them.
|
|
||||||
|
|
||||||
This module fires when the pantry covers all *required* roles of a template.
|
|
||||||
Results are injected at the top of the Level 1/2 suggestion list with negative
|
|
||||||
ids (client displays them identically to corpus recipes).
|
|
||||||
|
|
||||||
Templates define:
|
|
||||||
- required: list of role sets -- ALL must have at least one pantry match
|
|
||||||
- optional: role sets whose matched items are shown as extras
|
|
||||||
- directions: short cooking instructions
|
|
||||||
- notes: serving suggestions / variations
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from app.models.schemas.recipe import RecipeSuggestion
|
|
||||||
|
|
||||||
|
|
||||||
# IDs in range -100..-1 are reserved for assembly-generated suggestions
|
|
||||||
_ASSEMBLY_ID_START = -1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AssemblyRole:
|
|
||||||
"""One role in a template (e.g. 'protein').
|
|
||||||
|
|
||||||
display: human-readable role label
|
|
||||||
keywords: substrings matched against pantry item (lowercased)
|
|
||||||
"""
|
|
||||||
display: str
|
|
||||||
keywords: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AssemblyTemplate:
|
|
||||||
"""A template assembly dish."""
|
|
||||||
id: int
|
|
||||||
title: str
|
|
||||||
required: list[AssemblyRole]
|
|
||||||
optional: list[AssemblyRole]
|
|
||||||
directions: list[str]
|
|
||||||
notes: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def _matches_role(role: AssemblyRole, pantry_set: set[str]) -> list[str]:
|
|
||||||
"""Return pantry items that satisfy this role.
|
|
||||||
|
|
||||||
Single-word keywords use whole-word matching (word must appear as a
|
|
||||||
discrete token) so short words like 'pea', 'ham', 'egg' don't false-match
|
|
||||||
inside longer words like 'peanut', 'hamburger', 'eggnog'.
|
|
||||||
Multi-word keywords (e.g. 'burger patt') use substring matching.
|
|
||||||
"""
|
|
||||||
hits: list[str] = []
|
|
||||||
for item in pantry_set:
|
|
||||||
item_lower = item.lower()
|
|
||||||
item_words = set(item_lower.split())
|
|
||||||
for kw in role.keywords:
|
|
||||||
if " " in kw:
|
|
||||||
# Multi-word: substring match
|
|
||||||
if kw in item_lower:
|
|
||||||
hits.append(item)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Single-word: whole-word match only
|
|
||||||
if kw in item_words:
|
|
||||||
hits.append(item)
|
|
||||||
break
|
|
||||||
return hits
|
|
||||||
|
|
||||||
|
|
||||||
def _pick_one(items: list[str], seed: int) -> str:
|
|
||||||
"""Deterministically pick one item from a list using a seed."""
|
|
||||||
return sorted(items)[seed % len(items)]
|
|
||||||
|
|
||||||
|
|
||||||
def _pantry_hash(pantry_set: set[str]) -> int:
|
|
||||||
"""Stable integer derived from pantry contents — used for deterministic picks."""
|
|
||||||
key = ",".join(sorted(pantry_set))
|
|
||||||
return int(hashlib.md5(key.encode()).hexdigest(), 16) # noqa: S324 — non-crypto use
|
|
||||||
|
|
||||||
|
|
||||||
def _keyword_label(item: str, role: AssemblyRole) -> str:
|
|
||||||
"""Return a short, clean label derived from the keyword that matched.
|
|
||||||
|
|
||||||
Uses the longest matching keyword (most specific) as the base label,
|
|
||||||
then title-cases it. This avoids pasting full raw pantry names like
|
|
||||||
'Organic Extra Firm Tofu' into titles — just 'Tofu' instead.
|
|
||||||
"""
|
|
||||||
lower = item.lower()
|
|
||||||
best_kw = ""
|
|
||||||
for kw in role.keywords:
|
|
||||||
if kw in lower and len(kw) > len(best_kw):
|
|
||||||
best_kw = kw
|
|
||||||
label = (best_kw or item).strip().title()
|
|
||||||
# Drop trailing 's' from keywords like "beans" → "Bean" when it reads better
|
|
||||||
return label
|
|
||||||
|
|
||||||
|
|
||||||
def _personalized_title(tmpl: AssemblyTemplate, pantry_set: set[str], seed: int) -> str:
|
|
||||||
"""Build a specific title using actual pantry items, e.g. 'Chicken & Broccoli Burrito'.
|
|
||||||
|
|
||||||
Uses the matched keyword as the label (not the full pantry item name) so
|
|
||||||
'Organic Extra Firm Tofu Block' → 'Tofu' in the title.
|
|
||||||
Picks at most two roles; prefers protein then vegetable.
|
|
||||||
"""
|
|
||||||
priority_displays = ["protein", "vegetables", "sauce base", "cheese"]
|
|
||||||
|
|
||||||
picked: list[str] = []
|
|
||||||
for display in priority_displays:
|
|
||||||
for role in tmpl.optional:
|
|
||||||
if role.display != display:
|
|
||||||
continue
|
|
||||||
hits = _matches_role(role, pantry_set)
|
|
||||||
if hits:
|
|
||||||
item = _pick_one(hits, seed)
|
|
||||||
label = _keyword_label(item, role)
|
|
||||||
if label not in picked:
|
|
||||||
picked.append(label)
|
|
||||||
if len(picked) >= 2:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not picked:
|
|
||||||
return tmpl.title
|
|
||||||
return f"{' & '.join(picked)} {tmpl.title}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Template definitions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-1,
|
|
||||||
title="Burrito / Taco",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("tortilla or wrap", [
|
|
||||||
"tortilla", "wrap", "taco shell", "flatbread", "pita",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("protein", [
|
|
||||||
"chicken", "beef", "steak", "pork", "sausage", "hamburger",
|
|
||||||
"burger patt", "shrimp", "egg", "tofu", "beans", "bean",
|
|
||||||
]),
|
|
||||||
AssemblyRole("rice or starch", ["rice", "quinoa", "potato"]),
|
|
||||||
AssemblyRole("cheese", [
|
|
||||||
"cheese", "cheddar", "mozzarella", "monterey", "queso",
|
|
||||||
]),
|
|
||||||
AssemblyRole("salsa or sauce", [
|
|
||||||
"salsa", "hot sauce", "taco sauce", "enchilada", "guacamole",
|
|
||||||
]),
|
|
||||||
AssemblyRole("sour cream or yogurt", ["sour cream", "greek yogurt", "crema"]),
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"pepper", "onion", "tomato", "lettuce", "corn", "avocado",
|
|
||||||
"spinach", "broccoli", "zucchini",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Warm the tortilla in a dry skillet or microwave for 20 seconds.",
|
|
||||||
"Heat any proteins or vegetables in a pan until cooked through.",
|
|
||||||
"Layer ingredients down the center: rice first, then protein, then vegetables.",
|
|
||||||
"Add cheese, salsa, and sour cream last so they stay cool.",
|
|
||||||
"Fold in the sides and roll tightly. Optionally toast seam-side down 1-2 minutes.",
|
|
||||||
],
|
|
||||||
notes="Works as a burrito (rolled), taco (folded), or quesadilla (cheese only, pressed flat).",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-2,
|
|
||||||
title="Fried Rice",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("cooked rice", [
|
|
||||||
"rice", "leftover rice", "instant rice", "microwavable rice",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("protein", [
|
|
||||||
"chicken", "beef", "pork", "shrimp", "egg", "tofu",
|
|
||||||
"sausage", "ham", "spam",
|
|
||||||
]),
|
|
||||||
AssemblyRole("soy sauce or seasoning", [
|
|
||||||
"soy sauce", "tamari", "teriyaki", "oyster sauce", "fish sauce",
|
|
||||||
]),
|
|
||||||
AssemblyRole("oil", ["oil", "butter", "sesame"]),
|
|
||||||
AssemblyRole("egg", ["egg"]),
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"carrot", "peas", "corn", "onion", "scallion", "green onion",
|
|
||||||
"broccoli", "bok choy", "bean sprout", "zucchini", "spinach",
|
|
||||||
]),
|
|
||||||
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Use day-old cold rice if available -- it fries better than fresh.",
|
|
||||||
"Heat oil in a large skillet or wok over high heat.",
|
|
||||||
"Add garlic/ginger and any raw vegetables; stir fry 2-3 minutes.",
|
|
||||||
"Push to the side, scramble eggs in the same pan if using.",
|
|
||||||
"Add protein (pre-cooked or raw) and cook through.",
|
|
||||||
"Add rice, breaking up clumps. Stir fry until heated and lightly toasted.",
|
|
||||||
"Season with soy sauce and any other sauces. Toss to combine.",
|
|
||||||
],
|
|
||||||
notes="Add a fried egg on top. A drizzle of sesame oil at the end adds a lot.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-3,
|
|
||||||
title="Omelette / Scramble",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("eggs", ["egg"]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("cheese", [
|
|
||||||
"cheese", "cheddar", "mozzarella", "feta", "parmesan",
|
|
||||||
]),
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"pepper", "onion", "tomato", "spinach", "mushroom",
|
|
||||||
"broccoli", "zucchini", "scallion", "avocado",
|
|
||||||
]),
|
|
||||||
AssemblyRole("protein", [
|
|
||||||
"ham", "bacon", "sausage", "chicken", "turkey",
|
|
||||||
"smoked salmon",
|
|
||||||
]),
|
|
||||||
AssemblyRole("herbs or seasoning", [
|
|
||||||
"herb", "basil", "chive", "parsley", "salt", "pepper",
|
|
||||||
"hot sauce", "salsa",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Beat eggs with a splash of water or milk and a pinch of salt.",
|
|
||||||
"Saute any vegetables and proteins in butter or oil over medium heat until softened.",
|
|
||||||
"Pour eggs over fillings (scramble) or pour into a clean buttered pan (omelette).",
|
|
||||||
"For omelette: cook until nearly set, add fillings to one side, fold over.",
|
|
||||||
"For scramble: stir gently over medium-low heat until just set.",
|
|
||||||
"Season and serve immediately.",
|
|
||||||
],
|
|
||||||
notes="Works for breakfast, lunch, or a quick dinner. Any leftover vegetables work well.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-4,
|
|
||||||
title="Stir Fry",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"pepper", "broccoli", "carrot", "snap pea", "bok choy",
|
|
||||||
"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", [
|
|
||||||
"chicken", "beef", "pork", "shrimp", "tofu", "egg",
|
|
||||||
]),
|
|
||||||
AssemblyRole("sauce", [
|
|
||||||
"soy sauce", "teriyaki", "oyster sauce", "hoisin",
|
|
||||||
"stir fry sauce", "sesame",
|
|
||||||
]),
|
|
||||||
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
|
|
||||||
AssemblyRole("oil", ["oil", "sesame"]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Cut all proteins and vegetables into similar-sized pieces for even cooking.",
|
|
||||||
"Heat oil in a wok or large skillet over the highest heat your stove allows.",
|
|
||||||
"Cook protein first until nearly done; remove and set aside.",
|
|
||||||
"Add dense vegetables (carrots, broccoli) first; quick-cooking veg last.",
|
|
||||||
"Return protein, add sauce, and toss everything together for 1-2 minutes.",
|
|
||||||
"Serve over rice or noodles.",
|
|
||||||
],
|
|
||||||
notes="High heat is the key. Do not crowd the pan -- cook in batches if needed.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-5,
|
|
||||||
title="Pasta with Whatever You Have",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("pasta", [
|
|
||||||
"pasta", "spaghetti", "penne", "fettuccine", "rigatoni",
|
|
||||||
"linguine", "rotini", "farfalle", "macaroni", "noodle",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("sauce base", [
|
|
||||||
"tomato", "marinara", "pasta sauce", "cream", "butter",
|
|
||||||
"olive oil", "pesto",
|
|
||||||
]),
|
|
||||||
AssemblyRole("protein", [
|
|
||||||
"chicken", "beef", "pork", "shrimp", "sausage", "bacon",
|
|
||||||
"ham", "tuna", "canned fish",
|
|
||||||
]),
|
|
||||||
AssemblyRole("cheese", [
|
|
||||||
"parmesan", "romano", "mozzarella", "ricotta", "feta",
|
|
||||||
]),
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"tomato", "spinach", "mushroom", "pepper", "zucchini",
|
|
||||||
"broccoli", "artichoke", "olive", "onion",
|
|
||||||
]),
|
|
||||||
AssemblyRole("garlic", ["garlic"]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Cook pasta in well-salted boiling water until al dente. Reserve 1 cup pasta water.",
|
|
||||||
"While pasta cooks, saute garlic in olive oil over medium heat.",
|
|
||||||
"Add proteins and cook through; add vegetables until tender.",
|
|
||||||
"Add sauce base and simmer 5 minutes. Add pasta water to loosen if needed.",
|
|
||||||
"Toss cooked pasta with sauce. Finish with cheese if using.",
|
|
||||||
],
|
|
||||||
notes="Pasta water is the secret -- the starch thickens and binds any sauce.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-6,
|
|
||||||
title="Sandwich / Wrap",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("bread or wrap", [
|
|
||||||
"bread", "roll", "bun", "wrap", "tortilla", "pita",
|
|
||||||
"bagel", "english muffin", "croissant", "flatbread",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("protein", [
|
|
||||||
"chicken", "turkey", "ham", "roast beef", "tuna", "egg",
|
|
||||||
"bacon", "salami", "pepperoni", "tofu", "tempeh",
|
|
||||||
]),
|
|
||||||
AssemblyRole("cheese", [
|
|
||||||
"cheese", "cheddar", "swiss", "provolone", "mozzarella",
|
|
||||||
]),
|
|
||||||
AssemblyRole("condiment", [
|
|
||||||
"mayo", "mustard", "ketchup", "hot sauce", "ranch",
|
|
||||||
"hummus", "pesto", "aioli",
|
|
||||||
]),
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"lettuce", "tomato", "onion", "cucumber", "avocado",
|
|
||||||
"pepper", "sprout", "arugula",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Toast bread if desired.",
|
|
||||||
"Spread condiments on both inner surfaces.",
|
|
||||||
"Layer protein first, then cheese, then vegetables.",
|
|
||||||
"Press together and cut diagonally.",
|
|
||||||
],
|
|
||||||
notes="Leftovers, deli meat, canned fish -- nearly anything works between bread.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-7,
|
|
||||||
title="Grain Bowl",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("grain base", [
|
|
||||||
"rice", "quinoa", "farro", "barley", "couscous",
|
|
||||||
"bulgur", "freekeh", "polenta",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("protein", [
|
|
||||||
"chicken", "beef", "pork", "tofu", "egg", "shrimp",
|
|
||||||
"beans", "bean", "lentil", "chickpea",
|
|
||||||
]),
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"roasted", "broccoli", "carrot", "kale", "spinach",
|
|
||||||
"cucumber", "tomato", "corn", "edamame", "avocado",
|
|
||||||
"beet", "sweet potato",
|
|
||||||
]),
|
|
||||||
AssemblyRole("dressing or sauce", [
|
|
||||||
"dressing", "tahini", "vinaigrette", "sauce",
|
|
||||||
"olive oil", "lemon", "soy sauce",
|
|
||||||
]),
|
|
||||||
AssemblyRole("toppings", [
|
|
||||||
"nut", "seed", "feta", "parmesan", "herb",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Cook grain base according to package directions; season with salt.",
|
|
||||||
"Roast or saute vegetables with oil, salt, and pepper until tender.",
|
|
||||||
"Cook or slice protein.",
|
|
||||||
"Arrange grain in a bowl, top with protein and vegetables.",
|
|
||||||
"Drizzle with dressing and add toppings.",
|
|
||||||
],
|
|
||||||
notes="Great for meal prep -- cook grains and proteins in bulk, assemble bowls all week.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-8,
|
|
||||||
title="Soup / Stew",
|
|
||||||
required=[
|
|
||||||
# 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=[
|
|
||||||
AssemblyRole("protein", [
|
|
||||||
"chicken", "beef", "pork", "sausage", "shrimp",
|
|
||||||
"beans", "bean", "lentil", "tofu",
|
|
||||||
]),
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"carrot", "celery", "onion", "potato", "tomato",
|
|
||||||
"spinach", "kale", "corn", "pea", "zucchini",
|
|
||||||
]),
|
|
||||||
AssemblyRole("starch thickener", [
|
|
||||||
"potato", "pasta", "noodle", "rice", "barley",
|
|
||||||
"flour", "cornstarch",
|
|
||||||
]),
|
|
||||||
AssemblyRole("seasoning", [
|
|
||||||
"garlic", "herb", "bay leaf", "thyme", "rosemary",
|
|
||||||
"cumin", "paprika", "chili",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Saute onion, celery, and garlic in oil until softened, about 5 minutes.",
|
|
||||||
"Add any raw proteins and cook until browned.",
|
|
||||||
"Add broth or liquid base and bring to a simmer.",
|
|
||||||
"Add dense vegetables (carrots, potatoes) first; quick-cooking veg in the last 10 minutes.",
|
|
||||||
"Add starches and cook until tender.",
|
|
||||||
"Season to taste and simmer at least 20 minutes for flavors to develop.",
|
|
||||||
],
|
|
||||||
notes="Soups and stews improve overnight in the fridge. Almost any combination works.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-9,
|
|
||||||
title="Casserole / Bake",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("starch or base", [
|
|
||||||
"pasta", "rice", "potato", "noodle", "bread",
|
|
||||||
"tortilla", "polenta", "grits", "macaroni",
|
|
||||||
]),
|
|
||||||
AssemblyRole("binder or sauce", [
|
|
||||||
"cream of", "cheese", "cream cheese", "sour cream",
|
|
||||||
"soup mix", "gravy", "tomato sauce", "marinara",
|
|
||||||
"broth", "stock", "milk", "cream",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("protein", [
|
|
||||||
"chicken", "beef", "pork", "tuna", "ham", "sausage",
|
|
||||||
"ground", "shrimp", "beans", "bean", "lentil",
|
|
||||||
]),
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"broccoli", "corn", "pea", "onion", "mushroom",
|
|
||||||
"spinach", "zucchini", "tomato", "pepper", "carrot",
|
|
||||||
]),
|
|
||||||
AssemblyRole("cheese topping", [
|
|
||||||
"cheddar", "mozzarella", "parmesan", "swiss",
|
|
||||||
"cheese", "breadcrumb",
|
|
||||||
]),
|
|
||||||
AssemblyRole("seasoning", [
|
|
||||||
"garlic", "herb", "thyme", "rosemary", "paprika",
|
|
||||||
"onion powder", "salt", "pepper",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Preheat oven to 375 F (190 C). Grease a 9x13 baking dish.",
|
|
||||||
"Cook starch base (pasta, rice, potato) until just underdone -- it finishes in the oven.",
|
|
||||||
"Mix cooked starch with sauce/binder, protein, and vegetables in the dish.",
|
|
||||||
"Season generously -- casseroles need salt.",
|
|
||||||
"Top with cheese or breadcrumbs if using.",
|
|
||||||
"Bake covered 25 minutes, then uncovered 15 minutes until golden and bubbly.",
|
|
||||||
],
|
|
||||||
notes="Classic pantry dump dinner. Cream of anything soup is the universal binder.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-10,
|
|
||||||
title="Pancakes / Waffles / Quick Bread",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("flour or baking mix", [
|
|
||||||
"flour", "bisquick", "pancake mix", "waffle mix",
|
|
||||||
"baking mix", "cornmeal", "oats",
|
|
||||||
]),
|
|
||||||
AssemblyRole("leavening or egg", [
|
|
||||||
"egg", "baking powder", "baking soda", "yeast",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("liquid", [
|
|
||||||
"milk", "buttermilk", "water", "juice",
|
|
||||||
"almond milk", "oat milk", "sour cream",
|
|
||||||
]),
|
|
||||||
AssemblyRole("fat", [
|
|
||||||
"butter", "oil", "margarine",
|
|
||||||
]),
|
|
||||||
AssemblyRole("sweetener", [
|
|
||||||
"sugar", "honey", "maple syrup", "brown sugar",
|
|
||||||
]),
|
|
||||||
AssemblyRole("mix-ins", [
|
|
||||||
"blueberr", "banana", "apple", "chocolate chip",
|
|
||||||
"nut", "berry", "cinnamon", "vanilla",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Whisk dry ingredients (flour, leavening, sugar, salt) together in a bowl.",
|
|
||||||
"Whisk wet ingredients (egg, milk, melted butter) in a separate bowl.",
|
|
||||||
"Fold wet into dry until just combined -- lumps are fine, do not overmix.",
|
|
||||||
"For pancakes: cook on a buttered griddle over medium heat, flip when bubbles form.",
|
|
||||||
"For waffles: pour into preheated waffle iron according to manufacturer instructions.",
|
|
||||||
"For muffins or quick bread: pour into greased pan, bake at 375 F until a toothpick comes out clean.",
|
|
||||||
],
|
|
||||||
notes="Overmixing develops gluten and makes pancakes tough. Stop when just combined.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-11,
|
|
||||||
title="Porridge / Oatmeal",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("oats or grain porridge", [
|
|
||||||
"oat", "porridge", "grits", "semolina", "cream of wheat",
|
|
||||||
"polenta", "congee", "rice porridge",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("liquid", ["milk", "water", "almond milk", "oat milk", "coconut milk"]),
|
|
||||||
AssemblyRole("sweetener", ["sugar", "honey", "maple syrup", "brown sugar", "agave"]),
|
|
||||||
AssemblyRole("fruit", ["banana", "berry", "apple", "raisin", "date", "mango"]),
|
|
||||||
AssemblyRole("toppings", ["nut", "seed", "granola", "coconut", "chocolate"]),
|
|
||||||
AssemblyRole("spice", ["cinnamon", "nutmeg", "vanilla", "cardamom"]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"Combine oats with liquid in a pot — typically 1 part oats to 2 parts liquid.",
|
|
||||||
"Bring to a gentle simmer over medium heat, stirring occasionally.",
|
|
||||||
"Cook 5 minutes (rolled oats) or 2 minutes (quick oats) until thickened to your liking.",
|
|
||||||
"Stir in sweetener and spices.",
|
|
||||||
"Top with fruit, nuts, or seeds and serve immediately.",
|
|
||||||
],
|
|
||||||
notes="Overnight oats: skip cooking — soak oats in cold milk overnight in the fridge.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-12,
|
|
||||||
title="Pie / Pot Pie",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("pastry or crust", [
|
|
||||||
"pastry", "puff pastry", "pie crust", "shortcrust",
|
|
||||||
"pie shell", "phyllo", "filo", "biscuit",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
optional=[
|
|
||||||
AssemblyRole("protein filling", [
|
|
||||||
"chicken", "beef", "pork", "lamb", "turkey", "tofu",
|
|
||||||
"mushroom", "beans", "bean", "lentil", "tuna", "salmon",
|
|
||||||
]),
|
|
||||||
AssemblyRole("vegetables", [
|
|
||||||
"carrot", "pea", "corn", "potato", "onion", "leek",
|
|
||||||
"broccoli", "spinach", "mushroom", "parsnip", "swede",
|
|
||||||
]),
|
|
||||||
AssemblyRole("sauce or binder", [
|
|
||||||
"gravy", "cream of", "stock", "broth", "cream",
|
|
||||||
"white sauce", "bechamel", "cheese sauce",
|
|
||||||
]),
|
|
||||||
AssemblyRole("seasoning", [
|
|
||||||
"thyme", "rosemary", "sage", "garlic", "herb",
|
|
||||||
"mustard", "worcestershire",
|
|
||||||
]),
|
|
||||||
AssemblyRole("sweet filling", [
|
|
||||||
"apple", "berry", "cherry", "pear", "peach",
|
|
||||||
"rhubarb", "plum", "custard",
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"For pot pie: make a sauce by combining stock or cream-of-something with cooked vegetables and protein.",
|
|
||||||
"Season generously — fillings need more salt than you think.",
|
|
||||||
"Pour filling into a baking dish and top with pastry, pressing edges to seal.",
|
|
||||||
"Cut a few slits in the top to release steam. Brush with egg wash or milk if available.",
|
|
||||||
"Bake at 400 F (200 C) for 25-35 minutes until pastry is golden brown.",
|
|
||||||
"For sweet pie: fill unbaked crust with fruit filling, top with second crust or crumble, bake similarly.",
|
|
||||||
],
|
|
||||||
notes="Puff pastry from the freezer is the shortcut to impressive pot pies. Thaw in the fridge overnight.",
|
|
||||||
),
|
|
||||||
AssemblyTemplate(
|
|
||||||
id=-13,
|
|
||||||
title="Pudding / Custard",
|
|
||||||
required=[
|
|
||||||
AssemblyRole("dairy or dairy-free milk", [
|
|
||||||
"milk", "cream", "oat milk", "almond milk",
|
|
||||||
"soy milk", "coconut milk",
|
|
||||||
]),
|
|
||||||
AssemblyRole("thickener or set", [
|
|
||||||
"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"]),
|
|
||||||
AssemblyRole("flavouring", [
|
|
||||||
"vanilla", "chocolate", "cocoa", "caramel",
|
|
||||||
"lemon", "orange", "cinnamon", "nutmeg",
|
|
||||||
]),
|
|
||||||
AssemblyRole("starchy base", [
|
|
||||||
"rice", "bread", "sponge", "cake", "biscuit",
|
|
||||||
]),
|
|
||||||
AssemblyRole("fruit", ["raisin", "sultana", "berry", "banana", "apple"]),
|
|
||||||
],
|
|
||||||
directions=[
|
|
||||||
"For stovetop custard: whisk eggs and sugar together, heat milk until steaming.",
|
|
||||||
"Slowly pour hot milk into egg mixture while whisking constantly (tempering).",
|
|
||||||
"Return to low heat and stir until mixture coats the back of a spoon.",
|
|
||||||
"For cornstarch pudding: whisk cornstarch into cold milk first, then heat while stirring.",
|
|
||||||
"Add flavourings (vanilla, cocoa) once off heat.",
|
|
||||||
"Pour into dishes and refrigerate at least 2 hours to set.",
|
|
||||||
],
|
|
||||||
notes="UK-style pudding is broad — bread pudding, rice pudding, spotted dick, treacle sponge all count.",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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] = []
|
|
||||||
|
|
||||||
for tmpl in ASSEMBLY_TEMPLATES:
|
|
||||||
if tmpl.id in excluded:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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(matched) + expiry_bonus,
|
|
||||||
element_coverage={},
|
|
||||||
swap_candidates=[],
|
|
||||||
matched_ingredients=matched,
|
|
||||||
missing_ingredients=[],
|
|
||||||
directions=tmpl.directions,
|
|
||||||
notes=tmpl.notes,
|
|
||||||
level=1,
|
|
||||||
is_wildcard=False,
|
|
||||||
nutrition=None,
|
|
||||||
))
|
|
||||||
|
|
||||||
# Sort by optional coverage descending — best-matched templates first
|
|
||||||
results.sort(key=lambda s: s.match_count, reverse=True)
|
|
||||||
return results
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"""
|
|
||||||
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())
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
"""
|
|
||||||
ElementClassifier -- classify pantry items into culinary element tags.
|
|
||||||
|
|
||||||
Lookup order:
|
|
||||||
1. ingredient_profiles table (pre-computed from USDA FDC)
|
|
||||||
2. Keyword heuristic fallback (for unlisted ingredients)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
# All valid ingredient-level element labels (Method is recipe-level, not ingredient-level)
|
|
||||||
ELEMENTS = frozenset({
|
|
||||||
"Seasoning", "Richness", "Brightness", "Depth",
|
|
||||||
"Aroma", "Structure", "Texture",
|
|
||||||
})
|
|
||||||
|
|
||||||
_HEURISTIC: list[tuple[list[str], str]] = [
|
|
||||||
(["vinegar", "lemon", "lime", "citrus", "wine", "yogurt", "kefir",
|
|
||||||
"buttermilk", "tomato", "tamarind"], "Brightness"),
|
|
||||||
(["oil", "butter", "cream", "lard", "fat", "avocado", "coconut milk",
|
|
||||||
"ghee", "shortening", "crisco"], "Richness"),
|
|
||||||
(["salt", "soy", "miso", "tamari", "fish sauce", "worcestershire",
|
|
||||||
"anchov", "capers", "olive", "brine"], "Seasoning"),
|
|
||||||
(["mushroom", "parmesan", "miso", "nutritional yeast", "bouillon",
|
|
||||||
"broth", "umami", "anchov", "dried tomato", "soy"], "Depth"),
|
|
||||||
(["garlic", "onion", "shallot", "herb", "basil", "oregano", "thyme",
|
|
||||||
"rosemary", "spice", "cumin", "coriander", "paprika", "chili",
|
|
||||||
"ginger", "cinnamon", "pepper", "cilantro", "dill", "fennel",
|
|
||||||
"cardamom", "turmeric", "smoke"], "Aroma"),
|
|
||||||
(["flour", "starch", "cornstarch", "arrowroot", "egg", "gelatin",
|
|
||||||
"agar", "breadcrumb", "panko", "roux"], "Structure"),
|
|
||||||
(["nut", "seed", "cracker", "crisp", "wafer", "chip", "crouton",
|
|
||||||
"granola", "tofu", "tempeh"], "Texture"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_json_list(val) -> list:
|
|
||||||
if isinstance(val, list):
|
|
||||||
return val
|
|
||||||
if isinstance(val, str):
|
|
||||||
try:
|
|
||||||
return json.loads(val)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IngredientProfile:
|
|
||||||
name: str
|
|
||||||
elements: list[str]
|
|
||||||
fat_pct: float = 0.0
|
|
||||||
fat_saturated_pct: float = 0.0
|
|
||||||
moisture_pct: float = 0.0
|
|
||||||
protein_pct: float = 0.0
|
|
||||||
starch_pct: float = 0.0
|
|
||||||
binding_score: int = 0
|
|
||||||
glutamate_mg: float = 0.0
|
|
||||||
ph_estimate: float | None = None
|
|
||||||
flavor_molecule_ids: list[str] = field(default_factory=list)
|
|
||||||
heat_stable: bool = True
|
|
||||||
add_timing: str = "any"
|
|
||||||
acid_type: str | None = None
|
|
||||||
sodium_mg_per_100g: float = 0.0
|
|
||||||
is_fermented: bool = False
|
|
||||||
texture_profile: str = "neutral"
|
|
||||||
smoke_point_c: float | None = None
|
|
||||||
is_emulsifier: bool = False
|
|
||||||
source: str = "heuristic"
|
|
||||||
|
|
||||||
|
|
||||||
class ElementClassifier:
|
|
||||||
def __init__(self, store: "Store") -> None:
|
|
||||||
self._store = store
|
|
||||||
|
|
||||||
def classify(self, ingredient_name: str) -> IngredientProfile:
|
|
||||||
"""Return element profile for a single ingredient name."""
|
|
||||||
name = ingredient_name.lower().strip()
|
|
||||||
if not name:
|
|
||||||
return IngredientProfile(name="", elements=[], source="heuristic")
|
|
||||||
row = self._store._fetch_one(
|
|
||||||
"SELECT * FROM ingredient_profiles WHERE name = ?", (name,)
|
|
||||||
)
|
|
||||||
if row:
|
|
||||||
return self._row_to_profile(row)
|
|
||||||
return self._heuristic_profile(name)
|
|
||||||
|
|
||||||
def classify_batch(self, names: list[str]) -> list[IngredientProfile]:
|
|
||||||
return [self.classify(n) for n in names]
|
|
||||||
|
|
||||||
def identify_gaps(self, profiles: list[IngredientProfile]) -> list[str]:
|
|
||||||
"""Return element names that have no coverage in the given profile list."""
|
|
||||||
covered = set()
|
|
||||||
for p in profiles:
|
|
||||||
covered.update(p.elements)
|
|
||||||
return sorted(ELEMENTS - covered)
|
|
||||||
|
|
||||||
def _row_to_profile(self, row: dict) -> IngredientProfile:
|
|
||||||
return IngredientProfile(
|
|
||||||
name=row["name"],
|
|
||||||
elements=_safe_json_list(row.get("elements")),
|
|
||||||
fat_pct=row.get("fat_pct") or 0.0,
|
|
||||||
fat_saturated_pct=row.get("fat_saturated_pct") or 0.0,
|
|
||||||
moisture_pct=row.get("moisture_pct") or 0.0,
|
|
||||||
protein_pct=row.get("protein_pct") or 0.0,
|
|
||||||
starch_pct=row.get("starch_pct") or 0.0,
|
|
||||||
binding_score=row.get("binding_score") or 0,
|
|
||||||
glutamate_mg=row.get("glutamate_mg") or 0.0,
|
|
||||||
ph_estimate=row.get("ph_estimate"),
|
|
||||||
flavor_molecule_ids=_safe_json_list(row.get("flavor_molecule_ids")),
|
|
||||||
heat_stable=bool(row.get("heat_stable", 1)),
|
|
||||||
add_timing=row.get("add_timing") or "any",
|
|
||||||
acid_type=row.get("acid_type"),
|
|
||||||
sodium_mg_per_100g=row.get("sodium_mg_per_100g") or 0.0,
|
|
||||||
is_fermented=bool(row.get("is_fermented", 0)),
|
|
||||||
texture_profile=row.get("texture_profile") or "neutral",
|
|
||||||
smoke_point_c=row.get("smoke_point_c"),
|
|
||||||
is_emulsifier=bool(row.get("is_emulsifier", 0)),
|
|
||||||
source="db",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _heuristic_profile(self, name: str) -> IngredientProfile:
|
|
||||||
seen: set[str] = set()
|
|
||||||
elements: list[str] = []
|
|
||||||
for keywords, element in _HEURISTIC:
|
|
||||||
if element not in seen and any(kw in name for kw in keywords):
|
|
||||||
elements.append(element)
|
|
||||||
seen.add(element)
|
|
||||||
return IngredientProfile(name=name, elements=elements, source="heuristic")
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"""
|
|
||||||
GroceryLinkBuilder — affiliate deeplinks for missing ingredient grocery lists.
|
|
||||||
|
|
||||||
Delegates URL wrapping to circuitforge_core.affiliates.wrap_url, which handles
|
|
||||||
the full resolution chain: opt-out → BYOK id → CF env var → plain URL.
|
|
||||||
|
|
||||||
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_fresh_link(ingredient: str) -> GroceryLink:
|
|
||||||
q = quote_plus(ingredient)
|
|
||||||
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 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)
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryLinkBuilder:
|
|
||||||
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
|
|
||||||
self._tier = tier
|
|
||||||
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
|
||||||
|
|
||||||
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
|
||||||
"""Build grocery deeplinks for a single ingredient.
|
|
||||||
|
|
||||||
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] = [
|
|
||||||
_amazon_fresh_link(ingredient),
|
|
||||||
_instacart_link(ingredient),
|
|
||||||
]
|
|
||||||
if self._walmart_id:
|
|
||||||
links.append(_walmart_link(ingredient, self._walmart_id))
|
|
||||||
|
|
||||||
return links
|
|
||||||
|
|
||||||
def build_all(self, ingredients: list[str]) -> list[GroceryLink]:
|
|
||||||
"""Build links for a list of ingredients."""
|
|
||||||
links: list[GroceryLink] = []
|
|
||||||
for ingredient in ingredients:
|
|
||||||
links.extend(self.build_links(ingredient))
|
|
||||||
return links
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
"""LLM-driven recipe generator for Levels 3 and 4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from contextlib import nullcontext
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
from app.models.schemas.recipe import RecipeRequest, RecipeResult, RecipeSuggestion
|
|
||||||
from app.services.recipe.element_classifier import IngredientProfile
|
|
||||||
from app.services.recipe.style_adapter import StyleAdapter
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_allergies(pantry_items: list[str], allergies: list[str]) -> list[str]:
|
|
||||||
"""Return pantry items with allergy matches removed (bidirectional substring)."""
|
|
||||||
if not allergies:
|
|
||||||
return list(pantry_items)
|
|
||||||
return [
|
|
||||||
item for item in pantry_items
|
|
||||||
if not any(
|
|
||||||
allergy.lower() in item.lower() or item.lower() in allergy.lower()
|
|
||||||
for allergy in allergies
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class LLMRecipeGenerator:
|
|
||||||
def __init__(self, store: "Store") -> None:
|
|
||||||
self._store = store
|
|
||||||
self._style_adapter = StyleAdapter()
|
|
||||||
|
|
||||||
def build_level3_prompt(
|
|
||||||
self,
|
|
||||||
req: RecipeRequest,
|
|
||||||
profiles: list[IngredientProfile],
|
|
||||||
gaps: list[str],
|
|
||||||
) -> str:
|
|
||||||
"""Build a structured element-scaffold prompt for Level 3."""
|
|
||||||
allergy_list = req.allergies
|
|
||||||
safe_pantry = _filter_allergies(req.pantry_items, allergy_list)
|
|
||||||
|
|
||||||
covered_elements: list[str] = []
|
|
||||||
for profile in profiles:
|
|
||||||
for element in profile.elements:
|
|
||||||
if element not in covered_elements:
|
|
||||||
covered_elements.append(element)
|
|
||||||
|
|
||||||
lines: list[str] = [
|
|
||||||
"You are a creative chef. Generate a recipe using the ingredients below.",
|
|
||||||
"IMPORTANT: When you use a pantry item, list it in Ingredients using its exact name from the pantry list. Do not add adjectives, quantities, or cooking states (e.g. use 'butter', not 'unsalted butter' or '2 tbsp butter').",
|
|
||||||
"IMPORTANT: Only use pantry items that make culinary sense for the dish. Do NOT force flavoured/sweetened items (vanilla yoghurt, fruit yoghurt, jam, dessert sauces, flavoured syrups) into savoury dishes. Plain yoghurt, plain cream, and plain dairy are fine in savoury cooking.",
|
|
||||||
"IMPORTANT: Do not default to the same ingredient repeatedly across dishes. If a pantry item does not genuinely improve this specific dish, leave it out.",
|
|
||||||
"",
|
|
||||||
f"Pantry items: {', '.join(safe_pantry)}",
|
|
||||||
]
|
|
||||||
|
|
||||||
if req.constraints:
|
|
||||||
lines.append(f"Dietary constraints: {', '.join(req.constraints)}")
|
|
||||||
|
|
||||||
if allergy_list:
|
|
||||||
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
|
|
||||||
|
|
||||||
if gaps:
|
|
||||||
lines.append(
|
|
||||||
f"Missing elements to address: {', '.join(gaps)}. "
|
|
||||||
"Incorporate ingredients or techniques to fill these gaps."
|
|
||||||
)
|
|
||||||
|
|
||||||
if req.style_id:
|
|
||||||
template = self._style_adapter.get(req.style_id)
|
|
||||||
if template:
|
|
||||||
lines.append(f"Cuisine style: {template.name}")
|
|
||||||
if template.aromatics:
|
|
||||||
lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}")
|
|
||||||
|
|
||||||
lines += [
|
|
||||||
"",
|
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
|
||||||
"Title: <name of the dish>",
|
|
||||||
"Ingredients: <comma-separated list>",
|
|
||||||
"Directions:",
|
|
||||||
"1. <first step>",
|
|
||||||
"2. <second step>",
|
|
||||||
"3. <continue for each step>",
|
|
||||||
"Notes: <optional tips>",
|
|
||||||
]
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def build_level4_prompt(
|
|
||||||
self,
|
|
||||||
req: RecipeRequest,
|
|
||||||
) -> str:
|
|
||||||
"""Build a minimal wildcard prompt for Level 4."""
|
|
||||||
allergy_list = req.allergies
|
|
||||||
safe_pantry = _filter_allergies(req.pantry_items, allergy_list)
|
|
||||||
|
|
||||||
lines: list[str] = [
|
|
||||||
"Surprise me with a creative, unexpected recipe.",
|
|
||||||
"Only use ingredients that make culinary sense together. Do not force flavoured/sweetened items (vanilla yoghurt, flavoured syrups, jam) into savoury dishes.",
|
|
||||||
f"Ingredients available: {', '.join(safe_pantry)}",
|
|
||||||
]
|
|
||||||
|
|
||||||
if req.constraints:
|
|
||||||
lines.append(f"Constraints: {', '.join(req.constraints)}")
|
|
||||||
|
|
||||||
if allergy_list:
|
|
||||||
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
|
|
||||||
|
|
||||||
lines += [
|
|
||||||
"Treat any mystery ingredient as a wildcard — use your imagination.",
|
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
|
||||||
"Title: <name of the dish>",
|
|
||||||
"Ingredients: <comma-separated list>",
|
|
||||||
"Directions:",
|
|
||||||
"1. <first step>",
|
|
||||||
"2. <second step>",
|
|
||||||
"Notes: <optional tips>",
|
|
||||||
]
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
_MODEL_CANDIDATES: list[str] = ["Ouro-2.6B-Thinking", "Ouro-1.4B"]
|
|
||||||
|
|
||||||
def _get_llm_context(self):
|
|
||||||
"""Return a sync context manager that yields an Allocation or None.
|
|
||||||
|
|
||||||
When CF_ORCH_URL is set, uses CFOrchClient to acquire a vLLM allocation
|
|
||||||
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
|
|
||||||
when the env var is absent or CFOrchClient raises on construction.
|
|
||||||
"""
|
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
|
||||||
if cf_orch_url:
|
|
||||||
try:
|
|
||||||
from circuitforge_orch.client import CFOrchClient
|
|
||||||
client = CFOrchClient(cf_orch_url)
|
|
||||||
return client.allocate(
|
|
||||||
service="vllm",
|
|
||||||
model_candidates=self._MODEL_CANDIDATES,
|
|
||||||
ttl_s=300.0,
|
|
||||||
caller="kiwi-recipe",
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
|
||||||
return nullcontext(None)
|
|
||||||
|
|
||||||
def _call_llm(self, prompt: str) -> str:
|
|
||||||
"""Call the LLM, using CFOrchClient allocation when CF_ORCH_URL is set.
|
|
||||||
|
|
||||||
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
|
||||||
calls the OpenAI-compatible API directly against the allocated service URL.
|
|
||||||
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:
|
|
||||||
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}")
|
|
||||||
|
|
||||||
def _strip_md(self, text: str) -> str:
|
|
||||||
return self._MD_BOLD.sub(r"\1", text).strip()
|
|
||||||
|
|
||||||
def _parse_response(self, response: str) -> dict[str, str | list[str]]:
|
|
||||||
"""Parse LLM response text into structured recipe fields.
|
|
||||||
|
|
||||||
Handles both plain-text and markdown-formatted responses. Directions are
|
|
||||||
preserved as newline-separated text so the caller can split on step numbers.
|
|
||||||
"""
|
|
||||||
result: dict[str, str | list[str]] = {
|
|
||||||
"title": "",
|
|
||||||
"ingredients": [],
|
|
||||||
"directions": "",
|
|
||||||
"notes": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
current_key: str | None = None
|
|
||||||
buffer: list[str] = []
|
|
||||||
|
|
||||||
def _flush(key: str | None, buf: list[str]) -> None:
|
|
||||||
if key is None or not buf:
|
|
||||||
return
|
|
||||||
if key == "directions":
|
|
||||||
result["directions"] = "\n".join(buf)
|
|
||||||
elif key == "ingredients":
|
|
||||||
text = " ".join(buf)
|
|
||||||
result["ingredients"] = [i.strip() for i in text.split(",") if i.strip()]
|
|
||||||
else:
|
|
||||||
result[key] = " ".join(buf).strip()
|
|
||||||
|
|
||||||
for raw_line in response.splitlines():
|
|
||||||
line = self._strip_md(raw_line)
|
|
||||||
lower = line.lower()
|
|
||||||
if lower.startswith("title:"):
|
|
||||||
_flush(current_key, buffer)
|
|
||||||
current_key, buffer = "title", [line.split(":", 1)[1].strip()]
|
|
||||||
elif lower.startswith("ingredients:"):
|
|
||||||
_flush(current_key, buffer)
|
|
||||||
current_key, buffer = "ingredients", [line.split(":", 1)[1].strip()]
|
|
||||||
elif lower.startswith("directions:"):
|
|
||||||
_flush(current_key, buffer)
|
|
||||||
rest = line.split(":", 1)[1].strip()
|
|
||||||
current_key, buffer = "directions", ([rest] if rest else [])
|
|
||||||
elif lower.startswith("notes:"):
|
|
||||||
_flush(current_key, buffer)
|
|
||||||
current_key, buffer = "notes", [line.split(":", 1)[1].strip()]
|
|
||||||
elif current_key and line.strip():
|
|
||||||
buffer.append(line.strip())
|
|
||||||
elif current_key is None and line.strip() and ":" not in line:
|
|
||||||
# Before any section header: a 2-10 word colon-free line is the dish name
|
|
||||||
words = line.split()
|
|
||||||
if 2 <= len(words) <= 10 and not result["title"]:
|
|
||||||
result["title"] = line.strip()
|
|
||||||
|
|
||||||
_flush(current_key, buffer)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def generate(
|
|
||||||
self,
|
|
||||||
req: RecipeRequest,
|
|
||||||
profiles: list[IngredientProfile],
|
|
||||||
gaps: list[str],
|
|
||||||
) -> RecipeResult:
|
|
||||||
"""Generate a recipe via LLM and return a RecipeResult."""
|
|
||||||
if req.level == 4:
|
|
||||||
prompt = self.build_level4_prompt(req)
|
|
||||||
else:
|
|
||||||
prompt = self.build_level3_prompt(req, profiles, gaps)
|
|
||||||
|
|
||||||
response = self._call_llm(prompt)
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
return RecipeResult(suggestions=[], element_gaps=gaps)
|
|
||||||
|
|
||||||
parsed = self._parse_response(response)
|
|
||||||
|
|
||||||
raw_directions = parsed.get("directions", "")
|
|
||||||
if isinstance(raw_directions, str):
|
|
||||||
# Split on newlines; strip leading step numbers ("1.", "2.", "- ", "* ")
|
|
||||||
_step_prefix = re.compile(r"^\s*(?:\d+[.)]\s*|[-*]\s+)")
|
|
||||||
directions_list = [
|
|
||||||
_step_prefix.sub("", s).strip()
|
|
||||||
for s in raw_directions.splitlines()
|
|
||||||
if s.strip()
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
directions_list = list(raw_directions)
|
|
||||||
raw_notes = parsed.get("notes", "")
|
|
||||||
notes_str: str = raw_notes if isinstance(raw_notes, str) else ""
|
|
||||||
|
|
||||||
all_ingredients: list[str] = list(parsed.get("ingredients", []))
|
|
||||||
pantry_set = {item.lower() for item in (req.pantry_items or [])}
|
|
||||||
|
|
||||||
# Strip leading quantities/units (e.g. "2 cups rice" → "rice") before
|
|
||||||
# checking against pantry, since LLMs return formatted ingredient strings.
|
|
||||||
_qty_re = re.compile(
|
|
||||||
r"^\s*[\d½¼¾⅓⅔]+[\s/\-]*" # leading digits or fractions
|
|
||||||
r"(?:cup|cups|tbsp|tsp|tablespoon|teaspoon|oz|lb|lbs|g|kg|"
|
|
||||||
r"can|cans|clove|cloves|bunch|package|pkg|slice|slices|"
|
|
||||||
r"piece|pieces|pinch|dash|handful|head|heads|large|small|medium"
|
|
||||||
r")s?\b[,\s]*",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
missing = []
|
|
||||||
for ing in all_ingredients:
|
|
||||||
bare = _qty_re.sub("", ing).strip().lower()
|
|
||||||
if bare not in pantry_set and ing.lower() not in pantry_set:
|
|
||||||
missing.append(bare or ing)
|
|
||||||
|
|
||||||
suggestion = RecipeSuggestion(
|
|
||||||
id=0,
|
|
||||||
title=parsed.get("title") or "LLM Recipe",
|
|
||||||
match_count=len(req.pantry_items),
|
|
||||||
element_coverage={},
|
|
||||||
missing_ingredients=missing,
|
|
||||||
directions=directions_list,
|
|
||||||
notes=notes_str,
|
|
||||||
level=req.level,
|
|
||||||
is_wildcard=(req.level == 4),
|
|
||||||
)
|
|
||||||
|
|
||||||
return RecipeResult(
|
|
||||||
suggestions=[suggestion],
|
|
||||||
element_gaps=gaps,
|
|
||||||
)
|
|
||||||
|
|
@ -1,816 +0,0 @@
|
||||||
"""
|
|
||||||
RecipeEngine — orchestrates the four creativity levels.
|
|
||||||
|
|
||||||
Level 1: corpus lookup ranked by ingredient match + expiry urgency
|
|
||||||
Level 2: Level 1 + deterministic substitution swaps
|
|
||||||
Level 3: element scaffold → LLM constrained prompt (see llm_recipe.py)
|
|
||||||
Level 4: wildcard LLM (see llm_recipe.py)
|
|
||||||
|
|
||||||
Amendments:
|
|
||||||
- max_missing: filter to recipes missing ≤ N pantry items
|
|
||||||
- hard_day_mode: filter to easy-method recipes only
|
|
||||||
- grocery_list: aggregated missing ingredients across suggestions
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate
|
|
||||||
from app.services.recipe.assembly_recipes import match_assembly_templates
|
|
||||||
from app.services.recipe.element_classifier import ElementClassifier
|
|
||||||
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
|
||||||
from app.services.recipe.substitution_engine import SubstitutionEngine
|
|
||||||
|
|
||||||
_LEFTOVER_DAILY_MAX_FREE = 5
|
|
||||||
|
|
||||||
# Words that carry no ingredient-identity signal — stripped before overlap scoring
|
|
||||||
_SWAP_STOPWORDS = frozenset({
|
|
||||||
"a", "an", "the", "of", "in", "for", "with", "and", "or",
|
|
||||||
"to", "from", "at", "by", "as", "on",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Maps product-label substrings to recipe-corpus canonical terms.
|
|
||||||
# Kept in sync with Store._FTS_SYNONYMS — both must agree on canonical names.
|
|
||||||
# Used to expand pantry_set so single-word recipe ingredients can match
|
|
||||||
# multi-word product names (e.g. "hamburger" satisfied by "burger patties").
|
|
||||||
_PANTRY_LABEL_SYNONYMS: dict[str, str] = {
|
|
||||||
"burger patt": "hamburger",
|
|
||||||
"beef patt": "hamburger",
|
|
||||||
"ground beef": "hamburger",
|
|
||||||
"ground chuck": "hamburger",
|
|
||||||
"ground round": "hamburger",
|
|
||||||
"mince": "hamburger",
|
|
||||||
"veggie burger": "hamburger",
|
|
||||||
"beyond burger": "hamburger",
|
|
||||||
"impossible burger": "hamburger",
|
|
||||||
"plant burger": "hamburger",
|
|
||||||
"chicken patt": "chicken patty",
|
|
||||||
"kielbasa": "sausage",
|
|
||||||
"bratwurst": "sausage",
|
|
||||||
"frankfurter": "hotdog",
|
|
||||||
"wiener": "hotdog",
|
|
||||||
"chicken breast": "chicken",
|
|
||||||
"chicken thigh": "chicken",
|
|
||||||
"chicken drumstick": "chicken",
|
|
||||||
"chicken wing": "chicken",
|
|
||||||
"rotisserie chicken": "chicken",
|
|
||||||
"chicken tender": "chicken",
|
|
||||||
"chicken strip": "chicken",
|
|
||||||
"chicken piece": "chicken",
|
|
||||||
"fake chicken": "chicken",
|
|
||||||
"plant chicken": "chicken",
|
|
||||||
"vegan chicken": "chicken",
|
|
||||||
"daring": "chicken",
|
|
||||||
"gardein chick": "chicken",
|
|
||||||
"quorn chick": "chicken",
|
|
||||||
"chick'n": "chicken",
|
|
||||||
"chikn": "chicken",
|
|
||||||
"not-chicken": "chicken",
|
|
||||||
"no-chicken": "chicken",
|
|
||||||
# Plant-based beef subs → broad "beef" (strips ≠ ground; texture matters)
|
|
||||||
"not-beef": "beef",
|
|
||||||
"no-beef": "beef",
|
|
||||||
"plant beef": "beef",
|
|
||||||
"vegan beef": "beef",
|
|
||||||
# Plant-based pork subs
|
|
||||||
"not-pork": "pork",
|
|
||||||
"no-pork": "pork",
|
|
||||||
"plant pork": "pork",
|
|
||||||
"vegan pork": "pork",
|
|
||||||
"omnipork": "pork",
|
|
||||||
"omni pork": "pork",
|
|
||||||
# Generic alt-meat catch-alls → broad "beef"
|
|
||||||
"fake meat": "beef",
|
|
||||||
"plant meat": "beef",
|
|
||||||
"vegan meat": "beef",
|
|
||||||
"meat-free": "beef",
|
|
||||||
"meatless": "beef",
|
|
||||||
"pork chop": "pork",
|
|
||||||
"pork loin": "pork",
|
|
||||||
"pork tenderloin": "pork",
|
|
||||||
"marinara": "tomato sauce",
|
|
||||||
"pasta sauce": "tomato sauce",
|
|
||||||
"spaghetti sauce": "tomato sauce",
|
|
||||||
"pizza sauce": "tomato sauce",
|
|
||||||
"macaroni": "pasta",
|
|
||||||
"noodles": "pasta",
|
|
||||||
"spaghetti": "pasta",
|
|
||||||
"penne": "pasta",
|
|
||||||
"fettuccine": "pasta",
|
|
||||||
"rigatoni": "pasta",
|
|
||||||
"linguine": "pasta",
|
|
||||||
"rotini": "pasta",
|
|
||||||
"farfalle": "pasta",
|
|
||||||
"shredded cheese": "cheese",
|
|
||||||
"sliced cheese": "cheese",
|
|
||||||
"american cheese": "cheese",
|
|
||||||
"cheddar": "cheese",
|
|
||||||
"mozzarella": "cheese",
|
|
||||||
"heavy cream": "cream",
|
|
||||||
"whipping cream": "cream",
|
|
||||||
"half and half": "cream",
|
|
||||||
"burger bun": "buns",
|
|
||||||
"hamburger bun": "buns",
|
|
||||||
"hot dog bun": "buns",
|
|
||||||
"bread roll": "buns",
|
|
||||||
"dinner roll": "buns",
|
|
||||||
# Tortillas / wraps — assembly dishes (burritos, tacos, quesadillas)
|
|
||||||
"flour tortilla": "tortillas",
|
|
||||||
"corn tortilla": "tortillas",
|
|
||||||
"tortilla wrap": "tortillas",
|
|
||||||
"soft taco shell": "tortillas",
|
|
||||||
"taco shell": "taco shells",
|
|
||||||
"pita bread": "pita",
|
|
||||||
"flatbread": "flatbread",
|
|
||||||
# Canned beans — extremely interchangeable in assembly dishes
|
|
||||||
"black bean": "beans",
|
|
||||||
"pinto bean": "beans",
|
|
||||||
"kidney bean": "beans",
|
|
||||||
"refried bean": "beans",
|
|
||||||
"chickpea": "beans",
|
|
||||||
"garbanzo": "beans",
|
|
||||||
# Rice variants
|
|
||||||
"white rice": "rice",
|
|
||||||
"brown rice": "rice",
|
|
||||||
"jasmine rice": "rice",
|
|
||||||
"basmati rice": "rice",
|
|
||||||
"instant rice": "rice",
|
|
||||||
"microwavable rice": "rice",
|
|
||||||
# Salsa / hot sauce
|
|
||||||
"hot sauce": "salsa",
|
|
||||||
"taco sauce": "salsa",
|
|
||||||
"enchilada sauce": "salsa",
|
|
||||||
# Sour cream / Greek yogurt — functional substitutes
|
|
||||||
"greek yogurt": "sour cream",
|
|
||||||
# Frozen/prepackaged meal token extraction — handled by individual token
|
|
||||||
# fallback in _normalize_for_fts; these are the most common single-serve meal types
|
|
||||||
"lean cuisine": "casserole",
|
|
||||||
"stouffer": "casserole",
|
|
||||||
"healthy choice": "casserole",
|
|
||||||
"marie callender": "casserole",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Matches leading quantity/unit prefixes in recipe ingredient strings,
|
|
||||||
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
|
||||||
# "3 oz. butter" → "butter"
|
|
||||||
_QUANTITY_PREFIX = re.compile(
|
|
||||||
r"^\s*(?:\d+(?:[./]\d+)?\s*)?" # optional leading number (1, 1/2, 2.5)
|
|
||||||
r"(?:to\s+\d+\s*)?" # optional "to N" range
|
|
||||||
r"(?:c\.|cup|cups|tbsp|tsp|oz|lb|lbs|g|kg|ml|l|"
|
|
||||||
r"can|cans|pkg|pkg\.|package|slice|slices|clove|cloves|"
|
|
||||||
r"small|medium|large|bunch|head|piece|pieces|"
|
|
||||||
r"pinch|dash|handful|sprig|sprigs)\s*\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Preparation-state words that modify an ingredient without changing what it is.
|
|
||||||
# Stripped from both ends so "melted butter", "butter, melted" both → "butter".
|
|
||||||
_PREP_STATES = re.compile(
|
|
||||||
r"\b(melted|softened|cold|warm|hot|room.temperature|"
|
|
||||||
r"diced|sliced|chopped|minced|grated|shredded|shredded|beaten|whipped|"
|
|
||||||
r"cooked|raw|frozen|canned|dried|dehydrated|marinated|seasoned|"
|
|
||||||
r"roasted|toasted|ground|crushed|pressed|peeled|seeded|pitted|"
|
|
||||||
r"boneless|skinless|trimmed|halved|quartered|julienned|"
|
|
||||||
r"thinly|finely|roughly|coarsely|freshly|lightly|"
|
|
||||||
r"packed|heaping|level|sifted|divided|optional)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# Trailing comma + optional prep state (e.g. "butter, melted")
|
|
||||||
_TRAILING_PREP = re.compile(r",\s*\w+$")
|
|
||||||
|
|
||||||
|
|
||||||
# Maps prep-state words to human-readable instruction templates.
|
|
||||||
# {ingredient} is replaced with the actual ingredient name.
|
|
||||||
# None means the state is passive (frozen, canned) — no note needed.
|
|
||||||
_PREP_INSTRUCTIONS: dict[str, str | None] = {
|
|
||||||
"melted": "Melt the {ingredient} before starting.",
|
|
||||||
"softened": "Let the {ingredient} soften to room temperature before using.",
|
|
||||||
"room temperature": "Bring the {ingredient} to room temperature before using.",
|
|
||||||
"beaten": "Beat the {ingredient} lightly before adding.",
|
|
||||||
"whipped": "Whip the {ingredient} until soft peaks form.",
|
|
||||||
"sifted": "Sift the {ingredient} before measuring.",
|
|
||||||
"toasted": "Toast the {ingredient} in a dry pan until fragrant.",
|
|
||||||
"roasted": "Roast the {ingredient} before using.",
|
|
||||||
"pressed": "Press the {ingredient} to remove excess moisture.",
|
|
||||||
"diced": "Dice the {ingredient} into small pieces.",
|
|
||||||
"sliced": "Slice the {ingredient} thinly.",
|
|
||||||
"chopped": "Chop the {ingredient} roughly.",
|
|
||||||
"minced": "Mince the {ingredient} finely.",
|
|
||||||
"grated": "Grate the {ingredient}.",
|
|
||||||
"shredded": "Shred the {ingredient}.",
|
|
||||||
"ground": "Grind the {ingredient}.",
|
|
||||||
"crushed": "Crush the {ingredient}.",
|
|
||||||
"peeled": "Peel the {ingredient} before use.",
|
|
||||||
"seeded": "Remove seeds from the {ingredient}.",
|
|
||||||
"pitted": "Pit the {ingredient} before use.",
|
|
||||||
"trimmed": "Trim any excess from the {ingredient}.",
|
|
||||||
"julienned": "Cut the {ingredient} into thin matchstick strips.",
|
|
||||||
"cooked": "Pre-cook the {ingredient} before adding.",
|
|
||||||
# Passive states — ingredient is used as-is, no prep note needed
|
|
||||||
"cold": None,
|
|
||||||
"warm": None,
|
|
||||||
"hot": None,
|
|
||||||
"raw": None,
|
|
||||||
"frozen": None,
|
|
||||||
"canned": None,
|
|
||||||
"dried": None,
|
|
||||||
"dehydrated": None,
|
|
||||||
"marinated": None,
|
|
||||||
"seasoned": None,
|
|
||||||
"boneless": None,
|
|
||||||
"skinless": None,
|
|
||||||
"divided": None,
|
|
||||||
"optional": None,
|
|
||||||
"fresh": None,
|
|
||||||
"freshly": None,
|
|
||||||
"thinly": None,
|
|
||||||
"finely": None,
|
|
||||||
"roughly": None,
|
|
||||||
"coarsely": None,
|
|
||||||
"lightly": None,
|
|
||||||
"packed": None,
|
|
||||||
"heaping": None,
|
|
||||||
"level": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Finds the first actionable prep state in an ingredient string
|
|
||||||
_PREP_STATE_SEARCH = re.compile(
|
|
||||||
r"\b(" + "|".join(re.escape(k) for k in _PREP_INSTRUCTIONS) + r")\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_quantity(ingredient: str) -> str:
|
|
||||||
"""Remove leading quantity/unit and preparation-state words from a recipe ingredient.
|
|
||||||
|
|
||||||
e.g. "2 tbsp melted butter" → "butter"
|
|
||||||
"butter, melted" → "butter"
|
|
||||||
"1/4 cup flour, sifted" → "flour"
|
|
||||||
"""
|
|
||||||
stripped = _QUANTITY_PREFIX.sub("", ingredient).strip()
|
|
||||||
# Strip any remaining leading number (e.g. "3 eggs" → "eggs")
|
|
||||||
stripped = re.sub(r"^\d+\s+", "", stripped)
|
|
||||||
# Strip trailing ", prep_state"
|
|
||||||
stripped = _TRAILING_PREP.sub("", stripped).strip()
|
|
||||||
# Strip prep-state words (may be leading or embedded)
|
|
||||||
stripped = _PREP_STATES.sub("", stripped).strip()
|
|
||||||
# Clean up any double spaces left behind
|
|
||||||
stripped = re.sub(r"\s{2,}", " ", stripped).strip()
|
|
||||||
return stripped or ingredient
|
|
||||||
|
|
||||||
|
|
||||||
def _prep_note_for(ingredient: str) -> str | None:
|
|
||||||
"""Return a human-readable prep instruction for this ingredient string, or None.
|
|
||||||
|
|
||||||
e.g. "2 tbsp melted butter" → "Melt the butter before starting."
|
|
||||||
"onion, diced" → "Dice the onion into small pieces."
|
|
||||||
"frozen peas" → None (passive state, no action needed)
|
|
||||||
"""
|
|
||||||
match = _PREP_STATE_SEARCH.search(ingredient)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
state = match.group(1).lower()
|
|
||||||
template = _PREP_INSTRUCTIONS.get(state)
|
|
||||||
if not template:
|
|
||||||
return None
|
|
||||||
# Use the stripped ingredient name as the subject
|
|
||||||
ingredient_name = _strip_quantity(ingredient)
|
|
||||||
return template.format(ingredient=ingredient_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
|
|
||||||
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
|
|
||||||
|
|
||||||
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
|
||||||
and adds the canonical form. This lets single-word recipe ingredients
|
|
||||||
("hamburger", "chicken") match product-label pantry entries
|
|
||||||
("burger patties", "rotisserie chicken").
|
|
||||||
"""
|
|
||||||
expanded: set[str] = set()
|
|
||||||
for item in pantry_items:
|
|
||||||
lower = item.lower().strip()
|
|
||||||
expanded.add(lower)
|
|
||||||
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
|
||||||
if pattern in lower:
|
|
||||||
expanded.add(canonical)
|
|
||||||
return expanded
|
|
||||||
|
|
||||||
|
|
||||||
def _ingredient_in_pantry(ingredient: str, pantry_set: set[str]) -> bool:
|
|
||||||
"""Return True if the recipe ingredient is satisfied by the pantry.
|
|
||||||
|
|
||||||
Checks three layers in order:
|
|
||||||
1. Exact match after quantity stripping
|
|
||||||
2. Synonym lookup: ingredient → canonical → in pantry_set
|
|
||||||
(handles "ground beef" matched by "burger patties" via shared canonical)
|
|
||||||
3. Token subset: all content tokens of the ingredient appear in pantry
|
|
||||||
(handles "diced onions" when "onions" is in pantry)
|
|
||||||
"""
|
|
||||||
clean = _strip_quantity(ingredient).lower()
|
|
||||||
if clean in pantry_set:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check if this recipe ingredient maps to a canonical that's in pantry
|
|
||||||
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
|
||||||
if pattern in clean and canonical in pantry_set:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Single-token ingredient whose token appears in pantry (e.g. "ketchup" in "c. ketchup")
|
|
||||||
tokens = [t for t in clean.split() if t not in _SWAP_STOPWORDS and len(t) > 2]
|
|
||||||
if tokens and all(t in pantry_set for t in tokens):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _content_tokens(text: str) -> frozenset[str]:
|
|
||||||
return frozenset(
|
|
||||||
w for w in text.lower().split()
|
|
||||||
if w not in _SWAP_STOPWORDS and len(w) > 1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _pantry_creative_swap(required: str, pantry_items: set[str]) -> str | None:
|
|
||||||
"""Return a pantry item that's a plausible creative substitute, or None.
|
|
||||||
|
|
||||||
Requires ≥2 shared content tokens AND ≥50% bidirectional overlap so that
|
|
||||||
single-word differences (cream-of-mushroom vs cream-of-potato) qualify while
|
|
||||||
single-word ingredients (butter, flour) don't accidentally match supersets
|
|
||||||
(peanut butter, bread flour).
|
|
||||||
"""
|
|
||||||
req_tokens = _content_tokens(required)
|
|
||||||
if len(req_tokens) < 2:
|
|
||||||
return None # single-word ingredients must already be in pantry_set
|
|
||||||
|
|
||||||
best: str | None = None
|
|
||||||
best_score = 0.0
|
|
||||||
for item in pantry_items:
|
|
||||||
if item.lower() == required.lower():
|
|
||||||
continue
|
|
||||||
pan_tokens = _content_tokens(item)
|
|
||||||
if not pan_tokens:
|
|
||||||
continue
|
|
||||||
overlap = len(req_tokens & pan_tokens)
|
|
||||||
if overlap < 2:
|
|
||||||
continue
|
|
||||||
score = min(overlap / len(req_tokens), overlap / len(pan_tokens))
|
|
||||||
if score >= 0.5 and score > best_score:
|
|
||||||
best_score = score
|
|
||||||
best = item
|
|
||||||
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
|
|
||||||
)
|
|
||||||
_INVOLVED_METHODS = re.compile(
|
|
||||||
r"\b(braise|roast|knead|deep.?fry|fry|sauté|saute|bake|boil)\b", re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hard day mode sort tier patterns
|
|
||||||
_PREMADE_TITLE_RE = re.compile(
|
|
||||||
r"\b(frozen|instant|microwave|ready.?made|pre.?made|packaged|heat.?and.?eat)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_HEAT_ONLY_RE = re.compile(r"\b(microwave|heat|warm|thaw)\b", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _hard_day_sort_tier(
|
|
||||||
title: str,
|
|
||||||
ingredient_names: list[str],
|
|
||||||
directions: list[str],
|
|
||||||
) -> int:
|
|
||||||
"""Return a sort priority tier for hard day mode.
|
|
||||||
|
|
||||||
0 — premade / heat-only (frozen dinner, quesadilla, microwave meal)
|
|
||||||
1 — super simple (≤3 ingredients, easy method)
|
|
||||||
2 — easy/moderate (everything else that passed the 'involved' filter)
|
|
||||||
|
|
||||||
Lower tier surfaces first.
|
|
||||||
"""
|
|
||||||
dir_text = " ".join(directions)
|
|
||||||
n_ingredients = len(ingredient_names)
|
|
||||||
n_steps = len(directions)
|
|
||||||
|
|
||||||
# Tier 0: title signals premade, OR very few ingredients with heat-only steps
|
|
||||||
if _PREMADE_TITLE_RE.search(title):
|
|
||||||
return 0
|
|
||||||
if n_ingredients <= 2 and n_steps <= 3 and _HEAT_ONLY_RE.search(dir_text):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Tier 1: ≤3 ingredients with any easy method (quesadilla, cheese toast, etc.)
|
|
||||||
if n_ingredients <= 3 and _EASY_METHODS.search(dir_text):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
return 2
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_method_complexity(
|
|
||||||
directions: list[str],
|
|
||||||
available_equipment: list[str] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Classify recipe method complexity from direction strings.
|
|
||||||
|
|
||||||
Returns 'easy', 'moderate', or 'involved'.
|
|
||||||
available_equipment can expand the easy set (e.g. ['toaster', 'air fryer']).
|
|
||||||
"""
|
|
||||||
text = " ".join(directions).lower()
|
|
||||||
equipment_set = {e.lower() for e in (available_equipment or [])}
|
|
||||||
|
|
||||||
if _INVOLVED_METHODS.search(text):
|
|
||||||
return "involved"
|
|
||||||
|
|
||||||
if _EASY_METHODS.search(text):
|
|
||||||
return "easy"
|
|
||||||
|
|
||||||
# Check equipment-specific easy methods
|
|
||||||
for equip in equipment_set:
|
|
||||||
if equip in text:
|
|
||||||
return "easy"
|
|
||||||
|
|
||||||
return "moderate"
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeEngine:
|
|
||||||
def __init__(self, store: "Store") -> None:
|
|
||||||
self._store = store
|
|
||||||
self._classifier = ElementClassifier(store)
|
|
||||||
self._substitution = SubstitutionEngine(store)
|
|
||||||
|
|
||||||
def suggest(
|
|
||||||
self,
|
|
||||||
req: RecipeRequest,
|
|
||||||
available_equipment: list[str] | None = None,
|
|
||||||
) -> RecipeResult:
|
|
||||||
# Load cooking equipment from user settings when hard_day_mode is active
|
|
||||||
if req.hard_day_mode and available_equipment is None:
|
|
||||||
equipment_json = self._store.get_setting("cooking_equipment")
|
|
||||||
if equipment_json:
|
|
||||||
try:
|
|
||||||
available_equipment = json.loads(equipment_json)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
available_equipment = []
|
|
||||||
else:
|
|
||||||
available_equipment = []
|
|
||||||
# Rate-limit leftover mode for free tier
|
|
||||||
if req.expiry_first and req.tier == "free":
|
|
||||||
allowed, count = self._store.check_and_increment_rate_limit(
|
|
||||||
"leftover_mode", _LEFTOVER_DAILY_MAX_FREE
|
|
||||||
)
|
|
||||||
if not allowed:
|
|
||||||
return RecipeResult(
|
|
||||||
suggestions=[], element_gaps=[], rate_limited=True, rate_limit_count=count
|
|
||||||
)
|
|
||||||
|
|
||||||
profiles = self._classifier.classify_batch(req.pantry_items)
|
|
||||||
gaps = self._classifier.identify_gaps(profiles)
|
|
||||||
pantry_set = _expand_pantry_set(req.pantry_items)
|
|
||||||
|
|
||||||
if req.level >= 3:
|
|
||||||
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
|
||||||
gen = LLMRecipeGenerator(self._store)
|
|
||||||
return gen.generate(req, profiles, gaps)
|
|
||||||
|
|
||||||
# Level 1 & 2: deterministic path
|
|
||||||
nf = req.nutrition_filters
|
|
||||||
rows = self._store.search_recipes_by_ingredients(
|
|
||||||
req.pantry_items,
|
|
||||||
limit=20,
|
|
||||||
category=req.category or None,
|
|
||||||
max_calories=nf.max_calories,
|
|
||||||
max_sugar_g=nf.max_sugar_g,
|
|
||||||
max_carbs_g=nf.max_carbs_g,
|
|
||||||
max_sodium_mg=nf.max_sodium_mg,
|
|
||||||
excluded_ids=req.excluded_ids or [],
|
|
||||||
)
|
|
||||||
suggestions = []
|
|
||||||
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
ingredient_names: list[str] = row.get("ingredient_names") or []
|
|
||||||
if isinstance(ingredient_names, str):
|
|
||||||
try:
|
|
||||||
ingredient_names = json.loads(ingredient_names)
|
|
||||||
except Exception:
|
|
||||||
ingredient_names = []
|
|
||||||
|
|
||||||
# Compute missing ingredients, detecting pantry coverage first.
|
|
||||||
# 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,
|
|
||||||
substitute_name=swap_item,
|
|
||||||
constraint_label="pantry_swap",
|
|
||||||
explanation=f"You have {swap_item} — use it in place of {n}.",
|
|
||||||
compensation_hints=[],
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
missing.append(n)
|
|
||||||
|
|
||||||
# Filter by max_missing — skipped in shopping mode (user is willing to buy)
|
|
||||||
if not req.shopping_mode and req.max_missing is not None and len(missing) > req.max_missing:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Filter and tier-rank by hard_day_mode
|
|
||||||
if req.hard_day_mode:
|
|
||||||
directions: list[str] = row.get("directions") or []
|
|
||||||
if isinstance(directions, str):
|
|
||||||
try:
|
|
||||||
directions = json.loads(directions)
|
|
||||||
except Exception:
|
|
||||||
directions = [directions]
|
|
||||||
complexity = _classify_method_complexity(directions, available_equipment)
|
|
||||||
if complexity == "involved":
|
|
||||||
continue
|
|
||||||
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
|
|
||||||
title=row.get("title", ""),
|
|
||||||
ingredient_names=ingredient_names,
|
|
||||||
directions=directions,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Level 2: also add dietary constraint swaps from substitution_pairs
|
|
||||||
if req.level == 2 and req.constraints:
|
|
||||||
for ing in ingredient_names:
|
|
||||||
for constraint in req.constraints:
|
|
||||||
swaps = self._substitution.find_substitutes(ing, constraint)
|
|
||||||
for swap in swaps[:1]:
|
|
||||||
swap_candidates.append(SwapCandidate(
|
|
||||||
original_name=swap.original_name,
|
|
||||||
substitute_name=swap.substitute_name,
|
|
||||||
constraint_label=swap.constraint_label,
|
|
||||||
explanation=swap.explanation,
|
|
||||||
compensation_hints=swap.compensation_hints,
|
|
||||||
))
|
|
||||||
|
|
||||||
coverage_raw = row.get("element_coverage") or {}
|
|
||||||
if isinstance(coverage_raw, str):
|
|
||||||
try:
|
|
||||||
coverage_raw = json.loads(coverage_raw)
|
|
||||||
except Exception:
|
|
||||||
coverage_raw = {}
|
|
||||||
|
|
||||||
servings = row.get("servings") or None
|
|
||||||
nutrition = NutritionPanel(
|
|
||||||
calories=row.get("calories"),
|
|
||||||
fat_g=row.get("fat_g"),
|
|
||||||
protein_g=row.get("protein_g"),
|
|
||||||
carbs_g=row.get("carbs_g"),
|
|
||||||
fiber_g=row.get("fiber_g"),
|
|
||||||
sugar_g=row.get("sugar_g"),
|
|
||||||
sodium_mg=row.get("sodium_mg"),
|
|
||||||
servings=servings,
|
|
||||||
estimated=bool(row.get("nutrition_estimated", 0)),
|
|
||||||
)
|
|
||||||
has_nutrition = any(
|
|
||||||
v is not None
|
|
||||||
for v in (nutrition.calories, nutrition.sugar_g, nutrition.carbs_g)
|
|
||||||
)
|
|
||||||
suggestions.append(RecipeSuggestion(
|
|
||||||
id=row["id"],
|
|
||||||
title=row["title"],
|
|
||||||
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),
|
|
||||||
))
|
|
||||||
|
|
||||||
# Assembly-dish templates (burrito, fried rice, pasta, etc.)
|
|
||||||
# Expiry boost: when expiry_first, the pantry_items list is already sorted
|
|
||||||
# by expiry urgency — treat the first slice as the "expiring" set so templates
|
|
||||||
# that use those items bubble up in the merged ranking.
|
|
||||||
expiring_set: set[str] = set()
|
|
||||||
if req.expiry_first:
|
|
||||||
expiring_set = _expand_pantry_set(req.pantry_items[:10])
|
|
||||||
|
|
||||||
assembly = match_assembly_templates(
|
|
||||||
pantry_items=req.pantry_items,
|
|
||||||
pantry_set=pantry_set,
|
|
||||||
excluded_ids=req.excluded_ids or [],
|
|
||||||
expiring_set=expiring_set,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cap by tier — lifted in shopping mode since missing-ingredient templates
|
|
||||||
# are desirable there (each fires an affiliate link opportunity).
|
|
||||||
if not req.shopping_mode:
|
|
||||||
assembly_limit = _ASSEMBLY_TIER_LIMITS.get(req.tier, 3)
|
|
||||||
assembly = assembly[:assembly_limit]
|
|
||||||
|
|
||||||
# Interleave: sort templates and corpus recipes together.
|
|
||||||
# Hard day mode: primary sort by tier (0=premade, 1=simple, 2=moderate),
|
|
||||||
# then by match_count within each tier. Assembly templates are inherently
|
|
||||||
# simple so they default to tier 1 when not in the tier map.
|
|
||||||
# Normal mode: sort by match_count only.
|
|
||||||
if req.hard_day_mode and hard_day_tier_map:
|
|
||||||
suggestions = sorted(
|
|
||||||
assembly + suggestions,
|
|
||||||
key=lambda s: (hard_day_tier_map.get(s.id, 1), -s.match_count),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
suggestions = sorted(assembly + suggestions, key=lambda s: s.match_count, reverse=True)
|
|
||||||
|
|
||||||
# Build grocery list — deduplicated union of all missing ingredients
|
|
||||||
seen: set[str] = set()
|
|
||||||
grocery_list: list[str] = []
|
|
||||||
for s in suggestions:
|
|
||||||
for item in s.missing_ingredients:
|
|
||||||
if item not in seen:
|
|
||||||
grocery_list.append(item)
|
|
||||||
seen.add(item)
|
|
||||||
|
|
||||||
# Build grocery links — affiliate deeplinks for each missing ingredient
|
|
||||||
link_builder = GroceryLinkBuilder(tier=req.tier, has_byok=req.has_byok)
|
|
||||||
grocery_links = link_builder.build_all(grocery_list)
|
|
||||||
|
|
||||||
return RecipeResult(
|
|
||||||
suggestions=suggestions,
|
|
||||||
element_gaps=gaps,
|
|
||||||
grocery_list=grocery_list,
|
|
||||||
grocery_links=grocery_links,
|
|
||||||
)
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"""
|
|
||||||
StapleLibrary -- bulk-preparable base component reference data.
|
|
||||||
Loaded from YAML files in app/staples/.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
_STAPLES_DIR = Path(__file__).parents[2] / "staples"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StapleEntry:
|
|
||||||
slug: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
dietary_labels: list[str]
|
|
||||||
base_ingredients: list[str]
|
|
||||||
base_method: str
|
|
||||||
base_time_minutes: int
|
|
||||||
yield_formats: dict[str, Any]
|
|
||||||
compatible_styles: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class StapleLibrary:
|
|
||||||
def __init__(self, staples_dir: Path = _STAPLES_DIR) -> None:
|
|
||||||
self._staples: dict[str, StapleEntry] = {}
|
|
||||||
for yaml_path in sorted(staples_dir.glob("*.yaml")):
|
|
||||||
entry = self._load(yaml_path)
|
|
||||||
self._staples[entry.slug] = entry
|
|
||||||
|
|
||||||
def get(self, slug: str) -> StapleEntry | None:
|
|
||||||
return self._staples.get(slug)
|
|
||||||
|
|
||||||
def list_all(self) -> list[StapleEntry]:
|
|
||||||
return list(self._staples.values())
|
|
||||||
|
|
||||||
def filter_by_dietary(self, label: str) -> list[StapleEntry]:
|
|
||||||
return [s for s in self._staples.values() if label in s.dietary_labels]
|
|
||||||
|
|
||||||
def _load(self, path: Path) -> StapleEntry:
|
|
||||||
try:
|
|
||||||
data = yaml.safe_load(path.read_text())
|
|
||||||
return StapleEntry(
|
|
||||||
slug=data["slug"],
|
|
||||||
name=data["name"],
|
|
||||||
description=data.get("description", ""),
|
|
||||||
dietary_labels=data.get("dietary_labels", []),
|
|
||||||
base_ingredients=data.get("base_ingredients", []),
|
|
||||||
base_method=data.get("base_method", ""),
|
|
||||||
base_time_minutes=int(data.get("base_time_minutes", 0)),
|
|
||||||
yield_formats=data.get("yield_formats", {}),
|
|
||||||
compatible_styles=data.get("compatible_styles", []),
|
|
||||||
)
|
|
||||||
except (KeyError, yaml.YAMLError) as exc:
|
|
||||||
raise ValueError(f"Failed to load staple from {path}: {exc}") from exc
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
"""
|
|
||||||
StyleAdapter — cuisine-mode overlay that biases element dimensions.
|
|
||||||
YAML templates in app/styles/.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
_STYLES_DIR = Path(__file__).parents[2] / "styles"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StyleTemplate:
|
|
||||||
style_id: str
|
|
||||||
name: str
|
|
||||||
aromatics: tuple[str, ...]
|
|
||||||
depth_sources: tuple[str, ...]
|
|
||||||
brightness_sources: tuple[str, ...]
|
|
||||||
method_bias: dict[str, float]
|
|
||||||
structure_forms: tuple[str, ...]
|
|
||||||
seasoning_bias: str
|
|
||||||
finishing_fat_str: str
|
|
||||||
|
|
||||||
def bias_aroma_selection(self, pantry_items: list[str]) -> list[str]:
|
|
||||||
"""Return aromatics present in pantry (bidirectional substring match)."""
|
|
||||||
result = []
|
|
||||||
for aroma in self.aromatics:
|
|
||||||
for item in pantry_items:
|
|
||||||
if aroma.lower() in item.lower() or item.lower() in aroma.lower():
|
|
||||||
result.append(aroma)
|
|
||||||
break
|
|
||||||
return result
|
|
||||||
|
|
||||||
def preferred_depth_sources(self, pantry_items: list[str]) -> list[str]:
|
|
||||||
"""Return depth_sources present in pantry."""
|
|
||||||
result = []
|
|
||||||
for src in self.depth_sources:
|
|
||||||
for item in pantry_items:
|
|
||||||
if src.lower() in item.lower() or item.lower() in src.lower():
|
|
||||||
result.append(src)
|
|
||||||
break
|
|
||||||
return result
|
|
||||||
|
|
||||||
def preferred_structure_forms(self, pantry_items: list[str]) -> list[str]:
|
|
||||||
"""Return structure_forms present in pantry."""
|
|
||||||
result = []
|
|
||||||
for form in self.structure_forms:
|
|
||||||
for item in pantry_items:
|
|
||||||
if form.lower() in item.lower() or item.lower() in form.lower():
|
|
||||||
result.append(form)
|
|
||||||
break
|
|
||||||
return result
|
|
||||||
|
|
||||||
def method_weights(self) -> dict[str, float]:
|
|
||||||
"""Return method bias weights."""
|
|
||||||
return dict(self.method_bias)
|
|
||||||
|
|
||||||
def seasoning_vector(self) -> str:
|
|
||||||
"""Return seasoning bias."""
|
|
||||||
return self.seasoning_bias
|
|
||||||
|
|
||||||
def finishing_fat(self) -> str:
|
|
||||||
"""Return finishing fat."""
|
|
||||||
return self.finishing_fat_str
|
|
||||||
|
|
||||||
|
|
||||||
class StyleAdapter:
|
|
||||||
def __init__(self, styles_dir: Path = _STYLES_DIR) -> None:
|
|
||||||
self._styles: dict[str, StyleTemplate] = {}
|
|
||||||
for yaml_path in sorted(styles_dir.glob("*.yaml")):
|
|
||||||
try:
|
|
||||||
template = self._load(yaml_path)
|
|
||||||
self._styles[template.style_id] = template
|
|
||||||
except (KeyError, yaml.YAMLError, TypeError) as exc:
|
|
||||||
raise ValueError(f"Failed to load style from {yaml_path}: {exc}") from exc
|
|
||||||
|
|
||||||
@property
|
|
||||||
def styles(self) -> dict[str, StyleTemplate]:
|
|
||||||
return self._styles
|
|
||||||
|
|
||||||
def get(self, style_id: str) -> StyleTemplate | None:
|
|
||||||
return self._styles.get(style_id)
|
|
||||||
|
|
||||||
def list_all(self) -> list[StyleTemplate]:
|
|
||||||
return list(self._styles.values())
|
|
||||||
|
|
||||||
def bias_aroma_selection(self, style_id: str, pantry_items: list[str]) -> list[str]:
|
|
||||||
"""Return pantry items that match the style's preferred aromatics.
|
|
||||||
Falls back to all pantry items if no match found."""
|
|
||||||
template = self._styles.get(style_id)
|
|
||||||
if not template:
|
|
||||||
return pantry_items
|
|
||||||
matched = [
|
|
||||||
item for item in pantry_items
|
|
||||||
if any(
|
|
||||||
aroma.lower() in item.lower() or item.lower() in aroma.lower()
|
|
||||||
for aroma in template.aromatics
|
|
||||||
)
|
|
||||||
]
|
|
||||||
return matched if matched else pantry_items
|
|
||||||
|
|
||||||
def apply(self, style_id: str, pantry_items: list[str]) -> dict:
|
|
||||||
"""Return style-biased ingredient guidance for each element dimension."""
|
|
||||||
template = self._styles.get(style_id)
|
|
||||||
if not template:
|
|
||||||
return {}
|
|
||||||
return {
|
|
||||||
"aroma_candidates": self.bias_aroma_selection(style_id, pantry_items),
|
|
||||||
"depth_suggestions": list(template.depth_sources),
|
|
||||||
"brightness_suggestions": list(template.brightness_sources),
|
|
||||||
"method_bias": template.method_bias,
|
|
||||||
"structure_forms": list(template.structure_forms),
|
|
||||||
"seasoning_bias": template.seasoning_bias,
|
|
||||||
"finishing_fat": template.finishing_fat_str,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _load(self, path: Path) -> StyleTemplate:
|
|
||||||
data = yaml.safe_load(path.read_text())
|
|
||||||
return StyleTemplate(
|
|
||||||
style_id=data["style_id"],
|
|
||||||
name=data["name"],
|
|
||||||
aromatics=tuple(data.get("aromatics", [])),
|
|
||||||
depth_sources=tuple(data.get("depth_sources", [])),
|
|
||||||
brightness_sources=tuple(data.get("brightness_sources", [])),
|
|
||||||
method_bias=dict(data.get("method_bias", {})),
|
|
||||||
structure_forms=tuple(data.get("structure_forms", [])),
|
|
||||||
seasoning_bias=data.get("seasoning_bias", ""),
|
|
||||||
finishing_fat_str=data.get("finishing_fat", ""),
|
|
||||||
)
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
"""
|
|
||||||
SubstitutionEngine — deterministic ingredient swap candidates with compensation hints.
|
|
||||||
|
|
||||||
Powered by:
|
|
||||||
- substitution_pairs table (derived from lishuyang/recipepairs)
|
|
||||||
- ingredient_profiles functional metadata (USDA FDC)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.db.store import Store
|
|
||||||
|
|
||||||
# Compensation threshold — if |delta| exceeds this, surface a hint
|
|
||||||
_FAT_THRESHOLD = 5.0 # grams per 100g
|
|
||||||
_GLUTAMATE_THRESHOLD = 1.0 # mg per 100g
|
|
||||||
_MOISTURE_THRESHOLD = 15.0 # grams per 100g
|
|
||||||
|
|
||||||
_RICHNESS_COMPENSATORS = ["olive oil", "coconut oil", "butter", "shortening", "full-fat coconut milk"]
|
|
||||||
_DEPTH_COMPENSATORS = ["nutritional yeast", "soy sauce", "miso", "mushroom powder",
|
|
||||||
"better than bouillon not-beef", "smoked paprika"]
|
|
||||||
_MOISTURE_BINDERS = ["cornstarch", "flour", "arrowroot", "breadcrumbs"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CompensationHint:
|
|
||||||
ingredient: str
|
|
||||||
reason: str
|
|
||||||
element: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SubstitutionSwap:
|
|
||||||
original_name: str
|
|
||||||
substitute_name: str
|
|
||||||
constraint_label: str
|
|
||||||
fat_delta: float
|
|
||||||
moisture_delta: float
|
|
||||||
glutamate_delta: float
|
|
||||||
protein_delta: float
|
|
||||||
occurrence_count: int
|
|
||||||
compensation_hints: list[dict] = field(default_factory=list)
|
|
||||||
explanation: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class SubstitutionEngine:
|
|
||||||
def __init__(self, store: "Store") -> None:
|
|
||||||
self._store = store
|
|
||||||
|
|
||||||
def find_substitutes(
|
|
||||||
self,
|
|
||||||
ingredient_name: str,
|
|
||||||
constraint: str,
|
|
||||||
) -> list[SubstitutionSwap]:
|
|
||||||
rows = self._store._fetch_all("""
|
|
||||||
SELECT substitute_name, constraint_label,
|
|
||||||
fat_delta, moisture_delta, glutamate_delta, protein_delta,
|
|
||||||
occurrence_count, compensation_hints
|
|
||||||
FROM substitution_pairs
|
|
||||||
WHERE original_name = ? AND constraint_label = ?
|
|
||||||
ORDER BY occurrence_count DESC
|
|
||||||
""", (ingredient_name.lower(), constraint))
|
|
||||||
|
|
||||||
return [self._row_to_swap(ingredient_name, row) for row in rows]
|
|
||||||
|
|
||||||
def _row_to_swap(self, original: str, row: dict) -> SubstitutionSwap:
|
|
||||||
hints = self._build_hints(row)
|
|
||||||
explanation = self._build_explanation(original, row, hints)
|
|
||||||
return SubstitutionSwap(
|
|
||||||
original_name=original,
|
|
||||||
substitute_name=row["substitute_name"],
|
|
||||||
constraint_label=row["constraint_label"],
|
|
||||||
fat_delta=row.get("fat_delta") or 0.0,
|
|
||||||
moisture_delta=row.get("moisture_delta") or 0.0,
|
|
||||||
glutamate_delta=row.get("glutamate_delta") or 0.0,
|
|
||||||
protein_delta=row.get("protein_delta") or 0.0,
|
|
||||||
occurrence_count=row.get("occurrence_count") or 1,
|
|
||||||
compensation_hints=[{"ingredient": h.ingredient, "reason": h.reason, "element": h.element} for h in hints],
|
|
||||||
explanation=explanation,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_hints(self, row: dict) -> list[CompensationHint]:
|
|
||||||
hints = []
|
|
||||||
fat_delta = row.get("fat_delta") or 0.0
|
|
||||||
glutamate_delta = row.get("glutamate_delta") or 0.0
|
|
||||||
moisture_delta = row.get("moisture_delta") or 0.0
|
|
||||||
|
|
||||||
if fat_delta < -_FAT_THRESHOLD:
|
|
||||||
missing = abs(fat_delta)
|
|
||||||
sugg = _RICHNESS_COMPENSATORS[0]
|
|
||||||
hints.append(CompensationHint(
|
|
||||||
ingredient=sugg,
|
|
||||||
reason=f"substitute has ~{missing:.0f}g/100g less fat — add {sugg} to restore Richness",
|
|
||||||
element="Richness",
|
|
||||||
))
|
|
||||||
|
|
||||||
if glutamate_delta < -_GLUTAMATE_THRESHOLD:
|
|
||||||
sugg = _DEPTH_COMPENSATORS[0]
|
|
||||||
hints.append(CompensationHint(
|
|
||||||
ingredient=sugg,
|
|
||||||
reason=f"substitute is lower in umami — add {sugg} to restore Depth",
|
|
||||||
element="Depth",
|
|
||||||
))
|
|
||||||
|
|
||||||
if moisture_delta > _MOISTURE_THRESHOLD:
|
|
||||||
sugg = _MOISTURE_BINDERS[0]
|
|
||||||
hints.append(CompensationHint(
|
|
||||||
ingredient=sugg,
|
|
||||||
reason=f"substitute adds ~{moisture_delta:.0f}g/100g more moisture — add {sugg} to maintain Structure",
|
|
||||||
element="Structure",
|
|
||||||
))
|
|
||||||
|
|
||||||
return hints
|
|
||||||
|
|
||||||
def _build_explanation(
|
|
||||||
self, original: str, row: dict, hints: list[CompensationHint]
|
|
||||||
) -> str:
|
|
||||||
sub = row["substitute_name"]
|
|
||||||
count = row.get("occurrence_count") or 1
|
|
||||||
base = f"Replace {original} with {sub} (seen in {count} recipes)."
|
|
||||||
if hints:
|
|
||||||
base += " To compensate: " + "; ".join(h.reason for h in hints) + "."
|
|
||||||
return base
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
slug: seitan
|
|
||||||
name: Seitan (Wheat Meat)
|
|
||||||
description: High-protein wheat gluten that mimics the texture of meat. Can be made in bulk and stored in multiple formats.
|
|
||||||
dietary_labels: [vegan, high-protein]
|
|
||||||
base_ingredients:
|
|
||||||
- vital wheat gluten
|
|
||||||
- nutritional yeast
|
|
||||||
- soy sauce
|
|
||||||
- garlic powder
|
|
||||||
- vegetable broth
|
|
||||||
base_method: simmer
|
|
||||||
base_time_minutes: 45
|
|
||||||
yield_formats:
|
|
||||||
fresh:
|
|
||||||
elements: [Structure, Depth, Richness]
|
|
||||||
shelf_days: 5
|
|
||||||
storage: airtight container, refrigerated in broth
|
|
||||||
methods: [saute, braise, grill, stir-fry]
|
|
||||||
texture: chewy, meaty
|
|
||||||
frozen:
|
|
||||||
elements: [Structure, Depth]
|
|
||||||
shelf_days: 90
|
|
||||||
storage: vacuum-sealed freezer bag
|
|
||||||
methods: [thaw then any method]
|
|
||||||
texture: slightly softer after thaw
|
|
||||||
braised:
|
|
||||||
elements: [Structure, Depth, Seasoning]
|
|
||||||
shelf_days: 4
|
|
||||||
storage: covered in braising liquid, refrigerated
|
|
||||||
methods: [serve directly, slice for sandwiches]
|
|
||||||
texture: tender, falling-apart
|
|
||||||
grilled:
|
|
||||||
elements: [Structure, Aroma, Texture]
|
|
||||||
shelf_days: 3
|
|
||||||
storage: refrigerated, uncovered to maintain crust
|
|
||||||
methods: [slice cold, reheat in pan]
|
|
||||||
texture: crisp exterior, chewy interior
|
|
||||||
compatible_styles: [italian, latin, east_asian, eastern_european]
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
slug: tempeh
|
|
||||||
name: Tempeh
|
|
||||||
description: Fermented soybean cake. Dense, nutty, high in protein. Excellent at absorbing marinades.
|
|
||||||
dietary_labels: [vegan, high-protein, fermented]
|
|
||||||
base_ingredients:
|
|
||||||
- tempeh block (store-bought or homemade from soybeans + starter)
|
|
||||||
base_method: steam then marinate
|
|
||||||
base_time_minutes: 20
|
|
||||||
yield_formats:
|
|
||||||
raw:
|
|
||||||
elements: [Structure, Depth, Richness]
|
|
||||||
shelf_days: 7
|
|
||||||
storage: refrigerated in original packaging or wrapped
|
|
||||||
methods: [steam, crumble, slice]
|
|
||||||
texture: dense, firm
|
|
||||||
marinated:
|
|
||||||
elements: [Structure, Depth, Seasoning, Aroma]
|
|
||||||
shelf_days: 5
|
|
||||||
storage: submerged in marinade, refrigerated
|
|
||||||
methods: [bake, pan-fry, grill]
|
|
||||||
texture: chewy, flavor-dense
|
|
||||||
crumbled:
|
|
||||||
elements: [Structure, Depth, Texture]
|
|
||||||
shelf_days: 3
|
|
||||||
storage: refrigerated, use quickly
|
|
||||||
methods: [saute as ground meat substitute, add to tacos or pasta]
|
|
||||||
texture: crumbly, browned bits
|
|
||||||
compatible_styles: [latin, east_asian, mediterranean]
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
slug: tofu_firm
|
|
||||||
name: Firm Tofu
|
|
||||||
description: Pressed soybean curd. Neutral flavor, excellent at absorbing surrounding flavors. Freeze-thaw cycle creates meatier texture.
|
|
||||||
dietary_labels: [vegan, high-protein]
|
|
||||||
base_ingredients:
|
|
||||||
- firm or extra-firm tofu block
|
|
||||||
base_method: press (30 min) then prepare
|
|
||||||
base_time_minutes: 30
|
|
||||||
yield_formats:
|
|
||||||
pressed_raw:
|
|
||||||
elements: [Structure]
|
|
||||||
shelf_days: 5
|
|
||||||
storage: submerged in water, refrigerated, change water daily
|
|
||||||
methods: [cube, slice, crumble]
|
|
||||||
texture: dense, uniform
|
|
||||||
freeze_thawed:
|
|
||||||
elements: [Structure, Texture]
|
|
||||||
shelf_days: 5
|
|
||||||
storage: refrigerated after thawing
|
|
||||||
methods: [squeeze dry, saute, bake]
|
|
||||||
texture: chewy, porous, absorbs marinades deeply
|
|
||||||
baked:
|
|
||||||
elements: [Structure, Texture, Aroma]
|
|
||||||
shelf_days: 4
|
|
||||||
storage: refrigerated, uncovered
|
|
||||||
methods: [add to stir-fry, bowl, salad]
|
|
||||||
texture: crisp exterior, chewy interior
|
|
||||||
silken:
|
|
||||||
elements: [Richness, Structure]
|
|
||||||
shelf_days: 3
|
|
||||||
storage: refrigerated, use within days of opening
|
|
||||||
methods: [blend into sauces, custards, dressings]
|
|
||||||
texture: silky, smooth
|
|
||||||
compatible_styles: [east_asian, mediterranean]
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
style_id: east_asian
|
|
||||||
name: East Asian
|
|
||||||
aromatics: [ginger, scallion, sesame, star anise, five spice, sichuan pepper, lemongrass]
|
|
||||||
depth_sources: [soy sauce, miso, oyster sauce, shiitake, fish sauce, bonito]
|
|
||||||
brightness_sources: [rice vinegar, mirin, citrus zest, ponzu]
|
|
||||||
method_bias:
|
|
||||||
stir_fry: 0.35
|
|
||||||
steam: 0.25
|
|
||||||
braise: 0.20
|
|
||||||
boil: 0.20
|
|
||||||
structure_forms: [dumpling wrapper, thin noodle, rice, bao]
|
|
||||||
seasoning_bias: soy sauce
|
|
||||||
finishing_fat: toasted sesame oil
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
style_id: eastern_european
|
|
||||||
name: Eastern European
|
|
||||||
aromatics: [dill, caraway, marjoram, parsley, horseradish, bay leaf]
|
|
||||||
depth_sources: [sour cream, smoked meats, bacon, dried mushrooms]
|
|
||||||
brightness_sources: [sauerkraut brine, apple cider vinegar, sour cream]
|
|
||||||
method_bias:
|
|
||||||
braise: 0.35
|
|
||||||
boil: 0.30
|
|
||||||
bake: 0.25
|
|
||||||
roast: 0.10
|
|
||||||
structure_forms: [dumpling wrapper, bread dough, stuffed cabbage]
|
|
||||||
seasoning_bias: kosher salt
|
|
||||||
finishing_fat: butter or lard
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
style_id: italian
|
|
||||||
name: Italian
|
|
||||||
aromatics: [basil, oregano, garlic, onion, fennel, rosemary, thyme, sage, marjoram]
|
|
||||||
depth_sources: [parmesan, pecorino, anchovies, canned tomato, porcini mushrooms]
|
|
||||||
brightness_sources: [lemon, white wine, tomato, red wine vinegar]
|
|
||||||
method_bias:
|
|
||||||
braise: 0.30
|
|
||||||
roast: 0.30
|
|
||||||
saute: 0.25
|
|
||||||
simmer: 0.15
|
|
||||||
structure_forms: [pasta, wrapped, layered, risotto]
|
|
||||||
seasoning_bias: sea salt
|
|
||||||
finishing_fat: olive oil
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
style_id: latin
|
|
||||||
name: Latin
|
|
||||||
aromatics: [cumin, chili, cilantro, epazote, mexican oregano, ancho, chipotle, smoked paprika]
|
|
||||||
depth_sources: [dried chilis, smoked peppers, chocolate, achiote]
|
|
||||||
brightness_sources: [lime, tomatillo, brined jalapeño, orange]
|
|
||||||
method_bias:
|
|
||||||
roast: 0.30
|
|
||||||
braise: 0.30
|
|
||||||
fry: 0.25
|
|
||||||
grill: 0.15
|
|
||||||
structure_forms: [wrapped in masa, pastry, stuffed, bowl]
|
|
||||||
seasoning_bias: kosher salt
|
|
||||||
finishing_fat: lard or neutral oil
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
style_id: mediterranean
|
|
||||||
name: Mediterranean
|
|
||||||
aromatics: [oregano, thyme, rosemary, mint, sumac, za'atar, preserved lemon]
|
|
||||||
depth_sources: [tahini, feta, halloumi, dried olives, harissa]
|
|
||||||
brightness_sources: [lemon, pomegranate molasses, yogurt, sumac]
|
|
||||||
method_bias:
|
|
||||||
roast: 0.35
|
|
||||||
grill: 0.30
|
|
||||||
braise: 0.25
|
|
||||||
saute: 0.10
|
|
||||||
structure_forms: [flatbread, stuffed vegetables, grain bowl, mezze plate]
|
|
||||||
seasoning_bias: sea salt
|
|
||||||
finishing_fat: olive oil
|
|
||||||
|
|
@ -27,9 +27,6 @@ LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback"})
|
||||||
VRAM_BUDGETS: dict[str, float] = {
|
VRAM_BUDGETS: dict[str, float] = {
|
||||||
# ExpirationPredictor uses a small LLM (16 tokens out, single pass).
|
# ExpirationPredictor uses a small LLM (16 tokens out, single pass).
|
||||||
"expiry_llm_fallback": 2.0,
|
"expiry_llm_fallback": 2.0,
|
||||||
# Recipe LLM (levels 3-4): full recipe generation, ~200-500 tokens out.
|
|
||||||
# Budget assumes a quantized 7B-class model.
|
|
||||||
"recipe_llm": 4.0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ from circuitforge_core.tasks.scheduler import (
|
||||||
reset_scheduler, # re-export for tests
|
reset_scheduler, # re-export for tests
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -21,6 +20,4 @@ def get_scheduler(db_path: Path) -> TaskScheduler:
|
||||||
run_task_fn=run_task,
|
run_task_fn=run_task,
|
||||||
task_types=LLM_TASK_TYPES,
|
task_types=LLM_TASK_TYPES,
|
||||||
vram_budgets=VRAM_BUDGETS,
|
vram_budgets=VRAM_BUDGETS,
|
||||||
coordinator_url=settings.COORDINATOR_URL,
|
|
||||||
service_name="kiwi",
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
23
app/tiers.py
23
app/tiers.py
|
|
@ -15,7 +15,6 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"recipe_suggestions",
|
"recipe_suggestions",
|
||||||
"expiry_llm_matching",
|
"expiry_llm_matching",
|
||||||
"receipt_ocr",
|
"receipt_ocr",
|
||||||
"style_classifier",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Feature → minimum tier required
|
# Feature → minimum tier required
|
||||||
|
|
@ -26,8 +25,6 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"receipt_upload": "free",
|
"receipt_upload": "free",
|
||||||
"expiry_alerts": "free",
|
"expiry_alerts": "free",
|
||||||
"export_csv": "free",
|
"export_csv": "free",
|
||||||
"leftover_mode": "free", # Rate-limited at API layer, not tier-gated
|
|
||||||
"staple_library": "free",
|
|
||||||
|
|
||||||
# Paid tier
|
# Paid tier
|
||||||
"receipt_ocr": "paid", # BYOK-unlockable
|
"receipt_ocr": "paid", # BYOK-unlockable
|
||||||
|
|
@ -35,30 +32,21 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"expiry_llm_matching": "paid", # BYOK-unlockable
|
"expiry_llm_matching": "paid", # BYOK-unlockable
|
||||||
"meal_planning": "paid",
|
"meal_planning": "paid",
|
||||||
"dietary_profiles": "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
|
# Premium tier
|
||||||
"multi_household": "premium",
|
"multi_household": "premium",
|
||||||
"background_monitoring": "premium",
|
"background_monitoring": "premium",
|
||||||
|
"leftover_mode": "premium",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def can_use(feature: str, tier: str, has_byok: bool = False) -> bool:
|
def can_use(feature: str, tier: str, has_byok: bool = False) -> bool:
|
||||||
"""Return True if the given tier can access the feature.
|
"""Return True if the given tier can access the feature."""
|
||||||
|
|
||||||
The 'local' tier is assigned to dev-bypass and non-cloud sessions —
|
|
||||||
it has unrestricted access to all features.
|
|
||||||
"""
|
|
||||||
if tier == "local":
|
|
||||||
return True
|
|
||||||
return _can_use(
|
return _can_use(
|
||||||
feature,
|
feature,
|
||||||
tier,
|
tier,
|
||||||
has_byok=has_byok,
|
has_byok=has_byok,
|
||||||
_features=KIWI_FEATURES,
|
_features=KIWI_FEATURES,
|
||||||
_byok_unlockable=KIWI_BYOK_UNLOCKABLE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,12 +54,7 @@ def require_feature(feature: str, tier: str, has_byok: bool = False) -> None:
|
||||||
"""Raise ValueError if the tier cannot access the feature."""
|
"""Raise ValueError if the tier cannot access the feature."""
|
||||||
if not can_use(feature, tier, has_byok):
|
if not can_use(feature, tier, has_byok):
|
||||||
from circuitforge_core.tiers.tiers import tier_label
|
from circuitforge_core.tiers.tiers import tier_label
|
||||||
needed = tier_label(
|
needed = tier_label(feature, has_byok=has_byok, _features=KIWI_FEATURES)
|
||||||
feature,
|
|
||||||
has_byok=has_byok,
|
|
||||||
_features=KIWI_FEATURES,
|
|
||||||
_byok_unlockable=KIWI_BYOK_UNLOCKABLE,
|
|
||||||
)
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Feature '{feature}' requires {needed} tier. "
|
f"Feature '{feature}' requires {needed} tier. "
|
||||||
f"Current tier: {tier}."
|
f"Current tier: {tier}."
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
CLOUD_MODE: "true"
|
CLOUD_MODE: "true"
|
||||||
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
|
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
|
# 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:
|
volumes:
|
||||||
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
||||||
# LLM config — shared with other CF products; read-only in container
|
# LLM config — shared with other CF products; read-only in container
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# compose.override.yml — local dev additions (auto-merged by docker compose)
|
|
||||||
# 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.
|
|
||||||
cf-orch-agent:
|
|
||||||
image: kiwi-api # reuse local api image — cf-core already installed there
|
|
||||||
network_mode: host
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
# Override coordinator URL here or via .env
|
|
||||||
COORDINATOR_URL: ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
|
||||||
command: >
|
|
||||||
conda run -n kiwi cf-orch agent
|
|
||||||
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
|
||||||
--node-id kiwi
|
|
||||||
--host 0.0.0.0
|
|
||||||
--port 7702
|
|
||||||
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
|
|
@ -14,17 +14,6 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
# Forward the session header injected by Caddy from cf_session cookie.
|
# Forward the session header injected by Caddy from cf_session cookie.
|
||||||
proxy_set_header X-CF-Session $http_x_cf_session;
|
proxy_set_header X-CF-Session $http_x_cf_session;
|
||||||
# Allow image uploads (barcode/receipt photos from phone cameras).
|
|
||||||
client_max_body_size 20m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
|
|
||||||
# Vite's /kiwi base URL means assets are requested at /kiwi/assets/... but stored
|
|
||||||
# at /assets/... in nginx's root. Alias /kiwi/ → root so direct port access works.
|
|
||||||
# ^~ prevents regex locations from overriding this prefix match for /kiwi/ paths.
|
|
||||||
location ^~ /kiwi/ {
|
|
||||||
alias /usr/share/nginx/html/;
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ server {
|
||||||
proxy_pass http://172.17.0.1:8512;
|
proxy_pass http://172.17.0.1:8512;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
# Allow image uploads (barcode/receipt photos from phone cameras).
|
|
||||||
client_max_body_size 20m;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,5 @@ dependencies:
|
||||||
- numpy>=1.25
|
- numpy>=1.25
|
||||||
- pyzbar>=0.1.9
|
- pyzbar>=0.1.9
|
||||||
- httpx>=0.27
|
- httpx>=0.27
|
||||||
- psutil>=5.9
|
|
||||||
- pydantic>=2.5
|
- pydantic>=2.5
|
||||||
- PyJWT>=2.8
|
- PyJWT>=2.8
|
||||||
- datasets
|
|
||||||
- huggingface_hub
|
|
||||||
- transformers
|
|
||||||
- sentence-transformers
|
|
||||||
- torch
|
|
||||||
- pyyaml
|
|
||||||
- pandas
|
|
||||||
- pyarrow
|
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,8 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kiwi — Pantry Tracker</title>
|
<title>frontend</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<!-- Anti-FOUC: critical layout CSS inline so it's available before the JS bundle.
|
|
||||||
Without this, the sidebar flashes visible on mobile for ~100ms while the
|
|
||||||
bundle hydrates and injects component styles. -->
|
|
||||||
<style>
|
|
||||||
.sidebar { display: none; }
|
|
||||||
.bottom-nav { display: flex; }
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.sidebar { display: flex; flex-direction: column; }
|
|
||||||
.bottom-nav { display: none; }
|
|
||||||
.app-body { display: flex; flex-direction: column; flex: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
5
frontend/package-lock.json
generated
5
frontend/package-lock.json
generated
|
|
@ -844,6 +844,7 @@
|
||||||
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
|
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1556,6 +1557,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -1719,6 +1721,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -1740,6 +1743,7 @@
|
||||||
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -1821,6 +1825,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.22",
|
"@vue/compiler-dom": "3.5.22",
|
||||||
"@vue/compiler-sfc": "3.5.22",
|
"@vue/compiler-sfc": "3.5.22",
|
||||||
|
|
|
||||||
|
|
@ -1,226 +1,59 @@
|
||||||
<template>
|
<template>
|
||||||
<div id="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
<div id="app">
|
||||||
|
|
||||||
<!-- Desktop sidebar (hidden on mobile) -->
|
|
||||||
<aside class="sidebar" role="navigation" aria-label="Main navigation">
|
|
||||||
<!-- Wordmark + collapse toggle -->
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<span class="wordmark-kiwi" @click="onWordmarkClick" style="cursor:pointer">Kiwi</span>
|
|
||||||
<button class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed" :aria-label="sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
|
||||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
|
||||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</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"/>
|
|
||||||
<rect x="3" y="11" width="18" height="4" rx="1"/>
|
|
||||||
<rect x="3" y="18" width="18" height="3" rx="1"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sidebar-label">Pantry</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button :class="['sidebar-item', { active: currentTab === 'receipts' }]" @click="switchTab('receipts')">
|
|
||||||
<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="M4 4v16l2-1.5 2 1.5 2-1.5 2 1.5 2-1.5 2 1.5 2-1.5V4"/>
|
|
||||||
<line x1="8" y1="9" x2="16" y2="9"/>
|
|
||||||
<line x1="8" y1="13" x2="14" y2="13"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sidebar-label">Receipts</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"/>
|
|
||||||
<path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sidebar-label">Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main area: header + content -->
|
|
||||||
<div class="app-body">
|
|
||||||
<!-- Mobile-only header -->
|
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-inner">
|
<div class="container">
|
||||||
<span class="wordmark-kiwi">Kiwi</span>
|
<h1>🥝 Kiwi</h1>
|
||||||
|
<p class="tagline">Smart Pantry Tracking & Recipe Suggestions</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div v-show="currentTab === 'inventory'" class="tab-content fade-in">
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
:class="['tab', { active: currentTab === 'inventory' }]"
|
||||||
|
@click="switchTab('inventory')"
|
||||||
|
>
|
||||||
|
🏪 Inventory
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="['tab', { active: currentTab === 'receipts' }]"
|
||||||
|
@click="switchTab('receipts')"
|
||||||
|
>
|
||||||
|
🧾 Receipts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div v-show="currentTab === 'inventory'" class="tab-content">
|
||||||
<InventoryList />
|
<InventoryList />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="currentTab === 'receipts'" class="tab-content fade-in">
|
|
||||||
|
<div v-show="currentTab === 'receipts'" class="tab-content">
|
||||||
<ReceiptsView />
|
<ReceiptsView />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
|
|
||||||
<RecipesView />
|
|
||||||
</div>
|
|
||||||
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
|
||||||
<SettingsView />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile bottom nav only -->
|
<footer class="app-footer">
|
||||||
<nav class="bottom-nav" role="navigation" aria-label="Main navigation">
|
<div class="container">
|
||||||
<button :class="['nav-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')" aria-label="Recipes">
|
<p>© 2026 CircuitForge LLC</p>
|
||||||
<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"/>
|
|
||||||
<rect x="3" y="11" width="18" height="4" rx="1"/>
|
|
||||||
<rect x="3" y="18" width="18" height="3" rx="1"/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">Pantry</span>
|
|
||||||
</button>
|
|
||||||
<button :class="['nav-item', { active: currentTab === 'receipts' }]" @click="switchTab('receipts')" aria-label="Receipts">
|
|
||||||
<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="M4 4v16l2-1.5 2 1.5 2-1.5 2 1.5 2-1.5 2 1.5 2-1.5V4"/>
|
|
||||||
<line x1="8" y1="9" x2="16" y2="9"/>
|
|
||||||
<line x1="8" y1="13" x2="14" y2="13"/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">Receipts</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"/>
|
|
||||||
<path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
|
||||||
<FeedbackButton :current-tab="currentTab" />
|
|
||||||
|
|
||||||
<!-- Easter egg: Kiwi bird sprite — triggered by typing "kiwi" -->
|
|
||||||
<Transition name="kiwi-fade">
|
|
||||||
<div v-if="kiwiVisible" class="kiwi-bird-stage" aria-hidden="true">
|
|
||||||
<div class="kiwi-bird" :class="kiwiDirection">
|
|
||||||
<!-- Kiwi bird SVG — side profile, facing left by default (rtl walk) -->
|
|
||||||
<svg class="kiwi-svg" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!-- Body — plump oval -->
|
|
||||||
<ellipse cx="30" cy="38" rx="18" ry="15" fill="#8B6914" />
|
|
||||||
<!-- Head -->
|
|
||||||
<ellipse cx="46" cy="26" rx="10" ry="9" fill="#6B4F10" />
|
|
||||||
<!-- Long beak -->
|
|
||||||
<path d="M54 25 Q66 24 70 25 Q66 27 54 27Z" fill="#C8A96E" />
|
|
||||||
<!-- Eye -->
|
|
||||||
<circle cx="49" cy="23" r="2" fill="#1a1a1a" />
|
|
||||||
<circle cx="49.7" cy="22.3" r="0.6" fill="white" />
|
|
||||||
<!-- Wing texture lines -->
|
|
||||||
<path d="M18 32 Q24 28 34 30" stroke="#6B4F10" stroke-width="1.2" stroke-linecap="round" />
|
|
||||||
<path d="M16 37 Q22 33 32 35" stroke="#6B4F10" stroke-width="1.2" stroke-linecap="round" />
|
|
||||||
<!-- Legs -->
|
|
||||||
<line x1="24" y1="52" x2="22" y2="60" stroke="#A07820" stroke-width="2.5" stroke-linecap="round" />
|
|
||||||
<line x1="34" y1="52" x2="36" y2="60" stroke="#A07820" stroke-width="2.5" stroke-linecap="round" />
|
|
||||||
<!-- Feet -->
|
|
||||||
<path d="M18 60 L22 60 L24 57" stroke="#A07820" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
|
||||||
<path d="M32 60 L36 60 L38 57" stroke="#A07820" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
|
||||||
<!-- Feather texture -->
|
|
||||||
<path d="M22 38 Q28 34 36 36" stroke="#A07820" stroke-width="0.8" stroke-linecap="round" />
|
|
||||||
<path d="M20 43 Q26 39 34 41" stroke="#A07820" stroke-width="0.8" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</footer>
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref } from 'vue'
|
||||||
import InventoryList from './components/InventoryList.vue'
|
import InventoryList from './components/InventoryList.vue'
|
||||||
import ReceiptsView from './components/ReceiptsView.vue'
|
import ReceiptsView from './components/ReceiptsView.vue'
|
||||||
import RecipesView from './components/RecipesView.vue'
|
|
||||||
import 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'
|
const currentTab = ref<'inventory' | 'receipts'>('inventory')
|
||||||
|
|
||||||
const currentTab = ref<Tab>('inventory')
|
function switchTab(tab: 'inventory' | 'receipts') {
|
||||||
const sidebarCollapsed = ref(false)
|
|
||||||
const inventoryStore = useInventoryStore()
|
|
||||||
const { kiwiVisible, kiwiDirection } = useEasterEggs()
|
|
||||||
|
|
||||||
// Wordmark click counter for chef mode easter egg
|
|
||||||
const wordmarkClicks = ref(0)
|
|
||||||
let wordmarkTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
function onWordmarkClick() {
|
|
||||||
wordmarkClicks.value++
|
|
||||||
if (wordmarkTimer) clearTimeout(wordmarkTimer)
|
|
||||||
if (wordmarkClicks.value >= 5) {
|
|
||||||
wordmarkClicks.value = 0
|
|
||||||
document.querySelector('.wordmark-kiwi')?.classList.add('chef-spin')
|
|
||||||
setTimeout(() => document.querySelector('.wordmark-kiwi')?.classList.remove('chef-spin'), 800)
|
|
||||||
} else {
|
|
||||||
wordmarkTimer = setTimeout(() => { wordmarkClicks.value = 0 }, 1200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchTab(tab: Tab) {
|
|
||||||
currentTab.value = tab
|
currentTab.value = tab
|
||||||
if (tab === 'recipes' && inventoryStore.items.length === 0) {
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -231,329 +64,136 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-body);
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wordmark-kiwi {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-primary);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
MOBILE LAYOUT (< 769px)
|
|
||||||
sidebar hidden, bottom nav visible
|
|
||||||
============================================ */
|
|
||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
|
||||||
max-width: 100vw;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar { display: none; }
|
.container {
|
||||||
.app-body { display: contents; }
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
background: var(--gradient-header);
|
background: var(--gradient-primary);
|
||||||
border-bottom: 1px solid var(--color-border);
|
color: white;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-inner .wordmark-kiwi { font-size: 24px; }
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--spacing-md) 0 var(--spacing-xl);
|
|
||||||
/* Clear fixed bottom nav — env() gives extra room for iPhone home bar */
|
|
||||||
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content { min-height: 0; }
|
|
||||||
|
|
||||||
/* ---- Bottom nav ---- */
|
|
||||||
.bottom-nav {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 200;
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
|
||||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 8px 4px 10px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.18s ease, background 0.18s ease;
|
|
||||||
border-radius: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 20%;
|
|
||||||
right: 20%;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--color-primary);
|
|
||||||
border-radius: 0 0 2px 2px;
|
|
||||||
transform: scaleX(0);
|
|
||||||
transition: transform 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
background: rgba(232, 168, 32, 0.06);
|
|
||||||
transform: none;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active { color: var(--color-primary); }
|
|
||||||
.nav-item.active::before { transform: scaleX(1); }
|
|
||||||
|
|
||||||
.nav-icon { width: 22px; height: 22px; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.container { padding: 0 var(--spacing-sm); }
|
|
||||||
.app-main {
|
|
||||||
padding: var(--spacing-sm) 0 var(--spacing-lg);
|
|
||||||
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
DESKTOP LAYOUT (≥ 769px)
|
|
||||||
sidebar visible, bottom nav hidden
|
|
||||||
============================================ */
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.bottom-nav { display: none; }
|
|
||||||
|
|
||||||
#app {
|
|
||||||
flex-direction: row;
|
|
||||||
padding-bottom: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Sidebar ---- */
|
|
||||||
.sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 200px;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-collapsed .sidebar {
|
|
||||||
width: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
|
|
||||||
min-height: 56px;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header .wordmark-kiwi {
|
|
||||||
font-size: 22px;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.15s ease, width 0.22s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-collapsed .sidebar-header .wordmark-kiwi {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-toggle {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-toggle:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
transform: none;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding: 10px var(--spacing-sm);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
transform: none;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item.active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-item .nav-icon { width: 20px; height: 20px; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.sidebar-label {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.12s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-collapsed .sidebar-label {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Main body ---- */
|
|
||||||
.app-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0; /* prevent overflow */
|
|
||||||
contents: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header { display: none; } /* wordmark lives in sidebar on desktop */
|
|
||||||
|
|
||||||
/* Override style.css #app max-width so sidebar spans full viewport */
|
|
||||||
#app {
|
|
||||||
max-width: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--spacing-xl) 0;
|
padding: var(--spacing-xl) 0;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .tagline {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: var(--spacing-lg) 0;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer p {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 15px 30px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive Breakpoints */
|
||||||
|
@media (max-width: 480px) {
|
||||||
.container {
|
.container {
|
||||||
max-width: 860px;
|
padding: 0 12px;
|
||||||
padding: 0 var(--spacing-lg);
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header .tagline {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 481px) and (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
max-width: 960px;
|
padding: 0 16px;
|
||||||
padding: 0 var(--spacing-xl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Easter egg: wordmark spin on 5× click */
|
.app-header h1 {
|
||||||
@keyframes chefSpin {
|
font-size: 28px;
|
||||||
0% { transform: rotate(0deg) scale(1); }
|
|
||||||
30% { transform: rotate(180deg) scale(1.3); }
|
|
||||||
60% { transform: rotate(340deg) scale(1.1); }
|
|
||||||
100% { transform: rotate(360deg) scale(1); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wordmark-kiwi.chef-spin {
|
.tab {
|
||||||
display: inline-block;
|
padding: 14px 25px;
|
||||||
animation: chefSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kiwi bird transition */
|
|
||||||
.kiwi-fade-enter-active,
|
|
||||||
.kiwi-fade-leave-active {
|
|
||||||
transition: opacity 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kiwi-fade-enter-from,
|
|
||||||
.kiwi-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Product</label>
|
<label>Product</label>
|
||||||
<div class="product-info">
|
<div class="product-info">
|
||||||
<strong>{{ item.product_name || 'Unknown Product' }}</strong>
|
<strong>{{ item.product.name }}</strong>
|
||||||
<span v-if="item.category" class="brand">{{ item.category }}</span>
|
<span v-if="item.product.brand" class="brand">({{ item.product.brand }})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -228,183 +228,160 @@ function getExpiryHint(): string {
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-lg);
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md);
|
padding: 20px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
font-family: var(--font-display);
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 28px;
|
font-size: 32px;
|
||||||
color: var(--color-text-muted);
|
color: #999;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
transition: color 0.18s, background 0.18s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
padding: var(--spacing-lg);
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Using .form-row from theme.css */
|
/* Using .form-row from theme.css */
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-primary);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-sm);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: 10px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
background: var(--color-bg-input);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
transition: border-color 0.18s, box-shadow 0.18s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus {
|
.form-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: #2196F3;
|
||||||
box-shadow: 0 0 0 3px var(--color-warning-bg);
|
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input.expiry-expired {
|
.form-input.expiry-expired {
|
||||||
border-color: var(--color-error);
|
border-color: #f44336;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input.expiry-soon {
|
.form-input.expiry-soon {
|
||||||
border-color: var(--color-error-light);
|
border-color: #ff5722;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input.expiry-warning {
|
.form-input.expiry-warning {
|
||||||
border-color: var(--color-warning);
|
border-color: #ff9800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input.expiry-good {
|
.form-input.expiry-good {
|
||||||
border-color: var(--color-success);
|
border-color: #4CAF50;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea.form-input {
|
textarea.form-input {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-family: var(--font-body);
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-info {
|
.product-info {
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: 10px;
|
||||||
background: var(--color-bg-secondary);
|
background: #f5f5f5;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-info .brand {
|
.product-info .brand {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
margin-left: var(--spacing-sm);
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expiry-hint {
|
.expiry-hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: 5px;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
background: var(--color-error-bg);
|
background: #ffebee;
|
||||||
color: var(--color-error-light);
|
color: #c62828;
|
||||||
border: 1px solid var(--color-error-border);
|
padding: 12px;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
border-radius: var(--radius-sm);
|
||||||
border-radius: var(--radius-md);
|
margin-bottom: 15px;
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-sm);
|
gap: 10px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: var(--spacing-lg);
|
margin-top: 25px;
|
||||||
padding-top: var(--spacing-md);
|
padding-top: 20px;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel,
|
.btn-cancel,
|
||||||
.btn-save {
|
.btn-save {
|
||||||
padding: var(--spacing-sm) var(--spacing-lg);
|
padding: 10px 24px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: var(--font-body);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.18s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
background: var(--color-bg-elevated);
|
background: #f5f5f5;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-primary);
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel:hover {
|
.btn-cancel:hover {
|
||||||
background: var(--color-bg-primary);
|
background: #e0e0e0;
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-save {
|
.btn-save {
|
||||||
|
|
@ -417,7 +394,7 @@ textarea.form-input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-save:disabled {
|
.btn-save:disabled {
|
||||||
opacity: 0.45;
|
background: var(--color-text-muted);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,7 +408,7 @@ textarea.form-input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
padding: var(--spacing-md);
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
|
|
@ -439,24 +416,23 @@ textarea.form-input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
padding: var(--spacing-md);
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form actions stack on very small screens */
|
/* Form actions stack on very small screens */
|
||||||
.form-actions {
|
.form-actions {
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
gap: var(--spacing-sm);
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel,
|
.btn-cancel,
|
||||||
.btn-save {
|
.btn-save {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--spacing-md);
|
padding: 12px;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,5 +440,13 @@ textarea.form-input {
|
||||||
.modal-content {
|
.modal-content {
|
||||||
width: 92%;
|
width: 92%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,413 +0,0 @@
|
||||||
<template>
|
|
||||||
<!-- Floating trigger button -->
|
|
||||||
<button
|
|
||||||
v-if="enabled"
|
|
||||||
class="feedback-fab"
|
|
||||||
@click="open = true"
|
|
||||||
aria-label="Send feedback or report a bug"
|
|
||||||
title="Send feedback or report a bug"
|
|
||||||
>
|
|
||||||
<svg class="feedback-fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="feedback-fab-label">Feedback</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Modal — teleported to body to avoid z-index / overflow clipping -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="modal-fade">
|
|
||||||
<div v-if="open" class="feedback-overlay" @click.self="close">
|
|
||||||
<div class="feedback-modal" role="dialog" aria-modal="true" aria-label="Send Feedback">
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="feedback-header">
|
|
||||||
<h2 class="feedback-title">{{ step === 1 ? "What's on your mind?" : "Review & submit" }}</h2>
|
|
||||||
<button class="feedback-close" @click="close" aria-label="Close">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Step 1: Form ─────────────────────────────────────────── -->
|
|
||||||
<div v-if="step === 1" class="feedback-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Type</label>
|
|
||||||
<div class="filter-chip-row">
|
|
||||||
<button
|
|
||||||
v-for="t in types"
|
|
||||||
:key="t.value"
|
|
||||||
:class="['btn-chip', { active: form.type === t.value }]"
|
|
||||||
@click="form.type = t.value"
|
|
||||||
type="button"
|
|
||||||
>{{ t.label }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Title <span class="form-required">*</span></label>
|
|
||||||
<input
|
|
||||||
v-model="form.title"
|
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Short summary of the issue or idea"
|
|
||||||
maxlength="120"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Description <span class="form-required">*</span></label>
|
|
||||||
<textarea
|
|
||||||
v-model="form.description"
|
|
||||||
class="form-input feedback-textarea"
|
|
||||||
placeholder="Describe what happened or what you'd like to see…"
|
|
||||||
rows="4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="form.type === 'bug'" class="form-group">
|
|
||||||
<label class="form-label">Reproduction steps</label>
|
|
||||||
<textarea
|
|
||||||
v-model="form.repro"
|
|
||||||
class="form-input feedback-textarea"
|
|
||||||
placeholder="1. Go to… 2. Tap… 3. See error"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="stepError" class="feedback-error">{{ stepError }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Step 2: Attribution + confirm ──────────────────────────── -->
|
|
||||||
<div v-if="step === 2" class="feedback-body">
|
|
||||||
<div class="feedback-summary card">
|
|
||||||
<div class="feedback-summary-row">
|
|
||||||
<span class="text-muted text-sm">Type</span>
|
|
||||||
<span class="text-sm font-semibold">{{ typeLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="feedback-summary-row">
|
|
||||||
<span class="text-muted text-sm">Title</span>
|
|
||||||
<span class="text-sm">{{ form.title }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="feedback-summary-row">
|
|
||||||
<span class="text-muted text-sm">Description</span>
|
|
||||||
<span class="text-sm feedback-summary-desc">{{ form.description }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group mt-md">
|
|
||||||
<label class="form-label">Attribution (optional)</label>
|
|
||||||
<input
|
|
||||||
v-model="form.submitter"
|
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Your name <email@example.com>"
|
|
||||||
/>
|
|
||||||
<p class="text-muted text-xs mt-xs">Include your name and email in the issue if you'd like a response. Never required.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="submitError" class="feedback-error">{{ submitError }}</p>
|
|
||||||
<div v-if="submitted" class="feedback-success">
|
|
||||||
Issue filed! <a :href="issueUrl" target="_blank" rel="noopener" class="feedback-link">View on Forgejo →</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer nav -->
|
|
||||||
<div class="feedback-footer">
|
|
||||||
<button v-if="step === 2 && !submitted" class="btn btn-ghost" @click="step = 1" :disabled="loading">← Back</button>
|
|
||||||
<button v-if="!submitted" class="btn btn-ghost" @click="close" :disabled="loading">Cancel</button>
|
|
||||||
<button
|
|
||||||
v-if="step === 1"
|
|
||||||
class="btn btn-primary"
|
|
||||||
@click="nextStep"
|
|
||||||
>Next →</button>
|
|
||||||
<button
|
|
||||||
v-if="step === 2 && !submitted"
|
|
||||||
class="btn btn-primary"
|
|
||||||
@click="submit"
|
|
||||||
:disabled="loading"
|
|
||||||
>{{ loading ? 'Filing…' : 'Submit' }}</button>
|
|
||||||
<button v-if="submitted" class="btn btn-primary" @click="close">Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps<{ currentTab?: string }>()
|
|
||||||
|
|
||||||
// Probe once on mount — hidden until confirmed enabled so button never flashes
|
|
||||||
const enabled = ref(false)
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/feedback/status')
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
enabled.value = data.enabled === true
|
|
||||||
}
|
|
||||||
} catch { /* network error — stay hidden */ }
|
|
||||||
})
|
|
||||||
|
|
||||||
const open = ref(false)
|
|
||||||
const step = ref(1)
|
|
||||||
const loading = ref(false)
|
|
||||||
const stepError = ref('')
|
|
||||||
const submitError = ref('')
|
|
||||||
const submitted = ref(false)
|
|
||||||
const issueUrl = ref('')
|
|
||||||
|
|
||||||
const types: { value: 'bug' | 'feature' | 'other'; label: string }[] = [
|
|
||||||
{ value: 'bug', label: '🐛 Bug' },
|
|
||||||
{ value: 'feature', label: '✨ Feature request' },
|
|
||||||
{ value: 'other', label: '💬 Other' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
type: 'bug' as 'bug' | 'feature' | 'other',
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
repro: '',
|
|
||||||
submitter: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeLabel = computed(() => types.find(t => t.value === form.value.type)?.label ?? '')
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
open.value = false
|
|
||||||
// reset after transition
|
|
||||||
setTimeout(reset, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
step.value = 1
|
|
||||||
loading.value = false
|
|
||||||
stepError.value = ''
|
|
||||||
submitError.value = ''
|
|
||||||
submitted.value = false
|
|
||||||
issueUrl.value = ''
|
|
||||||
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextStep() {
|
|
||||||
stepError.value = ''
|
|
||||||
if (!form.value.title.trim() || !form.value.description.trim()) {
|
|
||||||
stepError.value = 'Please fill in both Title and Description.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
step.value = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
loading.value = true
|
|
||||||
submitError.value = ''
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v1/feedback', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: form.value.title.trim(),
|
|
||||||
description: form.value.description.trim(),
|
|
||||||
type: form.value.type,
|
|
||||||
repro: form.value.repro.trim(),
|
|
||||||
tab: props.currentTab ?? 'unknown',
|
|
||||||
submitter: form.value.submitter.trim(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
|
||||||
submitError.value = err.detail ?? 'Submission failed.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
|
||||||
issueUrl.value = data.issue_url
|
|
||||||
submitted.value = true
|
|
||||||
} catch (e) {
|
|
||||||
submitError.value = 'Network error — please try again.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Floating action button ─────────────────────────────────────────── */
|
|
||||||
.feedback-fab {
|
|
||||||
position: fixed;
|
|
||||||
right: var(--spacing-md);
|
|
||||||
bottom: calc(68px + var(--spacing-md)); /* above mobile bottom nav */
|
|
||||||
z-index: 190;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: 9px var(--spacing-md);
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.feedback-fab:hover {
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border-color: var(--color-border-focus);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
.feedback-fab-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
|
||||||
.feedback-fab-label { white-space: nowrap; }
|
|
||||||
|
|
||||||
/* On desktop, bottom nav is gone — drop to standard corner */
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.feedback-fab {
|
|
||||||
bottom: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Overlay ──────────────────────────────────────────────────────────── */
|
|
||||||
.feedback-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 500px) {
|
|
||||||
.feedback-overlay {
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal ────────────────────────────────────────────────────────────── */
|
|
||||||
.feedback-modal {
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 500px) {
|
|
||||||
.feedback-modal {
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
|
||||||
max-height: 85vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.feedback-title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.feedback-close {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.feedback-close:hover { color: var(--color-text-primary); }
|
|
||||||
|
|
||||||
.feedback-body {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-required { color: var(--color-error); margin-left: 2px; }
|
|
||||||
|
|
||||||
.feedback-error {
|
|
||||||
color: var(--color-error);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-success {
|
|
||||||
color: var(--color-success);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
background: var(--color-success-bg);
|
|
||||||
border: 1px solid var(--color-success-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
.feedback-link { color: var(--color-success); font-weight: 600; text-decoration: underline; }
|
|
||||||
|
|
||||||
/* Summary card (step 2) */
|
|
||||||
.feedback-summary {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.feedback-summary-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.feedback-summary-row > :first-child { min-width: 72px; flex-shrink: 0; }
|
|
||||||
.feedback-summary-desc {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-md { margin-top: var(--spacing-md); }
|
|
||||||
.mt-xs { margin-top: var(--spacing-xs); }
|
|
||||||
|
|
||||||
/* Transition */
|
|
||||||
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
|
|
||||||
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="receipts-view">
|
<div class="receipts-view">
|
||||||
<!-- Upload Section -->
|
<!-- Upload Section -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="section-title mb-md">Upload Receipt</h2>
|
<h2>📸 Upload Receipt</h2>
|
||||||
<div
|
<div
|
||||||
class="upload-area"
|
class="upload-area"
|
||||||
@click="triggerFileInput"
|
@click="triggerFileInput"
|
||||||
|
|
@ -21,9 +21,9 @@
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="uploading" class="loading-inline mt-md">
|
<div v-if="uploading" class="loading">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<span class="text-sm text-muted">Processing receipt…</span>
|
<p>Processing receipt...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="uploadResults.length > 0" class="results">
|
<div v-if="uploadResults.length > 0" class="results">
|
||||||
|
|
@ -39,8 +39,8 @@
|
||||||
|
|
||||||
<!-- Receipts List Section -->
|
<!-- Receipts List Section -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="section-title mb-md">Recent Receipts</h2>
|
<h2>📋 Recent Receipts</h2>
|
||||||
<div v-if="receipts.length === 0" class="text-center text-secondary p-lg">
|
<div v-if="receipts.length === 0" style="text-align: center; color: var(--color-text-secondary)">
|
||||||
<p>No receipts yet. Upload one above!</p>
|
<p>No receipts yet. Upload one above!</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
|
@ -89,9 +89,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-sm mt-md">
|
<div style="margin-top: 20px">
|
||||||
<button class="btn btn-secondary" @click="exportCSV">Download CSV</button>
|
<button class="button" @click="exportCSV">📊 Download CSV</button>
|
||||||
<button class="btn btn-secondary" @click="exportExcel">Download Excel</button>
|
<button class="button" @click="exportExcel">📈 Download Excel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,117 +225,157 @@ onMounted(() => {
|
||||||
.receipts-view {
|
.receipts-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-md);
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-area {
|
.upload-area {
|
||||||
border: 2px dashed var(--color-border-focus);
|
border: 3px dashed var(--color-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--spacing-xl) var(--spacing-lg);
|
padding: 40px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.3s;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-area:hover {
|
.upload-area:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-secondary);
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-icon {
|
.upload-icon {
|
||||||
font-size: 40px;
|
font-size: 48px;
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: 20px;
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-text {
|
.upload-text {
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-hint {
|
.upload-hint {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-inline {
|
.loading {
|
||||||
display: flex;
|
text-align: center;
|
||||||
align-items: center;
|
padding: 20px;
|
||||||
gap: var(--spacing-sm);
|
margin-top: 20px;
|
||||||
padding: var(--spacing-sm) 0;
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
margin-top: var(--spacing-md);
|
margin-top: 20px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-item {
|
.result-item {
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: 15px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-success {
|
.result-success {
|
||||||
background: var(--color-success-bg);
|
background: var(--color-success-bg);
|
||||||
color: var(--color-success-light);
|
color: var(--color-success-dark);
|
||||||
border: 1px solid var(--color-success-border);
|
border: 1px solid var(--color-success-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-error {
|
.result-error {
|
||||||
background: var(--color-error-bg);
|
background: var(--color-error-bg);
|
||||||
color: var(--color-error-light);
|
color: var(--color-error-dark);
|
||||||
border: 1px solid var(--color-error-border);
|
border: 1px solid var(--color-error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-info {
|
.result-info {
|
||||||
background: var(--color-info-bg);
|
background: var(--color-info-bg);
|
||||||
color: var(--color-info-light);
|
color: var(--color-info-dark);
|
||||||
border: 1px solid var(--color-info-border);
|
border: 1px solid var(--color-info-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stat cards */
|
/* Using .grid-stats from theme.css */
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
padding: var(--spacing-md);
|
padding: 20px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--font-size-2xl);
|
font-size: var(--font-size-2xl);
|
||||||
font-weight: 500;
|
font-weight: bold;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: 5px;
|
||||||
line-height: 1.1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-secondary);
|
||||||
text-transform: uppercase;
|
}
|
||||||
letter-spacing: 0.05em;
|
|
||||||
|
.button {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipts-list {
|
.receipts-list {
|
||||||
margin-top: var(--spacing-md);
|
margin-top: 20px;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-item {
|
.receipt-item {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: 15px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid var(--color-border);
|
margin-bottom: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -348,7 +388,7 @@ onMounted(() => {
|
||||||
.receipt-merchant {
|
.receipt-merchant {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: 5px;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,7 +396,7 @@ onMounted(() => {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-md);
|
gap: 15px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,17 +419,20 @@ onMounted(() => {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile Responsive - Handled by theme.css
|
||||||
|
Component-specific overrides only below */
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.stat-card {
|
.stat-card {
|
||||||
padding: var(--spacing-sm);
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Receipt items stack content vertically */
|
||||||
.receipt-item {
|
.receipt-item {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--spacing-sm);
|
gap: 12px;
|
||||||
padding: var(--spacing-sm);
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-info {
|
.receipt-info {
|
||||||
|
|
@ -397,8 +440,15 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-details {
|
.receipt-details {
|
||||||
gap: var(--spacing-sm);
|
gap: 10px;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Buttons full width on mobile */
|
||||||
|
.button {
|
||||||
|
width: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,339 +0,0 @@
|
||||||
<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 + Surprise Me -->
|
|
||||||
<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>
|
|
||||||
<button
|
|
||||||
v-if="categories.length > 1"
|
|
||||||
class="btn btn-secondary cat-btn surprise-btn"
|
|
||||||
@click="surpriseMe"
|
|
||||||
title="Pick a random category"
|
|
||||||
>
|
|
||||||
🎲 Surprise me
|
|
||||||
</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">Loading recipes…</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!loadingDomains" class="text-secondary text-sm">Loading…</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)
|
|
||||||
// Auto-select the most-populated category so content appears immediately
|
|
||||||
if (categories.value.length > 0) {
|
|
||||||
const top = categories.value.reduce((best, c) =>
|
|
||||||
c.recipe_count > best.recipe_count ? c : best, categories.value[0]!)
|
|
||||||
selectCategory(top.category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function surpriseMe() {
|
|
||||||
if (categories.value.length === 0) return
|
|
||||||
const pick = categories.value[Math.floor(Math.random() * categories.value.length)]!
|
|
||||||
selectCategory(pick.category)
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.surprise-btn {
|
|
||||||
opacity: 0.75;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.surprise-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
|
|
@ -1,802 +0,0 @@
|
||||||
<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>
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,294 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,289 +0,0 @@
|
||||||
<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">A–Z</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>
|
|
||||||
|
|
@ -1,453 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="settings-view">
|
|
||||||
<div class="card">
|
|
||||||
<h2 class="section-title text-xl mb-md">Settings</h2>
|
|
||||||
|
|
||||||
<!-- Cooking Equipment -->
|
|
||||||
<section>
|
|
||||||
<h3 class="text-lg font-semibold mb-xs">Cooking Equipment</h3>
|
|
||||||
<p class="text-sm text-secondary mb-md">
|
|
||||||
Tell Kiwi what you have — used when Hard Day Mode is on to filter out recipes requiring
|
|
||||||
equipment you don't own.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Current equipment tags -->
|
|
||||||
<div class="tags-wrap flex flex-wrap gap-xs mb-sm">
|
|
||||||
<span
|
|
||||||
v-for="item in settingsStore.cookingEquipment"
|
|
||||||
:key="item"
|
|
||||||
class="tag-chip status-badge status-info"
|
|
||||||
>
|
|
||||||
{{ item }}
|
|
||||||
<button class="chip-remove" @click="removeEquipment(item)" aria-label="Remove">×</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom input -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Add equipment</label>
|
|
||||||
<input
|
|
||||||
class="form-input"
|
|
||||||
v-model="equipmentInput"
|
|
||||||
placeholder="Type equipment name, press Enter or comma"
|
|
||||||
@keydown="onEquipmentKey"
|
|
||||||
@blur="commitEquipmentInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick-add chips -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Quick-add</label>
|
|
||||||
<div class="flex flex-wrap gap-xs">
|
|
||||||
<button
|
|
||||||
v-for="eq in quickAddOptions"
|
|
||||||
:key="eq"
|
|
||||||
:class="['btn', 'btn-sm', 'btn-secondary', { active: settingsStore.cookingEquipment.includes(eq) }]"
|
|
||||||
@click="toggleEquipment(eq)"
|
|
||||||
>
|
|
||||||
{{ eq }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save button -->
|
|
||||||
<div class="flex-start gap-sm">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="settingsStore.loading"
|
|
||||||
@click="settingsStore.save()"
|
|
||||||
>
|
|
||||||
<span v-if="settingsStore.loading">Saving…</span>
|
|
||||||
<span v-else-if="settingsStore.saved">✓ Saved!</span>
|
|
||||||
<span v-else>Save Settings</span>
|
|
||||||
</button>
|
|
||||||
</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, 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('')
|
|
||||||
|
|
||||||
const quickAddOptions = [
|
|
||||||
'Oven',
|
|
||||||
'Stovetop',
|
|
||||||
'Microwave',
|
|
||||||
'Air Fryer',
|
|
||||||
'Instant Pot',
|
|
||||||
'Slow Cooker',
|
|
||||||
'Grill',
|
|
||||||
'Blender',
|
|
||||||
]
|
|
||||||
|
|
||||||
function addEquipment(value: string) {
|
|
||||||
const item = value.trim()
|
|
||||||
if (item && !settingsStore.cookingEquipment.includes(item)) {
|
|
||||||
settingsStore.cookingEquipment = [...settingsStore.cookingEquipment, item]
|
|
||||||
}
|
|
||||||
equipmentInput.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeEquipment(item: string) {
|
|
||||||
settingsStore.cookingEquipment = settingsStore.cookingEquipment.filter((e) => e !== item)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEquipment(item: string) {
|
|
||||||
if (settingsStore.cookingEquipment.includes(item)) {
|
|
||||||
removeEquipment(item)
|
|
||||||
} else {
|
|
||||||
addEquipment(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onEquipmentKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter' || e.key === ',') {
|
|
||||||
e.preventDefault()
|
|
||||||
addEquipment(equipmentInput.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitEquipmentInput() {
|
|
||||||
if (equipmentInput.value.trim()) {
|
|
||||||
addEquipment(equipmentInput.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mb-md {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-sm {
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-xs {
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-remove {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
color: inherit;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-remove:hover {
|
|
||||||
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>
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
|
||||||
|
|
||||||
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
|
|
||||||
const KIWI_WORD = ['k','i','w','i']
|
|
||||||
|
|
||||||
// Module-level shared state — single instance across all component uses
|
|
||||||
const neonMode = ref(false)
|
|
||||||
const kiwiVisible = ref(false)
|
|
||||||
const kiwiDirection = ref<'ltr' | 'rtl'>('rtl') // bird enters from right by default
|
|
||||||
|
|
||||||
const NEON_VARS: Record<string, string> = {
|
|
||||||
'--color-bg-primary': '#070011',
|
|
||||||
'--color-bg-secondary': '#0f001f',
|
|
||||||
'--color-bg-elevated': '#160028',
|
|
||||||
'--color-bg-card': '#160028',
|
|
||||||
'--color-bg-input': '#0f001f',
|
|
||||||
'--color-primary': '#ff006e',
|
|
||||||
'--color-text-primary': '#f0e6ff',
|
|
||||||
'--color-text-secondary': '#c090ff',
|
|
||||||
'--color-text-muted': '#7040a0',
|
|
||||||
'--color-border': 'rgba(255, 0, 110, 0.22)',
|
|
||||||
'--color-border-focus': '#ff006e',
|
|
||||||
'--color-info': '#00f5ff',
|
|
||||||
'--color-info-bg': 'rgba(0, 245, 255, 0.10)',
|
|
||||||
'--color-info-border': 'rgba(0, 245, 255, 0.30)',
|
|
||||||
'--color-info-light': '#00f5ff',
|
|
||||||
'--color-success': '#39ff14',
|
|
||||||
'--color-success-bg': 'rgba(57, 255, 20, 0.10)',
|
|
||||||
'--color-success-border': 'rgba(57, 255, 20, 0.30)',
|
|
||||||
'--color-success-light': '#39ff14',
|
|
||||||
'--color-warning': '#ffbe0b',
|
|
||||||
'--color-warning-bg': 'rgba(255, 190, 11, 0.10)',
|
|
||||||
'--color-warning-border': 'rgba(255, 190, 11, 0.30)',
|
|
||||||
'--color-warning-light': '#ffbe0b',
|
|
||||||
'--shadow-amber': '0 0 18px rgba(255, 0, 110, 0.55)',
|
|
||||||
'--shadow-md': '0 2px 16px rgba(255, 0, 110, 0.18)',
|
|
||||||
'--shadow-lg': '0 4px 28px rgba(255, 0, 110, 0.25)',
|
|
||||||
'--gradient-primary': 'linear-gradient(135deg, #ff006e 0%, #8338ec 100%)',
|
|
||||||
'--gradient-header': 'linear-gradient(135deg, #070011 0%, #160028 100%)',
|
|
||||||
'--color-loc-fridge': '#00f5ff',
|
|
||||||
'--color-loc-freezer': '#8338ec',
|
|
||||||
'--color-loc-pantry': '#ff006e',
|
|
||||||
'--color-loc-cabinet': '#ffbe0b',
|
|
||||||
'--color-loc-garage-freezer': '#39ff14',
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyNeon() {
|
|
||||||
const root = document.documentElement
|
|
||||||
for (const [prop, val] of Object.entries(NEON_VARS)) {
|
|
||||||
root.style.setProperty(prop, val)
|
|
||||||
}
|
|
||||||
document.body.classList.add('neon-mode')
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeNeon() {
|
|
||||||
const root = document.documentElement
|
|
||||||
for (const prop of Object.keys(NEON_VARS)) {
|
|
||||||
root.style.removeProperty(prop)
|
|
||||||
}
|
|
||||||
document.body.classList.remove('neon-mode')
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleNeon() {
|
|
||||||
neonMode.value = !neonMode.value
|
|
||||||
if (neonMode.value) {
|
|
||||||
applyNeon()
|
|
||||||
localStorage.setItem('kiwi-neon-mode', '1')
|
|
||||||
} else {
|
|
||||||
removeNeon()
|
|
||||||
localStorage.removeItem('kiwi-neon-mode')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnKiwi() {
|
|
||||||
kiwiDirection.value = Math.random() > 0.5 ? 'ltr' : 'rtl'
|
|
||||||
kiwiVisible.value = true
|
|
||||||
setTimeout(() => { kiwiVisible.value = false }, 5500)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEasterEggs() {
|
|
||||||
const konamiBuffer: string[] = []
|
|
||||||
const kiwiBuffer: string[] = []
|
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
|
||||||
// Skip when user is typing in a form input
|
|
||||||
const tag = (e.target as HTMLElement)?.tagName
|
|
||||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|
|
||||||
|
|
||||||
// Konami code — works even in inputs
|
|
||||||
konamiBuffer.push(e.key)
|
|
||||||
if (konamiBuffer.length > KONAMI.length) konamiBuffer.shift()
|
|
||||||
if (konamiBuffer.join(',') === KONAMI.join(',')) {
|
|
||||||
toggleNeon()
|
|
||||||
konamiBuffer.length = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// KIWI word — only when not in a form input
|
|
||||||
if (!isInput) {
|
|
||||||
const key = e.key.toLowerCase()
|
|
||||||
if ('kiwi'.includes(key) && key.length === 1) {
|
|
||||||
kiwiBuffer.push(key)
|
|
||||||
if (kiwiBuffer.length > KIWI_WORD.length) kiwiBuffer.shift()
|
|
||||||
if (kiwiBuffer.join('') === 'kiwi') {
|
|
||||||
spawnKiwi()
|
|
||||||
kiwiBuffer.length = 0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
kiwiBuffer.length = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (localStorage.getItem('kiwi-neon-mode')) {
|
|
||||||
neonMode.value = true
|
|
||||||
applyNeon()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('keydown', onKeyDown)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
neonMode,
|
|
||||||
kiwiVisible,
|
|
||||||
kiwiDirection,
|
|
||||||
toggleNeon,
|
|
||||||
spawnKiwi,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -80,11 +80,9 @@ export interface Tag {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryItem {
|
export interface InventoryItem {
|
||||||
id: number
|
id: string
|
||||||
product_id: number
|
product_id: string
|
||||||
product_name: string | null
|
product: Product
|
||||||
barcode: string | null
|
|
||||||
category: string | null
|
|
||||||
quantity: number
|
quantity: number
|
||||||
unit: string
|
unit: string
|
||||||
location: string
|
location: string
|
||||||
|
|
@ -111,10 +109,11 @@ export interface InventoryItemUpdate {
|
||||||
|
|
||||||
export interface InventoryStats {
|
export interface InventoryStats {
|
||||||
total_items: number
|
total_items: number
|
||||||
available_items: number
|
total_products: number
|
||||||
expiring_soon: number
|
expiring_soon: number
|
||||||
expired_items: number
|
expired: number
|
||||||
locations: Record<string, number>
|
items_by_location: Record<string, number>
|
||||||
|
items_by_status: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Receipt {
|
export interface Receipt {
|
||||||
|
|
@ -186,7 +185,7 @@ export const inventoryAPI = {
|
||||||
/**
|
/**
|
||||||
* Update an inventory item
|
* Update an inventory item
|
||||||
*/
|
*/
|
||||||
async updateItem(itemId: number, update: InventoryItemUpdate): Promise<InventoryItem> {
|
async updateItem(itemId: string, update: InventoryItemUpdate): Promise<InventoryItem> {
|
||||||
const response = await api.patch(`/inventory/items/${itemId}`, update)
|
const response = await api.patch(`/inventory/items/${itemId}`, update)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
@ -194,7 +193,7 @@ export const inventoryAPI = {
|
||||||
/**
|
/**
|
||||||
* Delete an inventory item
|
* Delete an inventory item
|
||||||
*/
|
*/
|
||||||
async deleteItem(itemId: number): Promise<void> {
|
async deleteItem(itemId: string): Promise<void> {
|
||||||
await api.delete(`/inventory/items/${itemId}`)
|
await api.delete(`/inventory/items/${itemId}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -235,7 +234,7 @@ export const inventoryAPI = {
|
||||||
/**
|
/**
|
||||||
* Mark item as consumed
|
* Mark item as consumed
|
||||||
*/
|
*/
|
||||||
async consumeItem(itemId: number): Promise<void> {
|
async consumeItem(itemId: string): Promise<void> {
|
||||||
await api.post(`/inventory/items/${itemId}/consume`)
|
await api.post(`/inventory/items/${itemId}/consume`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -266,20 +265,6 @@ export const inventoryAPI = {
|
||||||
return response.data
|
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
|
* Scan barcode from image
|
||||||
*/
|
*/
|
||||||
|
|
@ -419,276 +404,4 @@ export const exportAPI = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Recipes & Settings Types ==========
|
|
||||||
|
|
||||||
export interface SwapCandidate {
|
|
||||||
original_name: string
|
|
||||||
substitute_name: string
|
|
||||||
constraint_label: string
|
|
||||||
explanation: string
|
|
||||||
compensation_hints: Record<string, string>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NutritionPanel {
|
|
||||||
calories: number | null
|
|
||||||
fat_g: number | null
|
|
||||||
protein_g: number | null
|
|
||||||
carbs_g: number | null
|
|
||||||
fiber_g: number | null
|
|
||||||
sugar_g: number | null
|
|
||||||
sodium_mg: number | null
|
|
||||||
servings: number | null
|
|
||||||
estimated: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeSuggestion {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
match_count: number
|
|
||||||
element_coverage: Record<string, number>
|
|
||||||
swap_candidates: SwapCandidate[]
|
|
||||||
matched_ingredients: string[]
|
|
||||||
missing_ingredients: string[]
|
|
||||||
directions: string[]
|
|
||||||
prep_notes: string[]
|
|
||||||
notes: string
|
|
||||||
level: number
|
|
||||||
is_wildcard: boolean
|
|
||||||
nutrition: NutritionPanel | null
|
|
||||||
source_url: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NutritionFilters {
|
|
||||||
max_calories: number | null
|
|
||||||
max_sugar_g: number | null
|
|
||||||
max_carbs_g: number | null
|
|
||||||
max_sodium_mg: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroceryLink {
|
|
||||||
ingredient: string
|
|
||||||
retailer: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeResult {
|
|
||||||
suggestions: RecipeSuggestion[]
|
|
||||||
element_gaps: string[]
|
|
||||||
grocery_list: string[]
|
|
||||||
grocery_links: GroceryLink[]
|
|
||||||
rate_limited: boolean
|
|
||||||
rate_limit_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeRequest {
|
|
||||||
pantry_items: string[]
|
|
||||||
level: number
|
|
||||||
constraints: string[]
|
|
||||||
allergies: string[]
|
|
||||||
expiry_first: boolean
|
|
||||||
hard_day_mode: boolean
|
|
||||||
max_missing: number | null
|
|
||||||
style_id: string | null
|
|
||||||
category: string | null
|
|
||||||
wildcard_confirmed: boolean
|
|
||||||
nutrition_filters: NutritionFilters
|
|
||||||
excluded_ids: number[]
|
|
||||||
shopping_mode: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Staple {
|
|
||||||
slug: string
|
|
||||||
name: string
|
|
||||||
category: string
|
|
||||||
dietary_tags: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Recipes API ==========
|
|
||||||
|
|
||||||
export const recipesAPI = {
|
|
||||||
async suggest(req: RecipeRequest): Promise<RecipeResult> {
|
|
||||||
const response = await api.post('/recipes/suggest', req)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
|
||||||
const response = await api.get(`/recipes/${id}`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
async listStaples(dietary?: string): Promise<Staple[]> {
|
|
||||||
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Settings API ==========
|
|
||||||
|
|
||||||
export const settingsAPI = {
|
|
||||||
async getSetting(key: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/settings/${key}`)
|
|
||||||
return response.data.value
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async setSetting(key: string, value: string): Promise<void> {
|
|
||||||
await api.put(`/settings/${key}`, { value })
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 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
|
export default api
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateItem(itemId: number, update: InventoryItemUpdate) {
|
async function updateItem(itemId: string, update: InventoryItemUpdate) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
|
|
@ -99,7 +99,7 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteItem(itemId: number) {
|
async function deleteItem(itemId: string) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
|
|
@ -143,13 +143,6 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
locationFilter.value = location
|
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) {
|
function setStatusFilter(status: string) {
|
||||||
statusFilter.value = status
|
statusFilter.value = status
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +166,6 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
fetchStats,
|
fetchStats,
|
||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
consumeItem,
|
|
||||||
scanBarcode,
|
scanBarcode,
|
||||||
setLocationFilter,
|
setLocationFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
|
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
/**
|
|
||||||
* Recipes Store
|
|
||||||
*
|
|
||||||
* Manages recipe suggestion state and request parameters using Pinia.
|
|
||||||
* Dismissed recipe IDs are persisted to localStorage with a 7-day TTL.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type NutritionFilters } from '../services/api'
|
|
||||||
|
|
||||||
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)
|
|
||||||
if (!raw) return new Set()
|
|
||||||
const entries: DismissEntry[] = JSON.parse(raw)
|
|
||||||
const cutoff = Date.now() - DISMISS_TTL_MS
|
|
||||||
return new Set(entries.filter(([, ts]) => ts > cutoff).map(([id]) => id))
|
|
||||||
} catch {
|
|
||||||
return new Set()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDismissed(ids: Set<number>) {
|
|
||||||
const now = Date.now()
|
|
||||||
const entries: DismissEntry[] = [...ids].map((id) => [id, now])
|
|
||||||
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)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
|
|
||||||
// Request parameters
|
|
||||||
const level = ref(1)
|
|
||||||
const constraints = ref<string[]>([])
|
|
||||||
const allergies = ref<string[]>([])
|
|
||||||
const hardDayMode = ref(false)
|
|
||||||
const maxMissing = ref<number | null>(null)
|
|
||||||
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,
|
|
||||||
max_carbs_g: null,
|
|
||||||
max_sodium_mg: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Dismissed IDs: persisted to localStorage, 7-day TTL
|
|
||||||
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)
|
|
||||||
|
|
||||||
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
|
|
||||||
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
|
|
||||||
return {
|
|
||||||
pantry_items: pantryItems,
|
|
||||||
level: level.value,
|
|
||||||
constraints: constraints.value,
|
|
||||||
allergies: allergies.value,
|
|
||||||
expiry_first: true,
|
|
||||||
hard_day_mode: hardDayMode.value,
|
|
||||||
max_missing: maxMissing.value,
|
|
||||||
style_id: styleId.value,
|
|
||||||
category: category.value,
|
|
||||||
wildcard_confirmed: wildcardConfirmed.value,
|
|
||||||
nutrition_filters: nutritionFilters.value,
|
|
||||||
excluded_ids: [...excluded],
|
|
||||||
shopping_mode: shoppingMode.value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _trackSeen(suggestions: RecipeSuggestion[]) {
|
|
||||||
for (const s of suggestions) {
|
|
||||||
if (s.id) seenIds.value = new Set([...seenIds.value, s.id])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function suggest(pantryItems: string[]) {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
seenIds.value = new Set()
|
|
||||||
|
|
||||||
try {
|
|
||||||
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
|
|
||||||
_trackSeen(result.value.suggestions)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore(pantryItems: string[]) {
|
|
||||||
if (!result.value || loading.value) return
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Exclude everything already shown (dismissed + all seen this session)
|
|
||||||
const more = await recipesAPI.suggest(_buildRequest(pantryItems, [...seenIds.value]))
|
|
||||||
if (more.suggestions.length === 0) {
|
|
||||||
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
|
|
||||||
} else {
|
|
||||||
result.value = {
|
|
||||||
...result.value,
|
|
||||||
suggestions: [...result.value.suggestions, ...more.suggestions],
|
|
||||||
grocery_list: [...new Set([...result.value.grocery_list, ...more.grocery_list])],
|
|
||||||
grocery_links: [...result.value.grocery_links, ...more.grocery_links],
|
|
||||||
}
|
|
||||||
_trackSeen(more.suggestions)
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load more recipes'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismiss(id: number) {
|
|
||||||
dismissedIds.value = new Set([...dismissedIds.value, id])
|
|
||||||
saveDismissed(dismissedIds.value)
|
|
||||||
// Remove from current results immediately
|
|
||||||
if (result.value) {
|
|
||||||
result.value = {
|
|
||||||
...result.value,
|
|
||||||
suggestions: result.value.suggestions.filter((s) => s.id !== id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearDismissed() {
|
|
||||||
dismissedIds.value = new Set()
|
|
||||||
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
|
|
||||||
wildcardConfirmed.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
result,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
level,
|
|
||||||
constraints,
|
|
||||||
allergies,
|
|
||||||
hardDayMode,
|
|
||||||
maxMissing,
|
|
||||||
styleId,
|
|
||||||
category,
|
|
||||||
wildcardConfirmed,
|
|
||||||
shoppingMode,
|
|
||||||
nutritionFilters,
|
|
||||||
dismissedIds,
|
|
||||||
dismissedCount,
|
|
||||||
cookLog,
|
|
||||||
logCook,
|
|
||||||
clearCookLog,
|
|
||||||
bookmarks,
|
|
||||||
isBookmarked,
|
|
||||||
toggleBookmark,
|
|
||||||
clearBookmarks,
|
|
||||||
suggest,
|
|
||||||
loadMore,
|
|
||||||
dismiss,
|
|
||||||
clearDismissed,
|
|
||||||
clearResult,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
/**
|
|
||||||
* Settings Store
|
|
||||||
*
|
|
||||||
* Manages user settings (cooking equipment, preferences) using Pinia.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { settingsAPI } from '../services/api'
|
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
|
||||||
// State
|
|
||||||
const cookingEquipment = ref<string[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const saved = ref(false)
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const raw = await settingsAPI.getSetting('cooking_equipment')
|
|
||||||
if (raw) {
|
|
||||||
cookingEquipment.value = JSON.parse(raw)
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error('Failed to load settings:', err)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value))
|
|
||||||
saved.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saved.value = false
|
|
||||||
}, 2000)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
console.error('Failed to save settings:', err)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
cookingEquipment,
|
|
||||||
loading,
|
|
||||||
saved,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
load,
|
|
||||||
save,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
:root {
|
:root {
|
||||||
/* Typography */
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
--font-display: 'Fraunces', Georgia, serif;
|
|
||||||
--font-mono: 'DM Mono', 'Courier New', monospace;
|
|
||||||
--font-body: 'DM Sans', system-ui, sans-serif;
|
|
||||||
|
|
||||||
font-family: var(--font-body);
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: light dark;
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
|
@ -16,79 +11,66 @@
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
/* Theme Colors - Dark Mode (Default) */
|
/* Theme Colors - Dark Mode (Default) */
|
||||||
--color-text-primary: rgba(255, 248, 235, 0.92);
|
--color-text-primary: rgba(255, 255, 255, 0.87);
|
||||||
--color-text-secondary: rgba(255, 248, 235, 0.60);
|
--color-text-secondary: rgba(255, 255, 255, 0.6);
|
||||||
--color-text-muted: rgba(255, 248, 235, 0.38);
|
--color-text-muted: rgba(255, 255, 255, 0.4);
|
||||||
|
|
||||||
--color-bg-primary: #1e1c1a;
|
--color-bg-primary: #242424;
|
||||||
--color-bg-secondary: #161412;
|
--color-bg-secondary: #1a1a1a;
|
||||||
--color-bg-elevated: #2a2724;
|
--color-bg-elevated: #2d2d2d;
|
||||||
--color-bg-card: #2a2724;
|
--color-bg-card: #2d2d2d;
|
||||||
--color-bg-input: #161412;
|
--color-bg-input: #1a1a1a;
|
||||||
|
|
||||||
--color-border: rgba(232, 168, 32, 0.12);
|
--color-border: rgba(255, 255, 255, 0.1);
|
||||||
--color-border-focus: rgba(232, 168, 32, 0.35);
|
--color-border-focus: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
/* Brand Colors — Saffron amber + forest green */
|
/* Brand Colors */
|
||||||
--color-primary: #e8a820;
|
--color-primary: #667eea;
|
||||||
--color-primary-dark: #c88c10;
|
--color-primary-dark: #5568d3;
|
||||||
--color-primary-light: #f0bc48;
|
--color-primary-light: #7d8ff0;
|
||||||
--color-secondary: #2d5a27;
|
--color-secondary: #764ba2;
|
||||||
--color-secondary-light: #3d7a35;
|
|
||||||
--color-secondary-dark: #1e3d1a;
|
|
||||||
|
|
||||||
/* Status Colors */
|
/* Status Colors */
|
||||||
--color-success: #4a8c40;
|
--color-success: #4CAF50;
|
||||||
--color-success-dark: #3a7030;
|
--color-success-dark: #45a049;
|
||||||
--color-success-light: #6aac60;
|
--color-success-light: #66bb6a;
|
||||||
--color-success-bg: rgba(74, 140, 64, 0.12);
|
--color-success-bg: rgba(76, 175, 80, 0.1);
|
||||||
--color-success-border: rgba(74, 140, 64, 0.30);
|
--color-success-border: rgba(76, 175, 80, 0.3);
|
||||||
|
|
||||||
--color-warning: #e8a820;
|
--color-warning: #ff9800;
|
||||||
--color-warning-dark: #c88c10;
|
--color-warning-dark: #f57c00;
|
||||||
--color-warning-light: #f0bc48;
|
--color-warning-light: #ffb74d;
|
||||||
--color-warning-bg: rgba(232, 168, 32, 0.12);
|
--color-warning-bg: rgba(255, 152, 0, 0.1);
|
||||||
--color-warning-border: rgba(232, 168, 32, 0.30);
|
--color-warning-border: rgba(255, 152, 0, 0.3);
|
||||||
|
|
||||||
--color-error: #c0392b;
|
--color-error: #f44336;
|
||||||
--color-error-dark: #96281b;
|
--color-error-dark: #d32f2f;
|
||||||
--color-error-light: #e74c3c;
|
--color-error-light: #ff6b6b;
|
||||||
--color-error-bg: rgba(192, 57, 43, 0.12);
|
--color-error-bg: rgba(244, 67, 54, 0.1);
|
||||||
--color-error-border: rgba(192, 57, 43, 0.30);
|
--color-error-border: rgba(244, 67, 54, 0.3);
|
||||||
|
|
||||||
--color-info: #2980b9;
|
--color-info: #2196F3;
|
||||||
--color-info-dark: #1a5f8a;
|
--color-info-dark: #1976D2;
|
||||||
--color-info-light: #5dade2;
|
--color-info-light: #64b5f6;
|
||||||
--color-info-bg: rgba(41, 128, 185, 0.12);
|
--color-info-bg: rgba(33, 150, 243, 0.1);
|
||||||
--color-info-border: rgba(41, 128, 185, 0.30);
|
--color-info-border: rgba(33, 150, 243, 0.3);
|
||||||
|
|
||||||
/* Location dot colors */
|
|
||||||
--color-loc-fridge: #5dade2;
|
|
||||||
--color-loc-freezer: #48d1cc;
|
|
||||||
--color-loc-garage-freezer: #7fb3d3;
|
|
||||||
--color-loc-pantry: #e8a820;
|
|
||||||
--color-loc-cabinet: #a0855b;
|
|
||||||
|
|
||||||
/* Gradient */
|
/* Gradient */
|
||||||
--gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, #c88c10 100%);
|
--gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
|
||||||
--gradient-secondary: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-light) 100%);
|
|
||||||
--gradient-header: linear-gradient(160deg, #2a2724 0%, #1e1c1a 100%);
|
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4);
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 10px 24px rgba(0, 0, 0, 0.5);
|
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.4);
|
||||||
--shadow-xl: 0 20px 48px rgba(0, 0, 0, 0.6);
|
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-amber: 0 4px 16px rgba(232, 168, 32, 0.20);
|
|
||||||
|
|
||||||
/* Typography Scale */
|
/* Typography */
|
||||||
--font-size-xs: 11px;
|
--font-size-xs: 12px;
|
||||||
--font-size-sm: 13px;
|
--font-size-sm: 14px;
|
||||||
--font-size-base: 15px;
|
--font-size-base: 16px;
|
||||||
--font-size-lg: 17px;
|
--font-size-lg: 18px;
|
||||||
--font-size-xl: 22px;
|
--font-size-xl: 24px;
|
||||||
--font-size-2xl: 30px;
|
--font-size-2xl: 32px;
|
||||||
--font-size-display: 28px;
|
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
--spacing-xs: 4px;
|
--spacing-xs: 4px;
|
||||||
|
|
@ -98,159 +80,176 @@
|
||||||
--spacing-xl: 32px;
|
--spacing-xl: 32px;
|
||||||
|
|
||||||
/* Border Radius */
|
/* Border Radius */
|
||||||
--radius-sm: 6px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 8px;
|
--radius-md: 6px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 8px;
|
||||||
--radius-xl: 16px;
|
--radius-xl: 12px;
|
||||||
--radius-pill: 999px;
|
|
||||||
|
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode overrides */
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
--color-text-primary: #2c1a06;
|
|
||||||
--color-text-secondary: #6b4c1e;
|
|
||||||
--color-text-muted: #a0845a;
|
|
||||||
|
|
||||||
--color-bg-primary: #fdf8f0;
|
|
||||||
--color-bg-secondary: #ffffff;
|
|
||||||
--color-bg-elevated: #fff9ed;
|
|
||||||
--color-bg-card: #ffffff;
|
|
||||||
--color-bg-input: #fef9ef;
|
|
||||||
|
|
||||||
--color-border: rgba(168, 100, 20, 0.15);
|
|
||||||
--color-border-focus: rgba(168, 100, 20, 0.40);
|
|
||||||
|
|
||||||
--color-success-bg: #e8f5e2;
|
|
||||||
--color-success-border: #c3e0bb;
|
|
||||||
--color-warning-bg: #fff8e1;
|
|
||||||
--color-warning-border: #ffe08a;
|
|
||||||
--color-error-bg: #fdecea;
|
|
||||||
--color-error-border: #f5c6c2;
|
|
||||||
--color-info-bg: #e3f2fd;
|
|
||||||
--color-info-border: #b3d8f5;
|
|
||||||
|
|
||||||
--gradient-header: linear-gradient(160deg, #fff9ed 0%, #fdf8f0 100%);
|
|
||||||
|
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
||||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.10);
|
|
||||||
--shadow-lg: 0 10px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
--shadow-xl: 0 20px 48px rgba(0, 0, 0, 0.16);
|
|
||||||
--shadow-amber: 0 4px 16px rgba(168, 100, 20, 0.15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-primary);
|
color: #646cff;
|
||||||
text-decoration: inherit;
|
text-decoration: inherit;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover {
|
||||||
color: var(--color-primary-light);
|
color: #535bf2;
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
overflow-x: hidden; /* iOS Safari: html is the true scroll container — body alone isn't enough */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: hidden;
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3 {
|
h1 {
|
||||||
font-family: var(--font-display);
|
font-size: 3.2em;
|
||||||
font-weight: 600;
|
line-height: 1.1;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border-radius: var(--radius-md);
|
border-radius: 8px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
padding: 0.5em 1.1em;
|
padding: 0.6em 1.2em;
|
||||||
font-size: var(--font-size-sm);
|
font-size: 1em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: var(--font-body);
|
font-family: inherit;
|
||||||
background-color: var(--color-bg-elevated);
|
background-color: #1a1a1a;
|
||||||
color: var(--color-text-primary);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: border-color 0.25s;
|
||||||
}
|
}
|
||||||
button:hover {
|
button:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: #646cff;
|
||||||
}
|
}
|
||||||
button:focus,
|
button:focus,
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: var(--spacing-lg);
|
padding: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
text-align: left;
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
/* Theme Colors - Light Mode */
|
||||||
|
--color-text-primary: #213547;
|
||||||
|
--color-text-secondary: #666;
|
||||||
|
--color-text-muted: #999;
|
||||||
|
|
||||||
|
--color-bg-primary: #f5f5f5;
|
||||||
|
--color-bg-secondary: #ffffff;
|
||||||
|
--color-bg-elevated: #ffffff;
|
||||||
|
--color-bg-card: #ffffff;
|
||||||
|
--color-bg-input: #ffffff;
|
||||||
|
|
||||||
|
--color-border: #ddd;
|
||||||
|
--color-border-focus: #ccc;
|
||||||
|
|
||||||
|
/* Status colors stay the same in light mode */
|
||||||
|
/* But we adjust backgrounds for better contrast */
|
||||||
|
--color-success-bg: #d4edda;
|
||||||
|
--color-success-border: #c3e6cb;
|
||||||
|
|
||||||
|
--color-warning-bg: #fff3cd;
|
||||||
|
--color-warning-border: #ffeaa7;
|
||||||
|
|
||||||
|
--color-error-bg: #f8d7da;
|
||||||
|
--color-error-border: #f5c6cb;
|
||||||
|
|
||||||
|
--color-info-bg: #d1ecf1;
|
||||||
|
--color-info-border: #bee5eb;
|
||||||
|
|
||||||
|
/* Shadows for light mode (lighter) */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Responsive Typography and Spacing */
|
/* Mobile Responsive Typography and Spacing */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
:root {
|
:root {
|
||||||
|
/* Reduce font sizes for mobile */
|
||||||
--font-size-xs: 11px;
|
--font-size-xs: 11px;
|
||||||
--font-size-sm: 12px;
|
--font-size-sm: 13px;
|
||||||
--font-size-base: 14px;
|
--font-size-base: 14px;
|
||||||
--font-size-lg: 16px;
|
--font-size-lg: 16px;
|
||||||
--font-size-xl: 19px;
|
--font-size-xl: 20px;
|
||||||
--font-size-2xl: 24px;
|
--font-size-2xl: 24px;
|
||||||
--font-size-display: 22px;
|
|
||||||
|
|
||||||
|
/* Reduce spacing for mobile */
|
||||||
--spacing-xs: 4px;
|
--spacing-xs: 4px;
|
||||||
--spacing-sm: 6px;
|
--spacing-sm: 6px;
|
||||||
--spacing-md: 12px;
|
--spacing-md: 12px;
|
||||||
--spacing-lg: 16px;
|
--spacing-lg: 16px;
|
||||||
--spacing-xl: 20px;
|
--spacing-xl: 20px;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
/* Reduce shadows for mobile */
|
||||||
--shadow-md: 0 2px 6px rgba(0, 0, 0, 0.35);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
--shadow-lg: 0 6px 12px rgba(0, 0, 0, 0.40);
|
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.50);
|
--shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: var(--spacing-md);
|
padding: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
padding: 0;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 481px) and (max-width: 768px) {
|
@media (min-width: 481px) and (max-width: 768px) {
|
||||||
:root {
|
:root {
|
||||||
--font-size-base: 14px;
|
/* Slightly reduced sizes for tablets */
|
||||||
--font-size-lg: 16px;
|
--font-size-base: 15px;
|
||||||
--font-size-xl: 20px;
|
--font-size-lg: 17px;
|
||||||
--font-size-2xl: 26px;
|
--font-size-xl: 22px;
|
||||||
|
--font-size-2xl: 28px;
|
||||||
|
|
||||||
--spacing-md: 14px;
|
--spacing-md: 14px;
|
||||||
--spacing-lg: 20px;
|
--spacing-lg: 20px;
|
||||||
--spacing-xl: 28px;
|
--spacing-xl: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5em;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
padding: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
padding: 0;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,32 @@
|
||||||
/**
|
/**
|
||||||
* Central Theme System for Kiwi
|
* Central Theme System for Project Thoth
|
||||||
*
|
*
|
||||||
* This file contains all reusable, theme-aware, responsive CSS classes.
|
* This file contains all reusable, theme-aware, responsive CSS classes.
|
||||||
* Components should use these classes instead of custom styles where possible.
|
* 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
|
LAYOUT UTILITIES - RESPONSIVE GRIDS
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
|
/* Responsive Grid - Automatically adjusts columns based on screen size */
|
||||||
.grid-responsive {
|
.grid-responsive {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: 1 column, Tablet: 2 columns, Desktop: 3+ columns */
|
||||||
.grid-auto {
|
.grid-auto {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr; /* Default to single column */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stats grid — horizontal strip of compact stats */
|
/* Stats grid - always fills available space */
|
||||||
.grid-stats {
|
.grid-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr; /* Default to single column */
|
||||||
}
|
|
||||||
|
|
||||||
.grid-stats-strip {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-stats-strip .stat-strip-item {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--spacing-sm) var(--spacing-xs);
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-stats-strip .stat-strip-item:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Force specific column counts */
|
/* Force specific column counts */
|
||||||
|
|
@ -85,7 +36,7 @@
|
||||||
.grid-4 { grid-template-columns: repeat(4, 1fr); }
|
.grid-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
FLEXBOX UTILITIES
|
FLEXBOX UTILITIES - RESPONSIVE
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.flex { display: flex; }
|
.flex { display: flex; }
|
||||||
|
|
@ -112,6 +63,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stack on mobile, horizontal on desktop */
|
||||||
.flex-responsive {
|
.flex-responsive {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
|
|
@ -122,12 +74,14 @@
|
||||||
SPACING UTILITIES
|
SPACING UTILITIES
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
|
/* Gaps */
|
||||||
.gap-xs { gap: var(--spacing-xs); }
|
.gap-xs { gap: var(--spacing-xs); }
|
||||||
.gap-sm { gap: var(--spacing-sm); }
|
.gap-sm { gap: var(--spacing-sm); }
|
||||||
.gap-md { gap: var(--spacing-md); }
|
.gap-md { gap: var(--spacing-md); }
|
||||||
.gap-lg { gap: var(--spacing-lg); }
|
.gap-lg { gap: var(--spacing-lg); }
|
||||||
.gap-xl { gap: var(--spacing-xl); }
|
.gap-xl { gap: var(--spacing-xl); }
|
||||||
|
|
||||||
|
/* Padding */
|
||||||
.p-0 { padding: 0; }
|
.p-0 { padding: 0; }
|
||||||
.p-xs { padding: var(--spacing-xs); }
|
.p-xs { padding: var(--spacing-xs); }
|
||||||
.p-sm { padding: var(--spacing-sm); }
|
.p-sm { padding: var(--spacing-sm); }
|
||||||
|
|
@ -135,6 +89,7 @@
|
||||||
.p-lg { padding: var(--spacing-lg); }
|
.p-lg { padding: var(--spacing-lg); }
|
||||||
.p-xl { padding: var(--spacing-xl); }
|
.p-xl { padding: var(--spacing-xl); }
|
||||||
|
|
||||||
|
/* Margin */
|
||||||
.m-0 { margin: 0; }
|
.m-0 { margin: 0; }
|
||||||
.m-xs { margin: var(--spacing-xs); }
|
.m-xs { margin: var(--spacing-xs); }
|
||||||
.m-sm { margin: var(--spacing-sm); }
|
.m-sm { margin: var(--spacing-sm); }
|
||||||
|
|
@ -142,14 +97,9 @@
|
||||||
.m-lg { margin: var(--spacing-lg); }
|
.m-lg { margin: var(--spacing-lg); }
|
||||||
.m-xl { margin: var(--spacing-xl); }
|
.m-xl { margin: var(--spacing-xl); }
|
||||||
|
|
||||||
.mt-xs { margin-top: var(--spacing-xs); }
|
/* Margin/Padding specific sides */
|
||||||
.mt-sm { margin-top: var(--spacing-sm); }
|
|
||||||
.mt-md { margin-top: var(--spacing-md); }
|
.mt-md { margin-top: var(--spacing-md); }
|
||||||
.mb-xs { margin-bottom: var(--spacing-xs); }
|
|
||||||
.mb-sm { margin-bottom: var(--spacing-sm); }
|
|
||||||
.mb-md { margin-bottom: var(--spacing-md); }
|
.mb-md { margin-bottom: var(--spacing-md); }
|
||||||
.mb-lg { margin-bottom: var(--spacing-lg); }
|
|
||||||
.ml-xs { margin-left: var(--spacing-xs); }
|
|
||||||
.ml-md { margin-left: var(--spacing-md); }
|
.ml-md { margin-left: var(--spacing-md); }
|
||||||
.mr-md { margin-right: var(--spacing-md); }
|
.mr-md { margin-right: var(--spacing-md); }
|
||||||
|
|
||||||
|
|
@ -165,9 +115,8 @@
|
||||||
.card {
|
.card {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
padding: var(--spacing-lg);
|
padding: var(--spacing-xl);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
transition: box-shadow 0.2s ease;
|
transition: box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,22 +129,20 @@
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-secondary {
|
.card-secondary {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-lg);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status border variants */
|
/* Status border variants */
|
||||||
.card-success { border-left: 3px solid var(--color-success); }
|
.card-success { border-left: 4px solid var(--color-success); }
|
||||||
.card-warning { border-left: 3px solid var(--color-warning); }
|
.card-warning { border-left: 4px solid var(--color-warning); }
|
||||||
.card-error { border-left: 3px solid var(--color-error); }
|
.card-error { border-left: 4px solid var(--color-error); }
|
||||||
.card-info { border-left: 3px solid var(--color-info); }
|
.card-info { border-left: 4px solid var(--color-info); }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
BUTTON COMPONENTS - THEME AWARE
|
BUTTON COMPONENTS - THEME AWARE
|
||||||
|
|
@ -203,18 +150,13 @@
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
border: 1px solid transparent;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: var(--font-body);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.18s ease;
|
transition: all 0.2s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
|
|
@ -226,7 +168,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
@ -234,14 +176,8 @@
|
||||||
/* Button variants */
|
/* Button variants */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--gradient-primary);
|
background: var(--gradient-primary);
|
||||||
color: #1e1c1a;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: var(--shadow-amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
box-shadow: 0 6px 20px rgba(232, 168, 32, 0.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success {
|
.btn-success {
|
||||||
|
|
@ -272,49 +208,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-secondary);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-primary);
|
||||||
border: 1px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary.active {
|
.btn-secondary.active {
|
||||||
background: var(--color-primary);
|
background: var(--gradient-primary);
|
||||||
color: #1e1c1a;
|
color: white;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pill chip button — for filter chips */
|
|
||||||
.btn-chip {
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.18s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-chip:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-chip.active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #1e1c1a;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button sizes */
|
/* Button sizes */
|
||||||
|
|
@ -325,38 +232,7 @@
|
||||||
|
|
||||||
.btn-lg {
|
.btn-lg {
|
||||||
padding: var(--spacing-md) var(--spacing-xl);
|
padding: var(--spacing-md) var(--spacing-xl);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-lg);
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon-only action button */
|
|
||||||
.btn-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.18s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover {
|
|
||||||
background: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon.btn-icon-danger:hover {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon.btn-icon-success:hover {
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|
@ -369,13 +245,10 @@
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: var(--spacing-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-primary);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-sm);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input,
|
.form-input,
|
||||||
|
|
@ -388,9 +261,7 @@
|
||||||
background: var(--color-bg-input);
|
background: var(--color-bg-input);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-family: var(--font-body);
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus,
|
.form-input:focus,
|
||||||
|
|
@ -398,43 +269,22 @@
|
||||||
.form-textarea:focus {
|
.form-textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 3px var(--color-warning-bg);
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-textarea {
|
.form-textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
font-family: var(--font-body);
|
font-family: inherit;
|
||||||
}
|
|
||||||
|
|
||||||
.form-hint {
|
|
||||||
display: block;
|
|
||||||
margin-top: var(--spacing-xs);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form layouts */
|
||||||
.form-row {
|
.form-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chip row filter bar — horizontal scroll */
|
|
||||||
.filter-chip-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: var(--spacing-xs);
|
|
||||||
scrollbar-width: none;
|
|
||||||
min-width: 0; /* allow flex item to shrink below content; lets overflow-x scroll internally */
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip-row::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
TEXT UTILITIES
|
TEXT UTILITIES
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
@ -446,17 +296,6 @@
|
||||||
.text-xl { font-size: var(--font-size-xl); }
|
.text-xl { font-size: var(--font-size-xl); }
|
||||||
.text-2xl { font-size: var(--font-size-2xl); }
|
.text-2xl { font-size: var(--font-size-2xl); }
|
||||||
|
|
||||||
/* Display font */
|
|
||||||
.text-display {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mono font */
|
|
||||||
.text-mono {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-primary { color: var(--color-text-primary); }
|
.text-primary { color: var(--color-text-primary); }
|
||||||
.text-secondary { color: var(--color-text-secondary); }
|
.text-secondary { color: var(--color-text-secondary); }
|
||||||
.text-muted { color: var(--color-text-muted); }
|
.text-muted { color: var(--color-text-muted); }
|
||||||
|
|
@ -465,7 +304,6 @@
|
||||||
.text-warning { color: var(--color-warning); }
|
.text-warning { color: var(--color-warning); }
|
||||||
.text-error { color: var(--color-error); }
|
.text-error { color: var(--color-error); }
|
||||||
.text-info { color: var(--color-info); }
|
.text-info { color: var(--color-info); }
|
||||||
.text-amber { color: var(--color-primary); }
|
|
||||||
|
|
||||||
.text-center { text-align: center; }
|
.text-center { text-align: center; }
|
||||||
.text-left { text-align: left; }
|
.text-left { text-align: left; }
|
||||||
|
|
@ -475,76 +313,59 @@
|
||||||
.font-semibold { font-weight: 600; }
|
.font-semibold { font-weight: 600; }
|
||||||
.font-normal { font-weight: 400; }
|
.font-normal { font-weight: 400; }
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
LOCATION DOT INDICATORS
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.loc-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loc-dot-fridge { background: var(--color-loc-fridge); }
|
|
||||||
.loc-dot-freezer { background: var(--color-loc-freezer); }
|
|
||||||
.loc-dot-garage_freezer { background: var(--color-loc-garage-freezer); }
|
|
||||||
.loc-dot-pantry { background: var(--color-loc-pantry); }
|
|
||||||
.loc-dot-cabinet { background: var(--color-loc-cabinet); }
|
|
||||||
|
|
||||||
/* Location left-border strip on inventory rows */
|
|
||||||
.inv-row-fridge { border-left-color: var(--color-loc-fridge) !important; }
|
|
||||||
.inv-row-freezer { border-left-color: var(--color-loc-freezer) !important; }
|
|
||||||
.inv-row-garage_freezer { border-left-color: var(--color-loc-garage-freezer) !important; }
|
|
||||||
.inv-row-pantry { border-left-color: var(--color-loc-pantry) !important; }
|
|
||||||
.inv-row-cabinet { border-left-color: var(--color-loc-cabinet) !important; }
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
RESPONSIVE UTILITIES
|
RESPONSIVE UTILITIES
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
|
/* Show/Hide based on screen size */
|
||||||
.mobile-only { display: none; }
|
.mobile-only { display: none; }
|
||||||
.desktop-only { display: block; }
|
.desktop-only { display: block; }
|
||||||
|
|
||||||
|
/* Width utilities */
|
||||||
.w-full { width: 100%; }
|
.w-full { width: 100%; }
|
||||||
.w-auto { width: auto; }
|
.w-auto { width: auto; }
|
||||||
|
|
||||||
|
/* Height utilities */
|
||||||
.h-full { height: 100%; }
|
.h-full { height: 100%; }
|
||||||
.h-auto { height: auto; }
|
.h-auto { height: auto; }
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
MOBILE BREAKPOINTS (<=480px)
|
MOBILE BREAKPOINTS (≤480px)
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
/* Show/Hide */
|
||||||
.mobile-only { display: block; }
|
.mobile-only { display: block; }
|
||||||
.desktop-only { display: none; }
|
.desktop-only { display: none; }
|
||||||
|
|
||||||
|
/* Grids already default to 1fr, just ensure it stays that way */
|
||||||
.grid-2,
|
.grid-2,
|
||||||
.grid-3,
|
.grid-3,
|
||||||
.grid-4 {
|
.grid-4 {
|
||||||
grid-template-columns: 1fr !important;
|
grid-template-columns: 1fr !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stack flex items vertically */
|
||||||
.flex-responsive {
|
.flex-responsive {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Buttons take full width */
|
||||||
.btn-mobile-full {
|
.btn-mobile-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reduce card padding on mobile */
|
||||||
.card {
|
.card {
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-sm {
|
.card-sm {
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Allow text wrapping on mobile */
|
||||||
.btn {
|
.btn {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -556,6 +377,7 @@
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@media (min-width: 481px) and (max-width: 768px) {
|
@media (min-width: 481px) and (max-width: 768px) {
|
||||||
|
/* 2-column layouts on tablets */
|
||||||
.grid-3,
|
.grid-3,
|
||||||
.grid-4 {
|
.grid-4 {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
@ -580,11 +402,11 @@
|
||||||
|
|
||||||
@media (min-width: 769px) and (max-width: 1024px) {
|
@media (min-width: 769px) and (max-width: 1024px) {
|
||||||
.grid-auto {
|
.grid-auto {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-stats {
|
.grid-stats {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-4 {
|
.grid-4 {
|
||||||
|
|
@ -593,16 +415,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
LARGE DESKTOP (>=1025px)
|
LARGE DESKTOP (≥1025px)
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@media (min-width: 1025px) {
|
@media (min-width: 1025px) {
|
||||||
.grid-auto {
|
.grid-auto {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-stats {
|
.grid-stats {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
|
|
@ -615,37 +437,34 @@
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-block;
|
||||||
align-items: center;
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
padding: 3px var(--spacing-sm);
|
border-radius: var(--radius-sm);
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: var(--font-mono);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-success {
|
.status-success {
|
||||||
background: var(--color-success-bg);
|
background: var(--color-success-bg);
|
||||||
color: var(--color-success-light);
|
color: var(--color-success-dark);
|
||||||
border: 1px solid var(--color-success-border);
|
border: 1px solid var(--color-success-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-warning {
|
.status-warning {
|
||||||
background: var(--color-warning-bg);
|
background: var(--color-warning-bg);
|
||||||
color: var(--color-warning-light);
|
color: var(--color-warning-dark);
|
||||||
border: 1px solid var(--color-warning-border);
|
border: 1px solid var(--color-warning-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-error {
|
.status-error {
|
||||||
background: var(--color-error-bg);
|
background: var(--color-error-bg);
|
||||||
color: var(--color-error-light);
|
color: var(--color-error-dark);
|
||||||
border: 1px solid var(--color-error-border);
|
border: 1px solid var(--color-error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-info {
|
.status-info {
|
||||||
background: var(--color-info-bg);
|
background: var(--color-info-bg);
|
||||||
color: var(--color-info-light);
|
color: var(--color-info-dark);
|
||||||
border: 1px solid var(--color-info-border);
|
border: 1px solid var(--color-info-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -669,7 +488,7 @@
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(16px);
|
transform: translateY(20px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
@ -677,39 +496,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
LOADING UTILITIES
|
LOADING UTILITIES
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
border: 2px solid var(--color-border);
|
border: 3px solid var(--color-border);
|
||||||
border-top: 2px solid var(--color-primary);
|
border-top: 3px solid var(--color-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 36px;
|
width: 40px;
|
||||||
height: 36px;
|
height: 40px;
|
||||||
animation: spin 0.9s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner-sm {
|
.spinner-sm {
|
||||||
width: 18px;
|
width: 20px;
|
||||||
height: 18px;
|
height: 20px;
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -731,160 +534,3 @@
|
||||||
.divider-md {
|
.divider-md {
|
||||||
margin: var(--spacing-md) 0;
|
margin: var(--spacing-md) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
SECTION HEADERS (display font)
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
EASTER EGG — GRID KITCHEN NEON MODE
|
|
||||||
Activated via Konami code
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
body.neon-mode .card,
|
|
||||||
body.neon-mode .card-sm,
|
|
||||||
body.neon-mode .card-secondary {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(255, 0, 110, 0.35),
|
|
||||||
0 0 12px rgba(255, 0, 110, 0.18),
|
|
||||||
0 2px 20px rgba(131, 56, 236, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.neon-mode .btn-primary {
|
|
||||||
box-shadow: 0 0 18px rgba(255, 0, 110, 0.55), 0 0 36px rgba(131, 56, 236, 0.25);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.neon-mode .wordmark-kiwi {
|
|
||||||
text-shadow: 0 0 10px rgba(255, 0, 110, 0.7), 0 0 24px rgba(131, 56, 236, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.neon-mode .sidebar,
|
|
||||||
body.neon-mode .bottom-nav {
|
|
||||||
border-color: rgba(255, 0, 110, 0.3);
|
|
||||||
box-shadow: 4px 0 20px rgba(255, 0, 110, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.neon-mode .sidebar-item.active,
|
|
||||||
body.neon-mode .nav-item.active {
|
|
||||||
text-shadow: 0 0 8px currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scanline overlay */
|
|
||||||
body.neon-mode::after {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
transparent,
|
|
||||||
transparent 3px,
|
|
||||||
rgba(0, 0, 0, 0.08) 3px,
|
|
||||||
rgba(0, 0, 0, 0.08) 4px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9998;
|
|
||||||
animation: scanlineScroll 8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scanlineScroll {
|
|
||||||
0% { background-position: 0 0; }
|
|
||||||
100% { background-position: 0 80px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CRT flicker on wordmark */
|
|
||||||
body.neon-mode .wordmark-kiwi {
|
|
||||||
animation: crtFlicker 6s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes crtFlicker {
|
|
||||||
0%, 94%, 100% { opacity: 1; }
|
|
||||||
95% { opacity: 0.88; }
|
|
||||||
97% { opacity: 0.95; }
|
|
||||||
98% { opacity: 0.82; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
EASTER EGG — KIWI BIRD SPRITE
|
|
||||||
============================================ */
|
|
||||||
|
|
||||||
.kiwi-bird-stage {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 72px; /* above bottom nav */
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 72px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
.kiwi-bird-stage {
|
|
||||||
bottom: 0;
|
|
||||||
left: 200px; /* clear the sidebar */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.kiwi-bird {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 8px;
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enters from right, walks left */
|
|
||||||
.kiwi-bird.rtl {
|
|
||||||
animation: kiwiWalkRtl 5.5s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
.kiwi-bird.rtl .kiwi-svg {
|
|
||||||
transform: scaleX(1); /* faces left */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enters from left, walks right */
|
|
||||||
.kiwi-bird.ltr {
|
|
||||||
animation: kiwiWalkLtr 5.5s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
.kiwi-bird.ltr .kiwi-svg {
|
|
||||||
transform: scaleX(-1); /* faces right */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bob on each step */
|
|
||||||
.kiwi-svg {
|
|
||||||
display: block;
|
|
||||||
animation: kiwiBob 0.38s steps(1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes kiwiWalkRtl {
|
|
||||||
0% { right: -80px; }
|
|
||||||
15% { right: 35%; } /* enter and slow */
|
|
||||||
40% { right: 35%; } /* pause — sniffing */
|
|
||||||
55% { right: 38%; } /* tiny shuffle */
|
|
||||||
60% { right: 35%; }
|
|
||||||
85% { right: 35%; }
|
|
||||||
100% { right: calc(100% + 80px); } /* exit left */
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes kiwiWalkLtr {
|
|
||||||
0% { left: -80px; }
|
|
||||||
15% { left: 35%; }
|
|
||||||
40% { left: 35%; }
|
|
||||||
55% { left: 38%; }
|
|
||||||
60% { left: 35%; }
|
|
||||||
85% { left: 35%; }
|
|
||||||
100% { left: calc(100% + 80px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes kiwiBob {
|
|
||||||
0% { transform: translateY(0) scaleX(var(--bird-flip, 1)); }
|
|
||||||
50% { transform: translateY(-4px) scaleX(var(--bird-flip, 1)); }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
20
manage.sh
20
manage.sh
|
|
@ -9,10 +9,6 @@ COMPOSE_FILE="compose.yml"
|
||||||
CLOUD_COMPOSE_FILE="compose.cloud.yml"
|
CLOUD_COMPOSE_FILE="compose.cloud.yml"
|
||||||
CLOUD_PROJECT="kiwi-cloud"
|
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() {
|
usage() {
|
||||||
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test"
|
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test"
|
||||||
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}"
|
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}"
|
||||||
|
|
@ -42,23 +38,23 @@ shift || true
|
||||||
|
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
start)
|
start)
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
echo "Kiwi running → http://localhost:${WEB_PORT}"
|
echo "Kiwi running → http://localhost:${WEB_PORT}"
|
||||||
;;
|
;;
|
||||||
stop)
|
stop)
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down
|
docker compose -f "$COMPOSE_FILE" down
|
||||||
;;
|
;;
|
||||||
restart)
|
restart)
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down
|
docker compose -f "$COMPOSE_FILE" down
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
echo "Kiwi running → http://localhost:${WEB_PORT}"
|
echo "Kiwi running → http://localhost:${WEB_PORT}"
|
||||||
;;
|
;;
|
||||||
status)
|
status)
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG ps
|
docker compose -f "$COMPOSE_FILE" ps
|
||||||
;;
|
;;
|
||||||
logs)
|
logs)
|
||||||
svc="${1:-}"
|
svc="${1:-}"
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG logs -f ${svc}
|
docker compose -f "$COMPOSE_FILE" logs -f ${svc}
|
||||||
;;
|
;;
|
||||||
open)
|
open)
|
||||||
xdg-open "http://localhost:${WEB_PORT}" 2>/dev/null \
|
xdg-open "http://localhost:${WEB_PORT}" 2>/dev/null \
|
||||||
|
|
@ -66,10 +62,10 @@ case "$cmd" in
|
||||||
|| echo "Open http://localhost:${WEB_PORT} in your browser"
|
|| echo "Open http://localhost:${WEB_PORT} in your browser"
|
||||||
;;
|
;;
|
||||||
build)
|
build)
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG build --no-cache
|
docker compose -f "$COMPOSE_FILE" build --no-cache
|
||||||
;;
|
;;
|
||||||
test)
|
test)
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG run --rm api \
|
docker compose -f "$COMPOSE_FILE" run --rm api \
|
||||||
conda run -n job-seeker pytest tests/ -v
|
conda run -n job-seeker pytest tests/ -v
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kiwi"
|
name = "kiwi"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
description = "Pantry tracking + leftover recipe suggestions"
|
description = "Pantry tracking + leftover recipe suggestions"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
@ -18,12 +18,10 @@ dependencies = [
|
||||||
"opencv-python>=4.8",
|
"opencv-python>=4.8",
|
||||||
"numpy>=1.25",
|
"numpy>=1.25",
|
||||||
"pyzbar>=0.1.9",
|
"pyzbar>=0.1.9",
|
||||||
"Pillow>=10.0",
|
# HTTP client
|
||||||
# HTTP clients
|
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"requests>=2.31",
|
|
||||||
# CircuitForge shared scaffold
|
# CircuitForge shared scaffold
|
||||||
"circuitforge-core>=0.8.0",
|
"circuitforge-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Backfill texture_profile in ingredient_profiles from existing macro data.
|
|
||||||
|
|
||||||
Texture categories and their macro signatures (all values g/100g):
|
|
||||||
fatty - fat > 60 (oils, lard, pure butter)
|
|
||||||
creamy - fat 15-60 (cream, cheese, fatty meats, nut butter)
|
|
||||||
firm - protein > 15, fat < 15 (lean meats, fish, legumes, firm tofu)
|
|
||||||
starchy - carbs > 40, fat < 10 (flour, oats, rice, bread, potatoes)
|
|
||||||
fibrous - fiber > 4, carbs < 40 (brassicas, leafy greens, whole grains)
|
|
||||||
tender - protein 2-15, fat < 10, (soft veg, eggs, soft tofu, cooked beans)
|
|
||||||
carbs < 40
|
|
||||||
liquid - calories < 25, fat < 1, (broth, juice, dilute sauces)
|
|
||||||
protein < 3
|
|
||||||
neutral - fallthrough default
|
|
||||||
|
|
||||||
Rules are applied in priority order: fatty → creamy → firm → starchy →
|
|
||||||
fibrous → tender → liquid → neutral.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
python scripts/backfill_texture_profiles.py [path/to/kiwi.db]
|
|
||||||
|
|
||||||
Or inside the container:
|
|
||||||
docker exec kiwi-cloud-api-1 python /app/kiwi/scripts/backfill_texture_profiles.py
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Default DB paths to try
|
|
||||||
_DEFAULT_PATHS = [
|
|
||||||
"/devl/kiwi-cloud-data/local-dev/kiwi.db",
|
|
||||||
"/devl/kiwi-data/kiwi.db",
|
|
||||||
]
|
|
||||||
|
|
||||||
BATCH_SIZE = 5_000
|
|
||||||
|
|
||||||
|
|
||||||
def _classify(fat: float, protein: float, carbs: float,
|
|
||||||
fiber: float, calories: float) -> str:
|
|
||||||
# Cap runaway values — data quality issue in some branded entries
|
|
||||||
fat = min(fat or 0.0, 100.0)
|
|
||||||
protein = min(protein or 0.0, 100.0)
|
|
||||||
carbs = min(carbs or 0.0, 100.0)
|
|
||||||
fiber = min(fiber or 0.0, 50.0)
|
|
||||||
calories = min(calories or 0.0, 900.0)
|
|
||||||
|
|
||||||
if fat > 60:
|
|
||||||
return "fatty"
|
|
||||||
if fat > 15:
|
|
||||||
return "creamy"
|
|
||||||
# Starchy before firm: oats/legumes have high protein AND high carbs — carbs win
|
|
||||||
if carbs > 40 and fat < 10:
|
|
||||||
return "starchy"
|
|
||||||
# Firm: lean proteins with low carbs (meats, fish, hard tofu)
|
|
||||||
# Lower protein threshold (>7) catches tofu (9%) and similar plant proteins
|
|
||||||
if protein > 7 and fat < 12 and carbs < 20:
|
|
||||||
return "firm"
|
|
||||||
if fiber > 4 and carbs < 40:
|
|
||||||
return "fibrous"
|
|
||||||
if 2 < protein <= 15 and fat < 10 and carbs < 40:
|
|
||||||
return "tender"
|
|
||||||
if calories < 25 and fat < 1 and protein < 3:
|
|
||||||
return "liquid"
|
|
||||||
return "neutral"
|
|
||||||
|
|
||||||
|
|
||||||
def backfill(db_path: str) -> None:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
|
|
||||||
total = conn.execute("SELECT COUNT(*) FROM ingredient_profiles").fetchone()[0]
|
|
||||||
print(f"Total rows: {total:,}")
|
|
||||||
|
|
||||||
updated = 0
|
|
||||||
offset = 0
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
while True:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""SELECT id, fat_pct, protein_pct, carbs_g_per_100g,
|
|
||||||
fiber_g_per_100g, calories_per_100g
|
|
||||||
FROM ingredient_profiles
|
|
||||||
LIMIT ? OFFSET ?""",
|
|
||||||
(BATCH_SIZE, offset),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
break
|
|
||||||
|
|
||||||
batch: list[tuple[str, int]] = []
|
|
||||||
for row in rows:
|
|
||||||
texture = _classify(
|
|
||||||
row["fat_pct"],
|
|
||||||
row["protein_pct"],
|
|
||||||
row["carbs_g_per_100g"],
|
|
||||||
row["fiber_g_per_100g"],
|
|
||||||
row["calories_per_100g"],
|
|
||||||
)
|
|
||||||
counts[texture] = counts.get(texture, 0) + 1
|
|
||||||
batch.append((texture, row["id"]))
|
|
||||||
|
|
||||||
conn.executemany(
|
|
||||||
"UPDATE ingredient_profiles SET texture_profile = ? WHERE id = ?",
|
|
||||||
batch,
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
updated += len(batch)
|
|
||||||
offset += BATCH_SIZE
|
|
||||||
print(f" {updated:,} / {total:,} updated...", end="\r")
|
|
||||||
|
|
||||||
print(f"\nDone. {updated:,} rows updated.\n")
|
|
||||||
print("Texture distribution:")
|
|
||||||
for texture, count in sorted(counts.items(), key=lambda x: -x[1]):
|
|
||||||
pct = count / updated * 100
|
|
||||||
print(f" {texture:10s} {count:8,} ({pct:.1f}%)")
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) > 1:
|
|
||||||
path = sys.argv[1]
|
|
||||||
else:
|
|
||||||
path = next((p for p in _DEFAULT_PATHS if Path(p).exists()), None)
|
|
||||||
if not path:
|
|
||||||
print(f"No DB found. Pass path as argument or create one of: {_DEFAULT_PATHS}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Backfilling texture profiles in: {path}")
|
|
||||||
backfill(path)
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"""
|
|
||||||
Import FlavorGraph compound->ingredient map into flavor_molecules table.
|
|
||||||
|
|
||||||
FlavorGraph GitHub: https://github.com/lamypark/FlavorGraph
|
|
||||||
Download: git clone https://github.com/lamypark/FlavorGraph /tmp/flavorgraph
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
conda run -n cf python scripts/pipeline/build_flavorgraph_index.py \
|
|
||||||
--db data/kiwi.db \
|
|
||||||
--flavorgraph-dir /tmp/flavorgraph/input
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from collections import defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def parse_ingredient_nodes(
|
|
||||||
nodes_path: Path, edges_path: Path
|
|
||||||
) -> tuple[dict[str, list[str]], dict[str, str]]:
|
|
||||||
"""Parse FlavorGraph CSVs → (ingredient→compounds, compound→name)."""
|
|
||||||
nodes = pd.read_csv(nodes_path, dtype=str).fillna("")
|
|
||||||
edges = pd.read_csv(edges_path, dtype=str).fillna("")
|
|
||||||
|
|
||||||
ingredient_ids: dict[str, str] = {} # node_id -> ingredient_name
|
|
||||||
compound_names: dict[str, str] = {} # node_id -> compound_name
|
|
||||||
|
|
||||||
for _, row in nodes.iterrows():
|
|
||||||
nid = row["node_id"]
|
|
||||||
name = row["name"].lower().replace("_", " ").strip()
|
|
||||||
if row["node_type"] == "ingredient":
|
|
||||||
ingredient_ids[nid] = name
|
|
||||||
else:
|
|
||||||
compound_names[nid] = name
|
|
||||||
|
|
||||||
ingredient_compounds: dict[str, list[str]] = defaultdict(list)
|
|
||||||
for _, row in edges.iterrows():
|
|
||||||
src, tgt = row["id_1"], row["id_2"]
|
|
||||||
if src in ingredient_ids:
|
|
||||||
ingredient_compounds[ingredient_ids[src]].append(tgt)
|
|
||||||
if tgt in ingredient_ids:
|
|
||||||
ingredient_compounds[ingredient_ids[tgt]].append(src)
|
|
||||||
|
|
||||||
return dict(ingredient_compounds), compound_names
|
|
||||||
|
|
||||||
|
|
||||||
def build(db_path: Path, flavorgraph_dir: Path) -> None:
|
|
||||||
nodes_path = flavorgraph_dir / "nodes_191120.csv"
|
|
||||||
edges_path = flavorgraph_dir / "edges_191120.csv"
|
|
||||||
|
|
||||||
ingredient_map, compound_names = parse_ingredient_nodes(nodes_path, edges_path)
|
|
||||||
|
|
||||||
compound_ingredients: dict[str, list[str]] = defaultdict(list)
|
|
||||||
for ingredient, compounds in ingredient_map.items():
|
|
||||||
for cid in compounds:
|
|
||||||
compound_ingredients[cid].append(ingredient)
|
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
for ingredient, compounds in ingredient_map.items():
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE ingredient_profiles SET flavor_molecule_ids = ? WHERE name = ?",
|
|
||||||
(json.dumps(compounds), ingredient),
|
|
||||||
)
|
|
||||||
|
|
||||||
for cid, ingredients in compound_ingredients.items():
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO flavor_molecules (compound_id, compound_name, ingredient_names)"
|
|
||||||
" VALUES (?, ?, ?)",
|
|
||||||
(cid, compound_names.get(cid, cid), json.dumps(ingredients)),
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
print(f"Indexed {len(ingredient_map)} ingredients, {len(compound_ingredients)} compounds")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--db", required=True, type=Path)
|
|
||||||
parser.add_argument("--flavorgraph-dir", required=True, type=Path)
|
|
||||||
args = parser.parse_args()
|
|
||||||
build(args.db, args.flavorgraph_dir)
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
"""
|
|
||||||
Build ingredient_profiles table from USDA FDC (Food Data Central) data.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
conda run -n job-seeker python scripts/pipeline/build_ingredient_index.py \
|
|
||||||
--db /path/to/kiwi.db \
|
|
||||||
--usda-fdc data/usda_fdc_cleaned.parquet \
|
|
||||||
--usda-branded data/usda_branded.parquet
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
# ── Element derivation rules (threshold-based) ────────────────────────────
|
|
||||||
|
|
||||||
_ELEMENT_RULES: list[tuple[str, callable]] = [
|
|
||||||
("Richness", lambda r: r.get("fat_pct", 0) > 5.0),
|
|
||||||
("Seasoning", lambda r: r.get("sodium_mg_per_100g", 0) > 200),
|
|
||||||
("Depth", lambda r: r.get("glutamate_mg", 0) > 1.0),
|
|
||||||
("Structure", lambda r: r.get("starch_pct", 0) > 10.0 or r.get("binding_score", 0) >= 2),
|
|
||||||
("Texture", lambda r: r.get("water_activity", 1.0) < 0.6), # low water = likely crunchy/dry
|
|
||||||
]
|
|
||||||
|
|
||||||
_ACID_KEYWORDS = ["vinegar", "lemon", "lime", "citric", "tartaric", "kombucha", "kefir",
|
|
||||||
"yogurt", "buttermilk", "wine", "tomato"]
|
|
||||||
_AROMA_KEYWORDS = ["garlic", "onion", "herb", "spice", "basil", "oregano", "cumin",
|
|
||||||
"ginger", "cinnamon", "pepper", "chili", "paprika", "thyme", "rosemary",
|
|
||||||
"cilantro", "parsley", "dill", "fennel", "cardamom", "turmeric"]
|
|
||||||
_FERMENTED_KEYWORDS = ["miso", "soy sauce", "kimchi", "sauerkraut", "kefir", "yogurt",
|
|
||||||
"kombucha", "tempeh", "natto", "vinegar", "nutritional yeast"]
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_name(raw: str) -> str:
|
|
||||||
"""Lowercase, strip parentheticals and trailing descriptors."""
|
|
||||||
name = raw.lower().strip()
|
|
||||||
name = re.sub(r"\(.*?\)", "", name) # remove (85% lean)
|
|
||||||
name = re.sub(r",.*$", "", name) # remove ,shredded
|
|
||||||
name = re.sub(r"\s+", " ", name).strip()
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def derive_elements(row: dict) -> list[str]:
|
|
||||||
elements = [elem for elem, check in _ELEMENT_RULES if check(row)]
|
|
||||||
name = row.get("name", "").lower()
|
|
||||||
if any(k in name for k in _ACID_KEYWORDS):
|
|
||||||
elements.append("Brightness")
|
|
||||||
if any(k in name for k in _AROMA_KEYWORDS):
|
|
||||||
elements.append("Aroma")
|
|
||||||
return list(dict.fromkeys(elements)) # dedup, preserve order
|
|
||||||
|
|
||||||
|
|
||||||
def derive_binding_score(row: dict) -> int:
|
|
||||||
protein = row.get("protein_pct", 0)
|
|
||||||
starch = row.get("starch_pct", 0)
|
|
||||||
if starch > 50 or (protein > 10 and starch > 20):
|
|
||||||
return 3
|
|
||||||
if starch > 20 or protein > 12:
|
|
||||||
return 2
|
|
||||||
if starch > 5 or protein > 6:
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def build(db_path: Path, usda_fdc_path: Path, usda_branded_path: Path) -> None:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
|
||||||
|
|
||||||
df_fdc = pd.read_parquet(usda_fdc_path)
|
|
||||||
df_branded = pd.read_parquet(usda_branded_path)
|
|
||||||
|
|
||||||
# Rename columns to unified schema
|
|
||||||
fdc_col_map = {
|
|
||||||
"food_item": "name",
|
|
||||||
"Total lipid (fat)": "fat_pct",
|
|
||||||
"Protein": "protein_pct",
|
|
||||||
"Carbohydrate, by difference": "carb_pct",
|
|
||||||
"Fiber, total dietary": "fiber_pct",
|
|
||||||
"Sodium, Na": "sodium_mg_per_100g",
|
|
||||||
"Water": "moisture_pct",
|
|
||||||
"Energy": "calories_per_100g",
|
|
||||||
}
|
|
||||||
df = df_fdc.rename(columns={k: v for k, v in fdc_col_map.items() if k in df_fdc.columns})
|
|
||||||
|
|
||||||
# Build a sugar lookup from the branded parquet (keyed by normalized name).
|
|
||||||
# usda_branded has SUGARS, TOTAL (G) for processed/packaged foods.
|
|
||||||
branded_col_map = {
|
|
||||||
"FOOD_NAME": "name",
|
|
||||||
"SUGARS, TOTAL (G)": "sugar_g_per_100g",
|
|
||||||
}
|
|
||||||
df_branded_slim = df_branded.rename(
|
|
||||||
columns={k: v for k, v in branded_col_map.items() if k in df_branded.columns}
|
|
||||||
)[list(set(branded_col_map.values()) & set(df_branded.rename(columns=branded_col_map).columns))]
|
|
||||||
sugar_lookup: dict[str, float] = {}
|
|
||||||
for _, brow in df_branded_slim.iterrows():
|
|
||||||
bname = normalize_name(str(brow.get("name", "")))
|
|
||||||
val = brow.get("sugar_g_per_100g")
|
|
||||||
try:
|
|
||||||
fval = float(val) # type: ignore[arg-type]
|
|
||||||
if fval > 0 and bname not in sugar_lookup:
|
|
||||||
sugar_lookup[bname] = fval
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
name = normalize_name(str(row.get("name", "")))
|
|
||||||
if not name or len(name) < 2:
|
|
||||||
continue
|
|
||||||
r = {
|
|
||||||
"name": name,
|
|
||||||
"fat_pct": float(row.get("fat_pct") or 0),
|
|
||||||
"protein_pct": float(row.get("protein_pct") or 0),
|
|
||||||
"moisture_pct": float(row.get("moisture_pct") or 0),
|
|
||||||
"sodium_mg_per_100g": float(row.get("sodium_mg_per_100g") or 0),
|
|
||||||
"starch_pct": 0.0,
|
|
||||||
"carbs_g_per_100g": float(row.get("carb_pct") or 0),
|
|
||||||
"fiber_g_per_100g": float(row.get("fiber_pct") or 0),
|
|
||||||
"calories_per_100g": float(row.get("calories_per_100g") or 0),
|
|
||||||
"sugar_g_per_100g": sugar_lookup.get(name, 0.0),
|
|
||||||
}
|
|
||||||
r["binding_score"] = derive_binding_score(r)
|
|
||||||
r["elements"] = derive_elements(r)
|
|
||||||
r["is_fermented"] = int(any(k in name for k in _FERMENTED_KEYWORDS))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Insert new profile or update macro columns on existing one.
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO ingredient_profiles
|
|
||||||
(name, elements, fat_pct, fat_saturated_pct, moisture_pct,
|
|
||||||
protein_pct, starch_pct, binding_score, sodium_mg_per_100g,
|
|
||||||
is_fermented,
|
|
||||||
carbs_g_per_100g, fiber_g_per_100g, calories_per_100g, sugar_g_per_100g,
|
|
||||||
source)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
ON CONFLICT(name) DO UPDATE SET
|
|
||||||
carbs_g_per_100g = excluded.carbs_g_per_100g,
|
|
||||||
fiber_g_per_100g = excluded.fiber_g_per_100g,
|
|
||||||
calories_per_100g = excluded.calories_per_100g,
|
|
||||||
sugar_g_per_100g = excluded.sugar_g_per_100g
|
|
||||||
""", (
|
|
||||||
r["name"], json.dumps(r["elements"]),
|
|
||||||
r["fat_pct"], 0.0, r["moisture_pct"],
|
|
||||||
r["protein_pct"], r["starch_pct"], r["binding_score"],
|
|
||||||
r["sodium_mg_per_100g"], r["is_fermented"],
|
|
||||||
r["carbs_g_per_100g"], r["fiber_g_per_100g"],
|
|
||||||
r["calories_per_100g"], r["sugar_g_per_100g"],
|
|
||||||
"usda_fdc",
|
|
||||||
))
|
|
||||||
inserted += 1
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
print(f"Inserted {inserted} ingredient profiles from USDA FDC")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--db", required=True, type=Path)
|
|
||||||
parser.add_argument("--usda-fdc", required=True, type=Path)
|
|
||||||
parser.add_argument("--usda-branded", required=True, type=Path)
|
|
||||||
args = parser.parse_args()
|
|
||||||
build(args.db, args.usda_fdc, args.usda_branded)
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
"""
|
|
||||||
Import food.com recipe corpus into recipes table.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
conda run -n job-seeker python scripts/pipeline/build_recipe_index.py \
|
|
||||||
--db /path/to/kiwi.db \
|
|
||||||
--recipes data/recipes_foodcom.parquet \
|
|
||||||
--batch-size 10000
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
_MEASURE_PATTERN = re.compile(
|
|
||||||
r"^\d[\d\s/\u00bc\u00bd\u00be\u2153\u2154]*\s*(cup|tbsp|tsp|oz|lb|g|kg|ml|l|clove|slice|piece|can|pkg|package|bunch|head|stalk|sprig|pinch|dash|to taste|as needed)s?\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_LEAD_NUMBER = re.compile(r"^\d[\d\s/\u00bc\u00bd\u00be\u2153\u2154]*\s*")
|
|
||||||
_TRAILING_QUALIFIER = re.compile(
|
|
||||||
r"\s*(to taste|as needed|or more|or less|optional|if desired|if needed)\s*$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_QUOTED = re.compile(r'"([^"]*)"')
|
|
||||||
|
|
||||||
|
|
||||||
def _float_or_none(val: object) -> float | None:
|
|
||||||
"""Return float > 0, or None for missing / zero values."""
|
|
||||||
try:
|
|
||||||
v = float(val) # type: ignore[arg-type]
|
|
||||||
return v if v > 0 else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_list(val: object) -> list:
|
|
||||||
"""Convert a value to a list, handling NaN/float/None gracefully."""
|
|
||||||
if val is None:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
import math
|
|
||||||
if isinstance(val, float) and math.isnan(val):
|
|
||||||
return []
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if isinstance(val, list):
|
|
||||||
return val
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_r_vector(s: str) -> list[str]:
|
|
||||||
"""Parse R character vector format: c("a", "b") -> ["a", "b"]."""
|
|
||||||
return _QUOTED.findall(s)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_ingredient_names(raw_list: list[str]) -> list[str]:
|
|
||||||
"""Strip quantities and units from ingredient strings -> normalized names."""
|
|
||||||
names = []
|
|
||||||
for raw in raw_list:
|
|
||||||
s = raw.lower().strip()
|
|
||||||
s = _MEASURE_PATTERN.sub("", s)
|
|
||||||
s = _LEAD_NUMBER.sub("", s)
|
|
||||||
s = re.sub(r"\(.*?\)", "", s)
|
|
||||||
s = re.sub(r",.*$", "", s)
|
|
||||||
s = _TRAILING_QUALIFIER.sub("", s)
|
|
||||||
s = s.strip(" -.,")
|
|
||||||
if s and len(s) > 1:
|
|
||||||
names.append(s)
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def compute_element_coverage(profiles: list[dict]) -> dict[str, float]:
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
for p in profiles:
|
|
||||||
for elem in p.get("elements", []):
|
|
||||||
counts[elem] = counts.get(elem, 0) + 1
|
|
||||||
if not profiles:
|
|
||||||
return {}
|
|
||||||
return {e: round(c / len(profiles), 3) for e, c in counts.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_allrecipes_text(text: str) -> tuple[str, list[str], list[str]]:
|
|
||||||
"""Parse corbt/all-recipes text format into (title, ingredients, directions)."""
|
|
||||||
lines = text.strip().split('\n')
|
|
||||||
title = lines[0].strip()
|
|
||||||
ingredients: list[str] = []
|
|
||||||
directions: list[str] = []
|
|
||||||
section: str | None = None
|
|
||||||
for line in lines[1:]:
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped.lower() == 'ingredients:':
|
|
||||||
section = 'ingredients'
|
|
||||||
elif stripped.lower() in ('directions:', 'steps:', 'instructions:'):
|
|
||||||
section = 'directions'
|
|
||||||
elif stripped.startswith('- ') and section == 'ingredients':
|
|
||||||
ingredients.append(stripped[2:].strip())
|
|
||||||
elif stripped.startswith('- ') and section == 'directions':
|
|
||||||
directions.append(stripped[2:].strip())
|
|
||||||
return title, ingredients, directions
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_fields(row: pd.Series) -> tuple[str, str, list[str], list[str]]:
|
|
||||||
"""Extract (external_id, title, raw_ingredients, directions) from a parquet row.
|
|
||||||
|
|
||||||
Handles both corbt/all-recipes (single 'input' text column) and the
|
|
||||||
food.com columnar format (RecipeId, Name, RecipeIngredientParts, ...).
|
|
||||||
"""
|
|
||||||
if "input" in row.index and pd.notna(row.get("input")):
|
|
||||||
title, raw_ingredients, directions = _parse_allrecipes_text(str(row["input"]))
|
|
||||||
external_id = f"ar_{hash(title) & 0xFFFFFFFF}"
|
|
||||||
else:
|
|
||||||
raw_parts = row.get("RecipeIngredientParts", [])
|
|
||||||
if isinstance(raw_parts, str):
|
|
||||||
parsed = _parse_r_vector(raw_parts)
|
|
||||||
raw_parts = parsed if parsed else [raw_parts]
|
|
||||||
raw_ingredients = [str(i) for i in (_safe_list(raw_parts))]
|
|
||||||
|
|
||||||
raw_dirs = row.get("RecipeInstructions", [])
|
|
||||||
if isinstance(raw_dirs, str):
|
|
||||||
parsed_dirs = _parse_r_vector(raw_dirs)
|
|
||||||
directions = parsed_dirs if parsed_dirs else [raw_dirs]
|
|
||||||
else:
|
|
||||||
directions = [str(d) for d in (_safe_list(raw_dirs))]
|
|
||||||
|
|
||||||
title = str(row.get("Name", ""))[:500]
|
|
||||||
external_id = str(row.get("RecipeId", ""))
|
|
||||||
|
|
||||||
return external_id, title, raw_ingredients, directions
|
|
||||||
|
|
||||||
|
|
||||||
def build(db_path: Path, recipes_path: Path, batch_size: int = 10000) -> None:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
|
||||||
|
|
||||||
# Pre-load ingredient element profiles to avoid N+1 queries
|
|
||||||
profile_index: dict[str, list[str]] = {}
|
|
||||||
for row in conn.execute("SELECT name, elements FROM ingredient_profiles"):
|
|
||||||
try:
|
|
||||||
profile_index[row[0]] = json.loads(row[1])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
df = pd.read_parquet(recipes_path)
|
|
||||||
inserted = 0
|
|
||||||
batch = []
|
|
||||||
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
external_id, title, raw_ingredients, directions = _row_to_fields(row)
|
|
||||||
if not title:
|
|
||||||
continue
|
|
||||||
ingredient_names = extract_ingredient_names(raw_ingredients)
|
|
||||||
|
|
||||||
profiles = []
|
|
||||||
for name in ingredient_names:
|
|
||||||
if name in profile_index:
|
|
||||||
profiles.append({"elements": profile_index[name]})
|
|
||||||
coverage = compute_element_coverage(profiles)
|
|
||||||
|
|
||||||
batch.append((
|
|
||||||
external_id,
|
|
||||||
title,
|
|
||||||
json.dumps(raw_ingredients),
|
|
||||||
json.dumps(ingredient_names),
|
|
||||||
json.dumps(directions),
|
|
||||||
str(row.get("RecipeCategory", "") or ""),
|
|
||||||
json.dumps(_safe_list(row.get("Keywords"))),
|
|
||||||
_float_or_none(row.get("Calories")),
|
|
||||||
_float_or_none(row.get("FatContent")),
|
|
||||||
_float_or_none(row.get("ProteinContent")),
|
|
||||||
_float_or_none(row.get("SodiumContent")),
|
|
||||||
json.dumps(coverage),
|
|
||||||
# New macro columns (migration 014)
|
|
||||||
_float_or_none(row.get("SugarContent")),
|
|
||||||
_float_or_none(row.get("CarbohydrateContent")),
|
|
||||||
_float_or_none(row.get("FiberContent")),
|
|
||||||
_float_or_none(row.get("RecipeServings")),
|
|
||||||
0, # nutrition_estimated — food.com direct data is authoritative
|
|
||||||
))
|
|
||||||
|
|
||||||
if len(batch) >= batch_size:
|
|
||||||
before = conn.total_changes
|
|
||||||
conn.executemany("""
|
|
||||||
INSERT OR REPLACE INTO recipes
|
|
||||||
(external_id, title, ingredients, ingredient_names, directions,
|
|
||||||
category, keywords, calories, fat_g, protein_g, sodium_mg,
|
|
||||||
element_coverage,
|
|
||||||
sugar_g, carbs_g, fiber_g, servings, nutrition_estimated)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""", batch)
|
|
||||||
conn.commit()
|
|
||||||
inserted += conn.total_changes - before
|
|
||||||
print(f" {inserted} recipes inserted...")
|
|
||||||
batch = []
|
|
||||||
|
|
||||||
if batch:
|
|
||||||
before = conn.total_changes
|
|
||||||
conn.executemany("""
|
|
||||||
INSERT OR REPLACE INTO recipes
|
|
||||||
(external_id, title, ingredients, ingredient_names, directions,
|
|
||||||
category, keywords, calories, fat_g, protein_g, sodium_mg,
|
|
||||||
element_coverage,
|
|
||||||
sugar_g, carbs_g, fiber_g, servings, nutrition_estimated)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""", batch)
|
|
||||||
conn.commit()
|
|
||||||
inserted += conn.total_changes - before
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
print(f"Total: {inserted} recipes inserted")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--db", required=True, type=Path)
|
|
||||||
parser.add_argument("--recipes", required=True, type=Path)
|
|
||||||
parser.add_argument("--batch-size", type=int, default=10000)
|
|
||||||
args = parser.parse_args()
|
|
||||||
build(args.db, args.recipes, args.batch_size)
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
"""
|
|
||||||
Derive substitution pairs by diffing lishuyang/recipepairs.
|
|
||||||
GPL-3.0 source -- derived annotations only, raw pairs not shipped.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
PYTHONPATH=/path/to/kiwi conda run -n cf python scripts/pipeline/derive_substitutions.py \
|
|
||||||
--db /path/to/kiwi.db \
|
|
||||||
--recipepairs data/pipeline/recipepairs.parquet \
|
|
||||||
--recipepairs-recipes data/pipeline/recipepairs_recipes.parquet
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from collections import defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def diff_ingredients(base: list[str], target: list[str]) -> tuple[list[str], list[str]]:
|
|
||||||
base_set = set(base)
|
|
||||||
target_set = set(target)
|
|
||||||
removed = list(base_set - target_set)
|
|
||||||
added = list(target_set - base_set)
|
|
||||||
return removed, added
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_categories(val: object) -> list[str]:
|
|
||||||
"""Parse categories field which may be a list, str-repr list, or bare string."""
|
|
||||||
if isinstance(val, list):
|
|
||||||
return [str(v) for v in val]
|
|
||||||
if isinstance(val, str):
|
|
||||||
val = val.strip()
|
|
||||||
if val.startswith("["):
|
|
||||||
# parse list repr: ['a', 'b'] — use json after converting single quotes
|
|
||||||
try:
|
|
||||||
fixed = re.sub(r"'", '"', val)
|
|
||||||
return json.loads(fixed)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return [val] if val else []
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def build(db_path: Path, recipepairs_path: Path, recipes_path: Path) -> None:
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
# Load ingredient lists from the bundled recipepairs recipe corpus.
|
|
||||||
# This is GPL-3.0 data — we only use it for diffing; raw data is not persisted.
|
|
||||||
print("Loading recipe ingredient index from recipepairs corpus...")
|
|
||||||
recipes_df = pd.read_parquet(recipes_path, columns=["id", "ingredients"])
|
|
||||||
recipe_ingredients: dict[str, list[str]] = {}
|
|
||||||
for _, r in recipes_df.iterrows():
|
|
||||||
ings = r["ingredients"]
|
|
||||||
if ings is not None and hasattr(ings, "__iter__") and not isinstance(ings, str):
|
|
||||||
recipe_ingredients[str(int(r["id"]))] = [str(i) for i in ings]
|
|
||||||
print(f" {len(recipe_ingredients)} recipes loaded")
|
|
||||||
|
|
||||||
pairs_df = pd.read_parquet(recipepairs_path)
|
|
||||||
pair_counts: dict[tuple, dict] = defaultdict(lambda: {"count": 0})
|
|
||||||
|
|
||||||
print("Diffing recipe pairs...")
|
|
||||||
for _, row in pairs_df.iterrows():
|
|
||||||
base_id = str(int(row["base"]))
|
|
||||||
target_id = str(int(row["target"]))
|
|
||||||
base_ings = recipe_ingredients.get(base_id, [])
|
|
||||||
target_ings = recipe_ingredients.get(target_id, [])
|
|
||||||
if not base_ings or not target_ings:
|
|
||||||
continue
|
|
||||||
|
|
||||||
removed, added = diff_ingredients(base_ings, target_ings)
|
|
||||||
if len(removed) != 1 or len(added) != 1:
|
|
||||||
continue
|
|
||||||
|
|
||||||
original = removed[0]
|
|
||||||
substitute = added[0]
|
|
||||||
constraints = _parse_categories(row.get("categories", []))
|
|
||||||
if not constraints:
|
|
||||||
continue
|
|
||||||
for constraint in constraints:
|
|
||||||
key = (original, substitute, constraint)
|
|
||||||
pair_counts[key]["count"] += 1
|
|
||||||
|
|
||||||
def get_profile(name: str) -> dict:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT fat_pct, moisture_pct, glutamate_mg, protein_pct "
|
|
||||||
"FROM ingredient_profiles WHERE name = ?", (name,)
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
return {"fat": row[0] or 0, "moisture": row[1] or 0,
|
|
||||||
"glutamate": row[2] or 0, "protein": row[3] or 0}
|
|
||||||
return {"fat": 0, "moisture": 0, "glutamate": 0, "protein": 0}
|
|
||||||
|
|
||||||
print("Writing substitution pairs...")
|
|
||||||
inserted = 0
|
|
||||||
for (original, substitute, constraint), data in pair_counts.items():
|
|
||||||
if data["count"] < 3:
|
|
||||||
continue
|
|
||||||
p_orig = get_profile(original)
|
|
||||||
p_sub = get_profile(substitute)
|
|
||||||
conn.execute("""
|
|
||||||
INSERT OR REPLACE INTO substitution_pairs
|
|
||||||
(original_name, substitute_name, constraint_label,
|
|
||||||
fat_delta, moisture_delta, glutamate_delta, protein_delta,
|
|
||||||
occurrence_count, source)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?)
|
|
||||||
""", (
|
|
||||||
original, substitute, constraint,
|
|
||||||
round(p_sub["fat"] - p_orig["fat"], 2),
|
|
||||||
round(p_sub["moisture"] - p_orig["moisture"], 2),
|
|
||||||
round(p_sub["glutamate"] - p_orig["glutamate"], 2),
|
|
||||||
round(p_sub["protein"] - p_orig["protein"], 2),
|
|
||||||
data["count"], "derived",
|
|
||||||
))
|
|
||||||
inserted += 1
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
print(f"Inserted {inserted} substitution pairs (min 3 occurrences)")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--db", required=True, type=Path)
|
|
||||||
parser.add_argument("--recipepairs", required=True, type=Path,
|
|
||||||
help="pairs.parquet from lishuyang/recipepairs")
|
|
||||||
parser.add_argument("--recipepairs-recipes", required=True, type=Path,
|
|
||||||
dest="recipepairs_recipes",
|
|
||||||
help="recipes.parquet from lishuyang/recipepairs (ingredient lookup)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
build(args.db, args.recipepairs, args.recipepairs_recipes)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue