Compare commits
No commits in common. "main" and "fix/a11y-audit" have entirely different histories.
main
...
fix/a11y-a
94 changed files with 329 additions and 6525 deletions
44
.cliff.toml
44
.cliff.toml
|
|
@ -1,44 +0,0 @@
|
||||||
# git-cliff changelog configuration for Kiwi
|
|
||||||
# See: https://git-cliff.org/docs/configuration
|
|
||||||
|
|
||||||
[changelog]
|
|
||||||
header = """
|
|
||||||
# Changelog\n
|
|
||||||
"""
|
|
||||||
body = """
|
|
||||||
{% if version %}\
|
|
||||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
|
||||||
{% else %}\
|
|
||||||
## [Unreleased]
|
|
||||||
{% endif %}\
|
|
||||||
{% for group, commits in commits | group_by(attribute="group") %}
|
|
||||||
### {{ group | upper_first }}
|
|
||||||
{% for commit in commits %}
|
|
||||||
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }}\
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}\n
|
|
||||||
"""
|
|
||||||
trim = true
|
|
||||||
|
|
||||||
[git]
|
|
||||||
conventional_commits = true
|
|
||||||
filter_unconventional = true
|
|
||||||
split_commits = false
|
|
||||||
commit_preprocessors = []
|
|
||||||
commit_parsers = [
|
|
||||||
{ message = "^feat", group = "Features" },
|
|
||||||
{ message = "^fix", group = "Bug Fixes" },
|
|
||||||
{ message = "^perf", group = "Performance" },
|
|
||||||
{ message = "^refactor", group = "Refactoring" },
|
|
||||||
{ message = "^docs", group = "Documentation" },
|
|
||||||
{ message = "^test", group = "Testing" },
|
|
||||||
{ message = "^chore", group = "Chores" },
|
|
||||||
{ message = "^ci", group = "CI/CD" },
|
|
||||||
{ message = "^revert", group = "Reverts" },
|
|
||||||
]
|
|
||||||
filter_commits = false
|
|
||||||
tag_pattern = "v[0-9].*"
|
|
||||||
skip_tags = ""
|
|
||||||
ignore_tags = ""
|
|
||||||
topo_order = false
|
|
||||||
sort_commits = "oldest"
|
|
||||||
13
.env.example
13
.env.example
|
|
@ -51,12 +51,6 @@ ENABLE_OCR=false
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
CLOUD_MODE=false
|
CLOUD_MODE=false
|
||||||
DEMO_MODE=false
|
DEMO_MODE=false
|
||||||
# Product identifier reported in cf-orch coordinator analytics for per-app breakdown
|
|
||||||
CF_APP_NAME=kiwi
|
|
||||||
# USE_ORCH_SCHEDULER: use coordinator-aware multi-GPU scheduler instead of local FIFO.
|
|
||||||
# Unset = auto-detect: true if CLOUD_MODE or circuitforge_orch is installed (paid+ local).
|
|
||||||
# Set false to force LocalScheduler even when cf-orch is present.
|
|
||||||
# USE_ORCH_SCHEDULER=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
|
||||||
|
|
@ -74,14 +68,9 @@ CF_APP_NAME=kiwi
|
||||||
# HEIMDALL_URL=https://license.circuitforge.tech
|
# HEIMDALL_URL=https://license.circuitforge.tech
|
||||||
# HEIMDALL_ADMIN_TOKEN=
|
# HEIMDALL_ADMIN_TOKEN=
|
||||||
|
|
||||||
# Directus JWT (must match cf-directus SECRET env var exactly, including base64 == padding)
|
# Directus JWT (must match cf-directus SECRET env var)
|
||||||
# DIRECTUS_JWT_SECRET=
|
# DIRECTUS_JWT_SECRET=
|
||||||
|
|
||||||
# E2E test account (Directus — free tier, used by automated tests)
|
|
||||||
# E2E_TEST_EMAIL=e2e@circuitforge.tech
|
|
||||||
# E2E_TEST_PASSWORD=
|
|
||||||
# E2E_TEST_USER_ID=
|
|
||||||
|
|
||||||
# In-app feedback → Forgejo issue creation
|
# In-app feedback → Forgejo issue creation
|
||||||
# FORGEJO_API_TOKEN=
|
# FORGEJO_API_TOKEN=
|
||||||
# FORGEJO_REPO=Circuit-Forge/kiwi
|
# FORGEJO_REPO=Circuit-Forge/kiwi
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Kiwi CI — lint, type-check, test on PR/push
|
|
||||||
# Full-stack: FastAPI (Python) + Vue 3 SPA (Node)
|
|
||||||
# Adapted from Circuit-Forge/cf-agents workflows/ci.yml (cf-agents#4 tracks the
|
|
||||||
# upstream ci-fullstack.yml variant; update this file when that lands).
|
|
||||||
#
|
|
||||||
# Note: frontend has no test suite yet — CI runs typecheck only.
|
|
||||||
# Add `npm run test` when vitest is wired (kiwi#XX).
|
|
||||||
#
|
|
||||||
# circuitforge-core is not on PyPI — installed from Forgejo git (public repo).
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, 'feature/**', 'fix/**']
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend:
|
|
||||||
name: Backend (Python)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install circuitforge-core
|
|
||||||
run: pip install git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -e ".[dev]" || pip install -e . pytest pytest-asyncio httpx ruff
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: pytest tests/ -v --tb=short
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend (Vue)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx vue-tsc --noEmit
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
# Mirror push to GitHub and Codeberg on every push to main or tag.
|
|
||||||
# Copied from Circuit-Forge/cf-agents workflows/mirror.yml
|
|
||||||
# Required secrets: GITHUB_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN
|
|
||||||
|
|
||||||
name: Mirror
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mirror:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Mirror to GitHub
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
run: |
|
|
||||||
git remote add github "https://x-access-token:${GITHUB_TOKEN}@github.com/CircuitForgeLLC/${REPO}.git"
|
|
||||||
git push github --mirror
|
|
||||||
|
|
||||||
- name: Mirror to Codeberg
|
|
||||||
env:
|
|
||||||
CODEBERG_TOKEN: ${{ secrets.CODEBERG_MIRROR_TOKEN }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
run: |
|
|
||||||
git remote add codeberg "https://CircuitForge:${CODEBERG_TOKEN}@codeberg.org/CircuitForge/${REPO}.git"
|
|
||||||
git push codeberg --mirror
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
# Tag-triggered release workflow.
|
|
||||||
# Generates changelog and creates Forgejo release on v* tags.
|
|
||||||
# Copied from Circuit-Forge/cf-agents workflows/release.yml
|
|
||||||
#
|
|
||||||
# Docker push is intentionally disabled — BSL 1.1 registry policy not yet resolved.
|
|
||||||
# Tracked in Circuit-Forge/cf-agents#3. Re-enable the Docker steps when that lands.
|
|
||||||
#
|
|
||||||
# Required secrets: FORGEJO_RELEASE_TOKEN
|
|
||||||
# (GHCR_TOKEN not needed until Docker push is enabled)
|
|
||||||
|
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# ── Changelog ────────────────────────────────────────────────────────────
|
|
||||||
- name: Generate changelog
|
|
||||||
uses: orhun/git-cliff-action@v3
|
|
||||||
id: cliff
|
|
||||||
with:
|
|
||||||
config: .cliff.toml
|
|
||||||
args: --latest --strip header
|
|
||||||
env:
|
|
||||||
OUTPUT: CHANGES.md
|
|
||||||
|
|
||||||
# ── Docker (disabled — BSL registry policy pending cf-agents#3) ──────────
|
|
||||||
# - name: Set up QEMU
|
|
||||||
# uses: docker/setup-qemu-action@v3
|
|
||||||
# - name: Set up Buildx
|
|
||||||
# uses: docker/setup-buildx-action@v3
|
|
||||||
# - name: Log in to GHCR
|
|
||||||
# uses: docker/login-action@v3
|
|
||||||
# with:
|
|
||||||
# registry: ghcr.io
|
|
||||||
# username: ${{ github.actor }}
|
|
||||||
# password: ${{ secrets.GHCR_TOKEN }}
|
|
||||||
# - name: Build and push Docker image
|
|
||||||
# uses: docker/build-push-action@v6
|
|
||||||
# with:
|
|
||||||
# context: .
|
|
||||||
# push: true
|
|
||||||
# platforms: linux/amd64,linux/arm64
|
|
||||||
# tags: |
|
|
||||||
# ghcr.io/circuitforgellc/kiwi:${{ github.ref_name }}
|
|
||||||
# ghcr.io/circuitforgellc/kiwi:latest
|
|
||||||
# cache-from: type=gha
|
|
||||||
# cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
# ── Forgejo Release ───────────────────────────────────────────────────────
|
|
||||||
- name: Create Forgejo release
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
NOTES: ${{ steps.cliff.outputs.content }}
|
|
||||||
run: |
|
|
||||||
curl -sS -X POST \
|
|
||||||
"https://git.opensourcesolarpunk.com/api/v1/repos/Circuit-Forge/${REPO}/releases" \
|
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg body "$NOTES" \
|
|
||||||
'{tag_name: $tag, name: $tag, body: $body}')"
|
|
||||||
59
.github/workflows/ci.yml
vendored
59
.github/workflows/ci.yml
vendored
|
|
@ -1,59 +0,0 @@
|
||||||
# Kiwi CI — runs on GitHub mirror for public credibility badge.
|
|
||||||
# Forgejo (.forgejo/workflows/ci.yml) is the canonical CI — keep these in sync.
|
|
||||||
# No Forgejo-specific secrets used here; circuitforge-core is public on Forgejo.
|
|
||||||
#
|
|
||||||
# Note: frontend has no test suite yet — CI runs typecheck only.
|
|
||||||
# Add 'npm run test' when vitest is wired.
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend:
|
|
||||||
name: Backend (Python)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install circuitforge-core
|
|
||||||
run: pip install git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -e . pytest pytest-asyncio httpx ruff
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: pytest tests/ -v --tb=short
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend (Vue)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: npx vue-tsc --noEmit
|
|
||||||
|
|
@ -3,16 +3,6 @@
|
||||||
[extend]
|
[extend]
|
||||||
path = "/Library/Development/CircuitForge/circuitforge-hooks/gitleaks.toml"
|
path = "/Library/Development/CircuitForge/circuitforge-hooks/gitleaks.toml"
|
||||||
|
|
||||||
# ── Global allowlist ──────────────────────────────────────────────────────────
|
|
||||||
# Amazon grocery department IDs (rh=n:<10-digit>) false-positive as phone
|
|
||||||
# numbers. locale_config.py is a static lookup table with no secrets.
|
|
||||||
|
|
||||||
[allowlist]
|
|
||||||
# Amazon grocery dept IDs (rh=n:<digits>) false-positive as phone numbers.
|
|
||||||
regexes = [
|
|
||||||
'''rh=n:\d{8,12}''',
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── Test fixture allowlists ───────────────────────────────────────────────────
|
# ── Test fixture allowlists ───────────────────────────────────────────────────
|
||||||
|
|
||||||
[[rules]]
|
[[rules]]
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY circuitforge-core/ ./circuitforge-core/
|
COPY circuitforge-core/ ./circuitforge-core/
|
||||||
RUN conda run -n base pip install --no-cache-dir -e ./circuitforge-core
|
RUN conda run -n base pip install --no-cache-dir -e ./circuitforge-core
|
||||||
|
|
||||||
# Install circuitforge-orch — needed for the cf-orch-agent sidecar (compose.override.yml)
|
|
||||||
COPY circuitforge-orch/ ./circuitforge-orch/
|
|
||||||
|
|
||||||
# Create kiwi conda env and install app
|
# Create kiwi conda env and install app
|
||||||
COPY kiwi/environment.yml .
|
COPY kiwi/environment.yml .
|
||||||
RUN conda env create -f environment.yml
|
RUN conda env create -f environment.yml
|
||||||
|
|
@ -25,9 +22,8 @@ COPY kiwi/ ./kiwi/
|
||||||
# they never end up in the cloud image regardless of .dockerignore placement.
|
# they never end up in the cloud image regardless of .dockerignore placement.
|
||||||
RUN rm -f /app/kiwi/.env
|
RUN rm -f /app/kiwi/.env
|
||||||
|
|
||||||
# Install cf-core and cf-orch into the kiwi env BEFORE installing kiwi
|
# Install cf-core into the kiwi env BEFORE installing kiwi (kiwi lists it as a dep)
|
||||||
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-core
|
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-core
|
||||||
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-orch
|
|
||||||
WORKDIR /app/kiwi
|
WORKDIR /app/kiwi
|
||||||
RUN conda run -n kiwi pip install --no-cache-dir -e .
|
RUN conda run -n kiwi pip install --no-cache-dir -e .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,13 +62,7 @@ async def list_posts(
|
||||||
):
|
):
|
||||||
store = _get_community_store()
|
store = _get_community_store()
|
||||||
if store is None:
|
if store is None:
|
||||||
return {
|
return {"posts": [], "total": 0, "note": "Community DB not available on this instance."}
|
||||||
"posts": [],
|
|
||||||
"total": 0,
|
|
||||||
"page": page,
|
|
||||||
"page_size": page_size,
|
|
||||||
"note": "Community DB not available on this instance.",
|
|
||||||
}
|
|
||||||
|
|
||||||
dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None
|
dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None
|
||||||
allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None
|
allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None
|
||||||
|
|
@ -82,8 +76,7 @@ async def list_posts(
|
||||||
dietary_tags=dietary,
|
dietary_tags=dietary,
|
||||||
allergen_exclude=allergen_ex,
|
allergen_exclude=allergen_ex,
|
||||||
)
|
)
|
||||||
visible = [_post_to_dict(p) for p in posts if _visible(p)]
|
return {"posts": [_post_to_dict(p) for p in posts if _visible(p)], "page": page, "page_size": page_size}
|
||||||
return {"posts": visible, "total": len(visible), "page": page, "page_size": page_size}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/posts/{slug}")
|
@router.get("/posts/{slug}")
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
"""Export endpoints — CSV and JSON export of user data."""
|
"""Export endpoints — CSV/Excel of receipt and inventory data."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
@ -47,33 +45,3 @@ async def export_inventory_csv(store: Store = Depends(get_store)):
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
|
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/json")
|
|
||||||
async def export_full_json(store: Store = Depends(get_store)):
|
|
||||||
"""Export full pantry inventory + saved recipes as a single JSON file.
|
|
||||||
|
|
||||||
Intended for data portability — users can import this into another
|
|
||||||
Kiwi instance or keep it as an offline backup.
|
|
||||||
"""
|
|
||||||
inventory, saved = await asyncio.gather(
|
|
||||||
asyncio.to_thread(store.list_inventory),
|
|
||||||
asyncio.to_thread(store.get_saved_recipes),
|
|
||||||
)
|
|
||||||
|
|
||||||
export_doc = {
|
|
||||||
"kiwi_export": {
|
|
||||||
"version": "1.0",
|
|
||||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"inventory": [dict(row) for row in inventory],
|
|
||||||
"saved_recipes": [dict(row) for row in saved],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body = json.dumps(export_doc, default=str, indent=2)
|
|
||||||
filename = f"kiwi-export-{datetime.now(timezone.utc).strftime('%Y%m%d')}.json"
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([body]),
|
|
||||||
media_type="application/json",
|
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"""Screenshot attachment endpoint for in-app feedback.
|
|
||||||
|
|
||||||
After the cf-core feedback router creates a Forgejo issue, the frontend
|
|
||||||
can call POST /feedback/attach to upload a screenshot and pin it as a
|
|
||||||
comment on that issue.
|
|
||||||
|
|
||||||
The endpoint is separate from the cf-core router so Kiwi owns it
|
|
||||||
without modifying shared infrastructure.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_FORGEJO_BASE = os.environ.get(
|
|
||||||
"FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1"
|
|
||||||
)
|
|
||||||
_REPO = "Circuit-Forge/kiwi"
|
|
||||||
_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
|
|
||||||
|
|
||||||
|
|
||||||
class AttachRequest(BaseModel):
|
|
||||||
issue_number: int
|
|
||||||
filename: str = Field(default="screenshot.png", max_length=80)
|
|
||||||
image_b64: str # data URI or raw base64
|
|
||||||
|
|
||||||
|
|
||||||
class AttachResponse(BaseModel):
|
|
||||||
comment_url: str
|
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_headers() -> dict[str, str]:
|
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
|
||||||
return {"Authorization": f"token {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_image(image_b64: str) -> tuple[bytes, str]:
|
|
||||||
"""Return (raw_bytes, mime_type) from a base64 string or data URI."""
|
|
||||||
if image_b64.startswith("data:"):
|
|
||||||
header, _, data = image_b64.partition(",")
|
|
||||||
mime = header.split(";")[0].split(":")[1] if ":" in header else "image/png"
|
|
||||||
else:
|
|
||||||
data = image_b64
|
|
||||||
mime = "image/png"
|
|
||||||
return base64.b64decode(data), mime
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/attach", response_model=AttachResponse)
|
|
||||||
def attach_screenshot(payload: AttachRequest) -> AttachResponse:
|
|
||||||
"""Upload a screenshot to a Forgejo issue as a comment with embedded image.
|
|
||||||
|
|
||||||
The image is uploaded as an issue asset, then referenced in a comment
|
|
||||||
so it is visible inline when the issue is viewed.
|
|
||||||
"""
|
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=503, detail="Feedback not configured.")
|
|
||||||
|
|
||||||
raw_bytes, mime = _decode_image(payload.image_b64)
|
|
||||||
|
|
||||||
if len(raw_bytes) > _MAX_BYTES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=413,
|
|
||||||
detail=f"Screenshot exceeds 5 MB limit ({len(raw_bytes) // 1024} KB received).",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Upload image as issue asset
|
|
||||||
asset_resp = requests.post(
|
|
||||||
f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/assets",
|
|
||||||
headers=_forgejo_headers(),
|
|
||||||
files={"attachment": (payload.filename, raw_bytes, mime)},
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
if not asset_resp.ok:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail=f"Forgejo asset upload failed: {asset_resp.text[:200]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
asset_url = asset_resp.json().get("browser_download_url", "")
|
|
||||||
|
|
||||||
# Pin as a comment so the image is visible inline
|
|
||||||
comment_body = f"**Screenshot attached by reporter:**\n\n"
|
|
||||||
comment_resp = requests.post(
|
|
||||||
f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/comments",
|
|
||||||
headers={**_forgejo_headers(), "Content-Type": "application/json"},
|
|
||||||
json={"body": comment_body},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if not comment_resp.ok:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail=f"Forgejo comment failed: {comment_resp.text[:200]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
comment_url = comment_resp.json().get("html_url", "")
|
|
||||||
return AttachResponse(comment_url=comment_url)
|
|
||||||
|
|
@ -128,18 +128,15 @@ async def household_status(session: CloudUser = Depends(_require_premium)):
|
||||||
@router.post("/invite", response_model=HouseholdInviteResponse)
|
@router.post("/invite", response_model=HouseholdInviteResponse)
|
||||||
async def create_invite(session: CloudUser = Depends(_require_household_owner)):
|
async def create_invite(session: CloudUser = Depends(_require_household_owner)):
|
||||||
"""Generate a one-time invite token valid for 7 days."""
|
"""Generate a one-time invite token valid for 7 days."""
|
||||||
|
store = Store(session.db)
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=_INVITE_TTL_DAYS)).isoformat()
|
expires_at = (datetime.now(timezone.utc) + timedelta(days=_INVITE_TTL_DAYS)).isoformat()
|
||||||
store = Store(session.db)
|
store.conn.execute(
|
||||||
try:
|
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
||||||
store.conn.execute(
|
VALUES (?, ?, ?, ?)""",
|
||||||
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
(token, session.household_id, session.user_id, expires_at),
|
||||||
VALUES (?, ?, ?, ?)""",
|
)
|
||||||
(token, session.household_id, session.user_id, expires_at),
|
store.conn.commit()
|
||||||
)
|
|
||||||
store.conn.commit()
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
invite_url = f"{_KIWI_BASE_URL}/#/join?household_id={session.household_id}&token={token}"
|
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)
|
return HouseholdInviteResponse(token=token, invite_url=invite_url, expires_at=expires_at)
|
||||||
|
|
||||||
|
|
@ -155,27 +152,24 @@ async def accept_invite(
|
||||||
|
|
||||||
hh_store = _household_store(body.household_id)
|
hh_store = _household_store(body.household_id)
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
try:
|
row = hh_store.conn.execute(
|
||||||
row = hh_store.conn.execute(
|
"""SELECT token, expires_at, used_at FROM household_invites
|
||||||
"""SELECT token, expires_at, used_at FROM household_invites
|
WHERE token = ? AND household_id = ?""",
|
||||||
WHERE token = ? AND household_id = ?""",
|
(body.token, body.household_id),
|
||||||
(body.token, body.household_id),
|
).fetchone()
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Invite not found.")
|
raise HTTPException(status_code=404, detail="Invite not found.")
|
||||||
if row["used_at"] is not None:
|
if row["used_at"] is not None:
|
||||||
raise HTTPException(status_code=410, detail="Invite already used.")
|
raise HTTPException(status_code=410, detail="Invite already used.")
|
||||||
if row["expires_at"] < now:
|
if row["expires_at"] < now:
|
||||||
raise HTTPException(status_code=410, detail="Invite has expired.")
|
raise HTTPException(status_code=410, detail="Invite has expired.")
|
||||||
|
|
||||||
hh_store.conn.execute(
|
hh_store.conn.execute(
|
||||||
"UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?",
|
"UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?",
|
||||||
(now, session.user_id, body.token),
|
(now, session.user_id, body.token),
|
||||||
)
|
)
|
||||||
hh_store.conn.commit()
|
hh_store.conn.commit()
|
||||||
finally:
|
|
||||||
hh_store.close()
|
|
||||||
|
|
||||||
_heimdall_post("/admin/household/add-member", {
|
_heimdall_post("/admin/household/add-member", {
|
||||||
"household_id": body.household_id,
|
"household_id": body.household_id,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
@ -12,25 +11,18 @@ import aiofiles
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
|
||||||
|
|
||||||
_predictor = ExpirationPredictor()
|
|
||||||
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,
|
BulkAddByNameRequest,
|
||||||
BulkAddByNameResponse,
|
BulkAddByNameResponse,
|
||||||
BulkAddItemResult,
|
BulkAddItemResult,
|
||||||
DiscardRequest,
|
|
||||||
InventoryItemCreate,
|
InventoryItemCreate,
|
||||||
InventoryItemResponse,
|
InventoryItemResponse,
|
||||||
InventoryItemUpdate,
|
InventoryItemUpdate,
|
||||||
InventoryStats,
|
InventoryStats,
|
||||||
PartialConsumeRequest,
|
|
||||||
ProductCreate,
|
ProductCreate,
|
||||||
ProductResponse,
|
ProductResponse,
|
||||||
ProductUpdate,
|
ProductUpdate,
|
||||||
|
|
@ -41,34 +33,6 @@ from app.models.schemas.inventory import (
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _enrich_item(item: dict) -> dict:
|
|
||||||
"""Attach computed fields: opened_expiry_date, secondary_state/uses/warning."""
|
|
||||||
from datetime import date, timedelta
|
|
||||||
opened = item.get("opened_date")
|
|
||||||
if opened:
|
|
||||||
days = _predictor.days_after_opening(item.get("category"))
|
|
||||||
if days is not None:
|
|
||||||
try:
|
|
||||||
opened_expiry = date.fromisoformat(opened) + timedelta(days=days)
|
|
||||||
item = {**item, "opened_expiry_date": str(opened_expiry)}
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if "opened_expiry_date" not in item:
|
|
||||||
item = {**item, "opened_expiry_date": None}
|
|
||||||
|
|
||||||
# Secondary use window — check sell-by date (not opened expiry)
|
|
||||||
sec = _predictor.secondary_state(item.get("category"), item.get("expiration_date"))
|
|
||||||
item = {
|
|
||||||
**item,
|
|
||||||
"secondary_state": sec["label"] if sec else None,
|
|
||||||
"secondary_uses": sec["uses"] if sec else None,
|
|
||||||
"secondary_warning": sec["warning"] if sec else None,
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
# ── Products ──────────────────────────────────────────────────────────────────
|
# ── Products ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
|
@ -153,12 +117,7 @@ async def delete_product(product_id: int, store: Store = Depends(get_store)):
|
||||||
# ── Inventory items ───────────────────────────────────────────────────────────
|
# ── Inventory items ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_inventory_item(
|
async def create_inventory_item(body: InventoryItemCreate, store: Store = Depends(get_store)):
|
||||||
body: InventoryItemCreate,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
log.info("add_item auth=%s tier=%s product_id=%s", _auth_label(session.user_id), session.tier, body.product_id)
|
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
body.product_id,
|
body.product_id,
|
||||||
|
|
@ -171,10 +130,7 @@ async def create_inventory_item(
|
||||||
notes=body.notes,
|
notes=body.notes,
|
||||||
source=body.source,
|
source=body.source,
|
||||||
)
|
)
|
||||||
# RETURNING * omits joined columns (product_name, barcode, category).
|
return InventoryItemResponse.model_validate(item)
|
||||||
# Re-fetch with the products JOIN so the response is fully populated (#99).
|
|
||||||
full_item = await asyncio.to_thread(store.get_inventory_item, item["id"])
|
|
||||||
return InventoryItemResponse.model_validate(full_item)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
|
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
|
||||||
|
|
@ -187,7 +143,7 @@ async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depe
|
||||||
for entry in body.items:
|
for entry in body.items:
|
||||||
try:
|
try:
|
||||||
product, _ = await asyncio.to_thread(
|
product, _ = await asyncio.to_thread(
|
||||||
store.get_or_create_product, entry.name, None, source="manual"
|
store.get_or_create_product, entry.name, None, source="shopping"
|
||||||
)
|
)
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
|
|
@ -195,7 +151,7 @@ async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depe
|
||||||
entry.location,
|
entry.location,
|
||||||
quantity=entry.quantity,
|
quantity=entry.quantity,
|
||||||
unit=entry.unit,
|
unit=entry.unit,
|
||||||
source="manual",
|
source="shopping",
|
||||||
)
|
)
|
||||||
results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"]))
|
results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"]))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -212,13 +168,13 @@ async def list_inventory_items(
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
):
|
):
|
||||||
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
||||||
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
return [InventoryItemResponse.model_validate(i) for i in items]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
||||||
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
||||||
items = await asyncio.to_thread(store.expiring_soon, days)
|
items = await asyncio.to_thread(store.expiring_soon, days)
|
||||||
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
return [InventoryItemResponse.model_validate(i) for i in items]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
|
@ -226,7 +182,7 @@ async def get_inventory_item(item_id: int, store: Store = Depends(get_store)):
|
||||||
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
return InventoryItemResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
|
@ -238,79 +194,24 @@ async def update_inventory_item(
|
||||||
updates["purchase_date"] = str(updates["purchase_date"])
|
updates["purchase_date"] = str(updates["purchase_date"])
|
||||||
if "expiration_date" in updates and updates["expiration_date"]:
|
if "expiration_date" in updates and updates["expiration_date"]:
|
||||||
updates["expiration_date"] = str(updates["expiration_date"])
|
updates["expiration_date"] = str(updates["expiration_date"])
|
||||||
if "opened_date" in updates and updates["opened_date"]:
|
|
||||||
updates["opened_date"] = str(updates["opened_date"])
|
|
||||||
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
return InventoryItemResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
|
|
||||||
async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
|
|
||||||
"""Record that this item was opened today, triggering secondary shelf-life tracking."""
|
|
||||||
from datetime import date
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.update_inventory_item,
|
|
||||||
item_id,
|
|
||||||
opened_date=str(date.today()),
|
|
||||||
)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
||||||
async def consume_item(
|
async def consume_item(item_id: int, store: Store = Depends(get_store)):
|
||||||
item_id: int,
|
|
||||||
body: Optional[PartialConsumeRequest] = None,
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
"""Consume an inventory item fully or partially.
|
|
||||||
|
|
||||||
When body.quantity is provided, decrements by that amount and only marks
|
|
||||||
status=consumed when quantity reaches zero. Omit body to consume all.
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
if body is not None:
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.partial_consume_item, item_id, body.quantity, now
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
store.update_inventory_item,
|
|
||||||
item_id,
|
|
||||||
status="consumed",
|
|
||||||
consumed_at=now,
|
|
||||||
)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
|
|
||||||
async def discard_item(
|
|
||||||
item_id: int,
|
|
||||||
body: DiscardRequest = DiscardRequest(),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
):
|
|
||||||
"""Mark an item as discarded (not used, spoiled, etc).
|
|
||||||
|
|
||||||
Optional reason field accepts free text or a preset label
|
|
||||||
('not used', 'spoiled', 'excess', 'other').
|
|
||||||
"""
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
item = await asyncio.to_thread(
|
item = await asyncio.to_thread(
|
||||||
store.update_inventory_item,
|
store.update_inventory_item,
|
||||||
item_id,
|
item_id,
|
||||||
status="discarded",
|
status="consumed",
|
||||||
consumed_at=datetime.now(timezone.utc).isoformat(),
|
consumed_at=datetime.now(timezone.utc).isoformat(),
|
||||||
disposal_reason=body.reason,
|
|
||||||
)
|
)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
return InventoryItemResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
@ -340,7 +241,6 @@ async def scan_barcode_text(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
|
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
|
||||||
log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode)
|
|
||||||
from app.services.openfoodfacts import OpenFoodFactsService
|
from app.services.openfoodfacts import OpenFoodFactsService
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
|
||||||
|
|
@ -367,14 +267,10 @@ async def scan_barcode_text(
|
||||||
tier=session.tier,
|
tier=session.tier,
|
||||||
has_byok=session.has_byok,
|
has_byok=session.has_byok,
|
||||||
)
|
)
|
||||||
# Use OFFs pack size when detected; caller-supplied quantity is a fallback
|
|
||||||
resolved_qty = product_info.get("pack_quantity") or body.quantity
|
|
||||||
resolved_unit = product_info.get("pack_unit") or "count"
|
|
||||||
inventory_item = await asyncio.to_thread(
|
inventory_item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
product["id"], body.location,
|
product["id"], body.location,
|
||||||
quantity=resolved_qty,
|
quantity=body.quantity,
|
||||||
unit=resolved_unit,
|
|
||||||
expiration_date=str(exp) if exp else None,
|
expiration_date=str(exp) if exp else None,
|
||||||
source="barcode_scan",
|
source="barcode_scan",
|
||||||
)
|
)
|
||||||
|
|
@ -382,7 +278,6 @@ async def scan_barcode_text(
|
||||||
else:
|
else:
|
||||||
result_product = None
|
result_product = None
|
||||||
|
|
||||||
product_found = product_info is not None
|
|
||||||
return BarcodeScanResponse(
|
return BarcodeScanResponse(
|
||||||
success=True,
|
success=True,
|
||||||
barcodes_found=1,
|
barcodes_found=1,
|
||||||
|
|
@ -392,8 +287,7 @@ async def scan_barcode_text(
|
||||||
"product": result_product,
|
"product": result_product,
|
||||||
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
||||||
"added_to_inventory": inventory_item is not None,
|
"added_to_inventory": inventory_item is not None,
|
||||||
"needs_manual_entry": not product_found,
|
"message": "Added to inventory" if inventory_item else "Product not found in database",
|
||||||
"message": "Added to inventory" if inventory_item else "Not found in any product database — add manually",
|
|
||||||
}],
|
}],
|
||||||
message="Barcode processed",
|
message="Barcode processed",
|
||||||
)
|
)
|
||||||
|
|
@ -409,7 +303,6 @@ async def scan_barcode_image(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Scan a barcode from an uploaded image. Requires Phase 2 scanner integration."""
|
"""Scan a barcode from an uploaded image. Requires Phase 2 scanner integration."""
|
||||||
log.info("scan_image auth=%s tier=%s", _auth_label(session.user_id), session.tier)
|
|
||||||
temp_dir = Path("/tmp/kiwi_barcode_scans")
|
temp_dir = Path("/tmp/kiwi_barcode_scans")
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}"
|
temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}"
|
||||||
|
|
@ -452,13 +345,10 @@ async def scan_barcode_image(
|
||||||
tier=session.tier,
|
tier=session.tier,
|
||||||
has_byok=session.has_byok,
|
has_byok=session.has_byok,
|
||||||
)
|
)
|
||||||
resolved_qty = product_info.get("pack_quantity") or quantity
|
|
||||||
resolved_unit = product_info.get("pack_unit") or "count"
|
|
||||||
inventory_item = await asyncio.to_thread(
|
inventory_item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
product["id"], location,
|
product["id"], location,
|
||||||
quantity=resolved_qty,
|
quantity=quantity,
|
||||||
unit=resolved_unit,
|
|
||||||
expiration_date=str(exp) if exp else None,
|
expiration_date=str(exp) if exp else None,
|
||||||
source="barcode_scan",
|
source="barcode_scan",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ from app.models.schemas.meal_plan import (
|
||||||
PrepTaskSummary,
|
PrepTaskSummary,
|
||||||
ShoppingListResponse,
|
ShoppingListResponse,
|
||||||
SlotSummary,
|
SlotSummary,
|
||||||
UpdatePlanRequest,
|
|
||||||
UpdatePrepTaskRequest,
|
UpdatePrepTaskRequest,
|
||||||
UpsertSlotRequest,
|
UpsertSlotRequest,
|
||||||
VALID_MEAL_TYPES,
|
VALID_MEAL_TYPES,
|
||||||
|
|
@ -82,21 +81,13 @@ async def create_plan(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
) -> PlanSummary:
|
) -> PlanSummary:
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
# Free tier is locked to dinner-only; paid+ may configure meal types
|
# Free tier is locked to dinner-only; paid+ may configure meal types
|
||||||
if can_use("meal_plan_config", session.tier):
|
if can_use("meal_plan_config", session.tier):
|
||||||
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
||||||
else:
|
else:
|
||||||
meal_types = ["dinner"]
|
meal_types = ["dinner"]
|
||||||
|
|
||||||
try:
|
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
|
||||||
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=f"A meal plan for the week of {req.week_start} already exists.",
|
|
||||||
)
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
|
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
|
||||||
return _plan_summary(plan, slots)
|
return _plan_summary(plan, slots)
|
||||||
|
|
||||||
|
|
@ -114,28 +105,6 @@ async def list_plans(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{plan_id}", response_model=PlanSummary)
|
|
||||||
async def update_plan(
|
|
||||||
plan_id: int,
|
|
||||||
req: UpdatePlanRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
store: Store = Depends(get_store),
|
|
||||||
) -> PlanSummary:
|
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
# Free tier stays dinner-only; paid+ may add meal types
|
|
||||||
if can_use("meal_plan_config", session.tier):
|
|
||||||
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
|
||||||
else:
|
|
||||||
meal_types = ["dinner"]
|
|
||||||
updated = await asyncio.to_thread(store.update_meal_plan_types, plan_id, meal_types)
|
|
||||||
if updated is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
|
||||||
return _plan_summary(updated, slots)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plan_id}", response_model=PlanSummary)
|
@router.get("/{plan_id}", response_model=PlanSummary)
|
||||||
async def get_plan(
|
async def get_plan(
|
||||||
plan_id: int,
|
plan_id: int,
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ def _commit_items(
|
||||||
receipt_id=receipt_id,
|
receipt_id=receipt_id,
|
||||||
purchase_date=str(purchase_date) if purchase_date else None,
|
purchase_date=str(purchase_date) if purchase_date else None,
|
||||||
expiration_date=str(exp) if exp else None,
|
expiration_date=str(exp) if exp else None,
|
||||||
source="receipt",
|
source="receipt_ocr",
|
||||||
)
|
)
|
||||||
|
|
||||||
created.append(ApprovedInventoryItem(
|
created.append(ApprovedInventoryItem(
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,9 @@ async def upload_receipt(
|
||||||
)
|
)
|
||||||
# Only queue OCR if the feature is enabled server-side AND the user's tier allows it.
|
# Only queue OCR if the feature is enabled server-side AND the user's tier allows it.
|
||||||
# Check tier here, not inside the background task — once dispatched it can't be cancelled.
|
# Check tier here, not inside the background task — once dispatched it can't be cancelled.
|
||||||
# Pass session.db (a Path) rather than store — the store dependency closes before
|
|
||||||
# background tasks run, so the task opens its own store from the DB path.
|
|
||||||
ocr_allowed = settings.ENABLE_OCR and can_use("receipt_ocr", session.tier, session.has_byok)
|
ocr_allowed = settings.ENABLE_OCR and can_use("receipt_ocr", session.tier, session.has_byok)
|
||||||
if ocr_allowed:
|
if ocr_allowed:
|
||||||
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, store)
|
||||||
return ReceiptResponse.model_validate(receipt)
|
return ReceiptResponse.model_validate(receipt)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,7 +64,7 @@ async def upload_receipts_batch(
|
||||||
store.create_receipt, file.filename, str(saved)
|
store.create_receipt, file.filename, str(saved)
|
||||||
)
|
)
|
||||||
if ocr_allowed:
|
if ocr_allowed:
|
||||||
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, store)
|
||||||
results.append(ReceiptResponse.model_validate(receipt))
|
results.append(ReceiptResponse.model_validate(receipt))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -99,13 +97,8 @@ async def get_receipt_quality(receipt_id: int, store: Store = Depends(get_store)
|
||||||
return QualityAssessment.model_validate(qa)
|
return QualityAssessment.model_validate(qa)
|
||||||
|
|
||||||
|
|
||||||
async def _process_receipt_ocr(receipt_id: int, image_path: Path, db_path: Path) -> None:
|
async def _process_receipt_ocr(receipt_id: int, image_path: Path, store: Store) -> None:
|
||||||
"""Background task: run OCR pipeline on an uploaded receipt.
|
"""Background task: run OCR pipeline on an uploaded receipt."""
|
||||||
|
|
||||||
Accepts db_path (not a Store instance) because FastAPI closes the request-scoped
|
|
||||||
store before background tasks execute. This task owns its store lifecycle.
|
|
||||||
"""
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(store.update_receipt_status, receipt_id, "processing")
|
await asyncio.to_thread(store.update_receipt_status, receipt_id, "processing")
|
||||||
from app.services.receipt_service import ReceiptService
|
from app.services.receipt_service import ReceiptService
|
||||||
|
|
@ -115,5 +108,3 @@ async def _process_receipt_ocr(receipt_id: int, image_path: Path, db_path: Path)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
store.update_receipt_status, receipt_id, "error", str(exc)
|
store.update_receipt_status, receipt_id, "error", str(exc)
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.recipe import (
|
from app.models.schemas.recipe import (
|
||||||
AssemblyTemplateOut,
|
AssemblyTemplateOut,
|
||||||
BuildRequest,
|
BuildRequest,
|
||||||
RecipeJobStatus,
|
|
||||||
RecipeRequest,
|
RecipeRequest,
|
||||||
RecipeResult,
|
RecipeResult,
|
||||||
RecipeSuggestion,
|
RecipeSuggestion,
|
||||||
|
|
@ -29,12 +24,9 @@ from app.services.recipe.assembly_recipes import (
|
||||||
)
|
)
|
||||||
from app.services.recipe.browser_domains import (
|
from app.services.recipe.browser_domains import (
|
||||||
DOMAINS,
|
DOMAINS,
|
||||||
category_has_subcategories,
|
|
||||||
get_category_names,
|
get_category_names,
|
||||||
get_domain_labels,
|
get_domain_labels,
|
||||||
get_keywords_for_category,
|
get_keywords_for_category,
|
||||||
get_keywords_for_subcategory,
|
|
||||||
get_subcategory_names,
|
|
||||||
)
|
)
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
from app.services.recipe.recipe_engine import RecipeEngine
|
||||||
from app.services.heimdall_orch import check_orch_budget
|
from app.services.heimdall_orch import check_orch_budget
|
||||||
|
|
@ -58,55 +50,13 @@ def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
|
@router.post("/suggest", response_model=RecipeResult)
|
||||||
"""Queue an async recipe_llm job and return 202 with job_id.
|
|
||||||
|
|
||||||
Falls back to synchronous generation in CLOUD_MODE (scheduler polls only
|
|
||||||
the shared settings DB, not per-user DBs — see snipe#45 / kiwi backlog).
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from app.cloud_session import CLOUD_MODE
|
|
||||||
from app.tasks.runner import insert_task
|
|
||||||
|
|
||||||
if CLOUD_MODE:
|
|
||||||
log.warning("recipe_llm async jobs not supported in CLOUD_MODE — falling back to sync")
|
|
||||||
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
|
||||||
return result
|
|
||||||
|
|
||||||
job_id = f"rec_{uuid.uuid4().hex}"
|
|
||||||
|
|
||||||
def _create(db_path: Path) -> int:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
row = store.create_recipe_job(job_id, session.user_id, req.model_dump_json())
|
|
||||||
return row["id"]
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
int_id = await asyncio.to_thread(_create, session.db)
|
|
||||||
params_json = json.dumps({"job_id": job_id})
|
|
||||||
task_id, is_new = insert_task(session.db, "recipe_llm", int_id, params=params_json)
|
|
||||||
if is_new:
|
|
||||||
from app.tasks.scheduler import get_scheduler
|
|
||||||
get_scheduler(session.db).enqueue(task_id, "recipe_llm", int_id, params_json)
|
|
||||||
|
|
||||||
return JSONResponse(content={"job_id": job_id, "status": "queued"}, status_code=202)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suggest")
|
|
||||||
async def suggest_recipes(
|
async def suggest_recipes(
|
||||||
req: RecipeRequest,
|
req: RecipeRequest,
|
||||||
async_mode: bool = Query(default=False, alias="async"),
|
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
) -> RecipeResult:
|
||||||
):
|
|
||||||
log.info("recipes auth=%s tier=%s level=%s", _auth_label(session.user_id), session.tier, req.level)
|
|
||||||
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
||||||
# Also read stored unit_system preference; default to metric if not set.
|
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
|
||||||
unit_system = store.get_setting("unit_system") or "metric"
|
|
||||||
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok, "unit_system": unit_system})
|
|
||||||
if req.level == 4 and not req.wildcard_confirmed:
|
if req.level == 4 and not req.wildcard_confirmed:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
@ -134,49 +84,12 @@ async def suggest_recipes(
|
||||||
req = req.model_copy(update={"level": 2})
|
req = req.model_copy(update={"level": 2})
|
||||||
orch_fallback = True
|
orch_fallback = True
|
||||||
|
|
||||||
if req.level in (3, 4) and async_mode:
|
|
||||||
return await _enqueue_recipe_job(session, req)
|
|
||||||
|
|
||||||
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
||||||
if orch_fallback:
|
if orch_fallback:
|
||||||
result = result.model_copy(update={"orch_fallback": True})
|
result = result.model_copy(update={"orch_fallback": True})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/{job_id}", response_model=RecipeJobStatus)
|
|
||||||
async def get_recipe_job_status(
|
|
||||||
job_id: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> RecipeJobStatus:
|
|
||||||
"""Poll the status of an async recipe generation job.
|
|
||||||
|
|
||||||
Returns 404 when job_id is unknown or belongs to a different user.
|
|
||||||
On status='done' with suggestions=[], the LLM returned empty — client
|
|
||||||
should show a 'no recipe generated, try again' message.
|
|
||||||
"""
|
|
||||||
def _get(db_path: Path) -> dict | None:
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return store.get_recipe_job(job_id, session.user_id)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
row = await asyncio.to_thread(_get, session.db)
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Job not found.")
|
|
||||||
|
|
||||||
result = None
|
|
||||||
if row["status"] == "done" and row["result"]:
|
|
||||||
result = RecipeResult.model_validate_json(row["result"])
|
|
||||||
|
|
||||||
return RecipeJobStatus(
|
|
||||||
job_id=row["job_id"],
|
|
||||||
status=row["status"],
|
|
||||||
result=result,
|
|
||||||
error=row["error"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse/domains")
|
@router.get("/browse/domains")
|
||||||
async def list_browse_domains(
|
async def list_browse_domains(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
|
|
@ -194,42 +107,15 @@ async def list_browse_categories(
|
||||||
if domain not in DOMAINS:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
cat_names = get_category_names(domain)
|
keywords_by_category = {
|
||||||
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
|
cat: get_keywords_for_category(domain, cat)
|
||||||
has_subs = {cat: category_has_subcategories(domain, cat) for cat in cat_names}
|
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, has_subs)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_get, session.db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse/{domain}/{category}/subcategories")
|
|
||||||
async def list_browse_subcategories(
|
|
||||||
domain: str,
|
|
||||||
category: str,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Return [{subcategory, recipe_count}] for a category that supports subcategories."""
|
|
||||||
if domain not in DOMAINS:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
|
||||||
if not category_has_subcategories(domain, category):
|
|
||||||
return []
|
|
||||||
|
|
||||||
subcat_names = get_subcategory_names(domain, category)
|
|
||||||
keywords_by_subcat = {
|
|
||||||
sub: get_keywords_for_subcategory(domain, category, sub)
|
|
||||||
for sub in subcat_names
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get(db_path: Path) -> list[dict]:
|
def _get(db_path: Path) -> list[dict]:
|
||||||
store = Store(db_path)
|
store = Store(db_path)
|
||||||
try:
|
try:
|
||||||
return store.get_browser_subcategories(domain, keywords_by_subcat)
|
return store.get_browser_categories(domain, keywords_by_category)
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
@ -243,36 +129,22 @@ async def browse_recipes(
|
||||||
page: Annotated[int, Query(ge=1)] = 1,
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
pantry_items: Annotated[str | None, Query()] = None,
|
pantry_items: Annotated[str | None, Query()] = None,
|
||||||
subcategory: Annotated[str | None, Query()] = None,
|
|
||||||
q: Annotated[str | None, Query(max_length=200)] = None,
|
|
||||||
sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc)$")] = "default",
|
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a paginated list of recipes for a domain/category.
|
"""Return a paginated list of recipes for a domain/category.
|
||||||
|
|
||||||
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
Pass pantry_items as a comma-separated string to receive match_pct
|
||||||
Pass subcategory to narrow within a category that has subcategories.
|
badges on each result.
|
||||||
Pass q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc).
|
|
||||||
"""
|
"""
|
||||||
if domain not in DOMAINS:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
if category == "_all":
|
keywords = get_keywords_for_category(domain, category)
|
||||||
keywords = None # unfiltered browse
|
if not keywords:
|
||||||
elif subcategory:
|
raise HTTPException(
|
||||||
keywords = get_keywords_for_subcategory(domain, category, subcategory)
|
status_code=404,
|
||||||
if not keywords:
|
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
||||||
raise HTTPException(
|
)
|
||||||
status_code=404,
|
|
||||||
detail=f"Unknown subcategory '{subcategory}' in '{category}'.",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
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 = (
|
pantry_list = (
|
||||||
[p.strip() for p in pantry_items.split(",") if p.strip()]
|
[p.strip() for p in pantry_items.split(",") if p.strip()]
|
||||||
|
|
@ -288,8 +160,6 @@ async def browse_recipes(
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
pantry_items=pantry_list,
|
pantry_items=pantry_list,
|
||||||
q=q or None,
|
|
||||||
sort=sort,
|
|
||||||
)
|
)
|
||||||
store.log_browser_telemetry(
|
store.log_browser_telemetry(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,6 @@ async def list_saved_recipes(
|
||||||
async def list_collections(
|
async def list_collections(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> list[CollectionSummary]:
|
) -> list[CollectionSummary]:
|
||||||
if not can_use("recipe_collections", session.tier):
|
|
||||||
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
|
||||||
rows = await asyncio.to_thread(
|
rows = await asyncio.to_thread(
|
||||||
_in_thread, session.db, lambda s: s.get_collections()
|
_in_thread, session.db, lambda s: s.get_collections()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
"""Session bootstrap endpoint — called once per app load by the frontend.
|
|
||||||
|
|
||||||
Logs auth= + tier= for log-based analytics without client-side tracking.
|
|
||||||
See Circuit-Forge/kiwi#86.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bootstrap")
|
|
||||||
def session_bootstrap(session: CloudUser = Depends(get_session)) -> dict:
|
|
||||||
"""Record auth type and tier for log-based analytics.
|
|
||||||
|
|
||||||
Expected log output:
|
|
||||||
INFO:app.api.endpoints.session: session auth=authed tier=paid
|
|
||||||
INFO:app.api.endpoints.session: session auth=anon tier=free
|
|
||||||
|
|
||||||
E2E test sessions (E2E_TEST_USER_ID) are logged at DEBUG so they don't
|
|
||||||
pollute analytics counts while still being visible when DEBUG=true.
|
|
||||||
"""
|
|
||||||
is_test = bool(settings.E2E_TEST_USER_ID and session.user_id == settings.E2E_TEST_USER_ID)
|
|
||||||
logger = log.debug if is_test else log.info
|
|
||||||
logger("session auth=%s tier=%s%s", _auth_label(session.user_id), session.tier, " e2e=true" if is_test else "")
|
|
||||||
return {
|
|
||||||
"auth": _auth_label(session.user_id),
|
|
||||||
"tier": session.tier,
|
|
||||||
"has_byok": session.has_byok,
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,7 @@ from app.db.store import Store
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"})
|
_ALLOWED_KEYS = frozenset({"cooking_equipment"})
|
||||||
|
|
||||||
|
|
||||||
class SettingBody(BaseModel):
|
class SettingBody(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
"""Shopping list endpoints.
|
|
||||||
|
|
||||||
Free tier for all users (anonymous guests included — shopping list is the
|
|
||||||
primary affiliate revenue surface). Confirm-purchase action is also Free:
|
|
||||||
it moves a checked item into pantry inventory without a tier gate so the
|
|
||||||
flow works for anyone who signs up or browses without an account.
|
|
||||||
|
|
||||||
Routes:
|
|
||||||
GET /shopping — list items (with affiliate links)
|
|
||||||
POST /shopping — add item manually
|
|
||||||
PATCH /shopping/{id} — update (check/uncheck, rename, qty)
|
|
||||||
DELETE /shopping/{id} — remove single item
|
|
||||||
DELETE /shopping/checked — clear all checked items
|
|
||||||
DELETE /shopping/all — clear entire list
|
|
||||||
POST /shopping/from-recipe — bulk add gaps from a recipe
|
|
||||||
POST /shopping/{id}/confirm — confirm purchase → add to pantry inventory
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
|
||||||
from app.db.session import get_store
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.shopping import (
|
|
||||||
BulkAddFromRecipeRequest,
|
|
||||||
ConfirmPurchaseRequest,
|
|
||||||
ShoppingItemCreate,
|
|
||||||
ShoppingItemResponse,
|
|
||||||
ShoppingItemUpdate,
|
|
||||||
)
|
|
||||||
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _enrich(item: dict, builder: GroceryLinkBuilder) -> ShoppingItemResponse:
|
|
||||||
"""Attach live affiliate links to a raw store row."""
|
|
||||||
links = builder.build_links(item["name"])
|
|
||||||
return ShoppingItemResponse(
|
|
||||||
**{**item, "checked": bool(item.get("checked", 0))},
|
|
||||||
grocery_links=[{"ingredient": l.ingredient, "retailer": l.retailer, "url": l.url} for l in links],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _in_thread(db_path, fn):
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
return fn(store)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ── List ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ShoppingItemResponse])
|
|
||||||
async def list_shopping_items(
|
|
||||||
include_checked: bool = True,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
|
||||||
items = await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.list_shopping_items(include_checked)
|
|
||||||
)
|
|
||||||
return [_enrich(i, builder) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Add manually ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("", response_model=ShoppingItemResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_shopping_item(
|
|
||||||
body: ShoppingItemCreate,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
_in_thread,
|
|
||||||
session.db,
|
|
||||||
lambda s: s.add_shopping_item(
|
|
||||||
name=body.name,
|
|
||||||
quantity=body.quantity,
|
|
||||||
unit=body.unit,
|
|
||||||
category=body.category,
|
|
||||||
notes=body.notes,
|
|
||||||
source=body.source,
|
|
||||||
recipe_id=body.recipe_id,
|
|
||||||
sort_order=body.sort_order,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return _enrich(item, builder)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Bulk add from recipe ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/from-recipe", response_model=list[ShoppingItemResponse], status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_from_recipe(
|
|
||||||
body: BulkAddFromRecipeRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Add missing ingredients from a recipe to the shopping list.
|
|
||||||
|
|
||||||
Runs pantry gap analysis and adds only the items the user doesn't have
|
|
||||||
(unless include_covered=True). Skips duplicates already on the list.
|
|
||||||
"""
|
|
||||||
from app.services.meal_plan.shopping_list import compute_shopping_list
|
|
||||||
|
|
||||||
def _run(store: Store):
|
|
||||||
recipe = store.get_recipe(body.recipe_id)
|
|
||||||
if not recipe:
|
|
||||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
|
||||||
inventory = store.list_inventory()
|
|
||||||
gaps, covered = compute_shopping_list([recipe], inventory)
|
|
||||||
targets = (gaps + covered) if body.include_covered else gaps
|
|
||||||
|
|
||||||
# Avoid duplicates already on the list
|
|
||||||
existing = {i["name"].lower() for i in store.list_shopping_items()}
|
|
||||||
added = []
|
|
||||||
for gap in targets:
|
|
||||||
if gap.ingredient_name.lower() in existing:
|
|
||||||
continue
|
|
||||||
item = store.add_shopping_item(
|
|
||||||
name=gap.ingredient_name,
|
|
||||||
quantity=None,
|
|
||||||
unit=gap.have_unit,
|
|
||||||
source="recipe",
|
|
||||||
recipe_id=body.recipe_id,
|
|
||||||
)
|
|
||||||
added.append(item)
|
|
||||||
return added
|
|
||||||
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
|
||||||
items = await asyncio.to_thread(_in_thread, session.db, _run)
|
|
||||||
return [_enrich(i, builder) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Update ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.patch("/{item_id}", response_model=ShoppingItemResponse)
|
|
||||||
async def update_shopping_item(
|
|
||||||
item_id: int,
|
|
||||||
body: ShoppingItemUpdate,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
|
||||||
item = await asyncio.to_thread(
|
|
||||||
_in_thread,
|
|
||||||
session.db,
|
|
||||||
lambda s: s.update_shopping_item(item_id, **body.model_dump(exclude_none=True)),
|
|
||||||
)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(status_code=404, detail="Shopping item not found")
|
|
||||||
return _enrich(item, builder)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Confirm purchase → pantry ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/{item_id}/confirm", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def confirm_purchase(
|
|
||||||
item_id: int,
|
|
||||||
body: ConfirmPurchaseRequest,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Confirm a checked item was purchased and add it to pantry inventory.
|
|
||||||
|
|
||||||
Human approval step: the user explicitly confirms what they actually bought
|
|
||||||
before it lands in their pantry. Returns the new inventory item.
|
|
||||||
"""
|
|
||||||
def _run(store: Store):
|
|
||||||
shopping_item = store.get_shopping_item(item_id)
|
|
||||||
if not shopping_item:
|
|
||||||
raise HTTPException(status_code=404, detail="Shopping item not found")
|
|
||||||
|
|
||||||
qty = body.quantity if body.quantity is not None else (shopping_item.get("quantity") or 1.0)
|
|
||||||
unit = body.unit or shopping_item.get("unit") or "count"
|
|
||||||
category = shopping_item.get("category")
|
|
||||||
|
|
||||||
product = store.get_or_create_product(
|
|
||||||
name=shopping_item["name"],
|
|
||||||
category=category,
|
|
||||||
)
|
|
||||||
inv_item = store.add_inventory_item(
|
|
||||||
product_id=product["id"],
|
|
||||||
location=body.location,
|
|
||||||
quantity=qty,
|
|
||||||
unit=unit,
|
|
||||||
source="manual",
|
|
||||||
)
|
|
||||||
# Mark the shopping item checked and leave it for the user to clear
|
|
||||||
store.update_shopping_item(item_id, checked=True)
|
|
||||||
return inv_item
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_in_thread, session.db, _run)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Delete ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_shopping_item(
|
|
||||||
item_id: int,
|
|
||||||
session: CloudUser = Depends(get_session),
|
|
||||||
):
|
|
||||||
deleted = await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.delete_shopping_item(item_id)
|
|
||||||
)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="Shopping item not found")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/checked", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def clear_checked(session: CloudUser = Depends(get_session)):
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.clear_checked_shopping_items()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/all", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def clear_all(session: CloudUser = Depends(get_session)):
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_in_thread, session.db, lambda s: s.clear_all_shopping_items()
|
|
||||||
)
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate, meal_plans, orch_usage
|
||||||
from app.api.endpoints.community import router as community_router
|
from app.api.endpoints.community import router as community_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(session.router, prefix="/session", tags=["session"])
|
|
||||||
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"])
|
||||||
|
|
@ -14,11 +13,9 @@ api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=
|
||||||
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||||
api_router.include_router(feedback_attach.router, prefix="/feedback", tags=["feedback"])
|
|
||||||
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
||||||
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
|
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
|
||||||
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
|
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
|
||||||
api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"])
|
api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"])
|
||||||
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
|
||||||
api_router.include_router(community_router)
|
api_router.include_router(community_router)
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,10 @@ import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
import requests
|
import requests
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import Depends, HTTPException, Request, Response
|
from fastapi import Depends, HTTPException, Request
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -84,15 +82,6 @@ _TIER_CACHE_TTL = 300 # 5 minutes
|
||||||
TIERS = ["free", "paid", "premium", "ultra"]
|
TIERS = ["free", "paid", "premium", "ultra"]
|
||||||
|
|
||||||
|
|
||||||
def _auth_label(user_id: str) -> str:
|
|
||||||
"""Classify a user_id into a short tag for structured log lines. No PII emitted."""
|
|
||||||
if user_id in ("local", "local-dev"):
|
|
||||||
return "local"
|
|
||||||
if user_id.startswith("anon-"):
|
|
||||||
return "anon"
|
|
||||||
return "authed"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Domain ────────────────────────────────────────────────────────────────────
|
# ── Domain ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -183,13 +172,9 @@ def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _anon_guest_db_path(guest_id: str) -> Path:
|
def _anon_db_path() -> Path:
|
||||||
"""Per-session DB for unauthenticated guest visitors.
|
"""Ephemeral DB for unauthenticated guest visitors (Free tier, no persistence)."""
|
||||||
|
path = CLOUD_DATA_ROOT / "anonymous" / "kiwi.db"
|
||||||
Each anonymous visitor gets an isolated SQLite DB keyed by their guest UUID
|
|
||||||
cookie, so shopping lists and affiliate interactions never bleed across sessions.
|
|
||||||
"""
|
|
||||||
path = CLOUD_DATA_ROOT / f"anon-{guest_id}" / "kiwi.db"
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
@ -219,52 +204,20 @@ def _detect_byok(config_path: Path = _LLM_CONFIG_PATH) -> bool:
|
||||||
|
|
||||||
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
# ── FastAPI dependency ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_GUEST_COOKIE = "kiwi_guest_id"
|
def get_session(request: Request) -> CloudUser:
|
||||||
_GUEST_COOKIE_MAX_AGE = 60 * 60 * 24 * 90 # 90 days
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_guest_session(request: Request, response: Response, has_byok: bool) -> CloudUser:
|
|
||||||
"""Return a per-session anonymous CloudUser, creating a guest UUID cookie if needed."""
|
|
||||||
guest_id = request.cookies.get(_GUEST_COOKIE, "").strip()
|
|
||||||
is_new = not guest_id
|
|
||||||
if is_new:
|
|
||||||
guest_id = str(uuid.uuid4())
|
|
||||||
log.debug("New guest session assigned: anon-%s", guest_id[:8])
|
|
||||||
# Secure flag only when the request actually arrived over HTTPS
|
|
||||||
# (Caddy sets X-Forwarded-Proto=https in cloud; absent on direct port access).
|
|
||||||
# Avoids losing the session cookie on HTTP direct-port testing of the cloud stack.
|
|
||||||
is_https = request.headers.get("x-forwarded-proto", "http").lower() == "https"
|
|
||||||
response.set_cookie(
|
|
||||||
key=_GUEST_COOKIE,
|
|
||||||
value=guest_id,
|
|
||||||
max_age=_GUEST_COOKIE_MAX_AGE,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax",
|
|
||||||
secure=is_https,
|
|
||||||
)
|
|
||||||
return CloudUser(
|
|
||||||
user_id=f"anon-{guest_id}",
|
|
||||||
tier="free",
|
|
||||||
db=_anon_guest_db_path(guest_id),
|
|
||||||
has_byok=has_byok,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_session(request: Request, response: Response) -> CloudUser:
|
|
||||||
"""FastAPI dependency — resolves the current user from the request.
|
"""FastAPI dependency — resolves the current user from the request.
|
||||||
|
|
||||||
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,
|
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).
|
returns a "local" session without JWT validation (dev/LAN use only).
|
||||||
Anonymous: per-session UUID cookie isolates each guest visitor's data.
|
|
||||||
"""
|
"""
|
||||||
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 Caddy from the actual client address) over the
|
# 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).
|
# TCP peer address (which is nginx's container IP when behind the proxy).
|
||||||
client_ip = (
|
client_ip = (
|
||||||
request.headers.get("x-real-ip", "")
|
request.headers.get("x-real-ip", "")
|
||||||
|
|
@ -276,19 +229,26 @@ def get_session(request: Request, response: Response) -> CloudUser:
|
||||||
dev_db = _user_db_path("local-dev")
|
dev_db = _user_db_path("local-dev")
|
||||||
return CloudUser(user_id="local-dev", tier="local", db=dev_db, has_byok=has_byok)
|
return CloudUser(user_id="local-dev", tier="local", db=dev_db, has_byok=has_byok)
|
||||||
|
|
||||||
# Resolve cf_session JWT: prefer the explicit header injected by Caddy, then
|
raw_header = (
|
||||||
# fall back to the cf_session cookie value. Other cookies (e.g. kiwi_guest_id)
|
request.headers.get("x-cf-session", "")
|
||||||
# must never be treated as auth tokens.
|
or request.headers.get("cookie", "")
|
||||||
raw_session = request.headers.get("x-cf-session", "").strip()
|
)
|
||||||
if not raw_session:
|
if not raw_header:
|
||||||
raw_session = request.cookies.get("cf_session", "").strip()
|
return CloudUser(
|
||||||
|
user_id="anonymous",
|
||||||
|
tier="free",
|
||||||
|
db=_anon_db_path(),
|
||||||
|
has_byok=has_byok,
|
||||||
|
)
|
||||||
|
|
||||||
if not raw_session:
|
token = _extract_session_token(raw_header) # gitleaks:allow — function name, not a secret
|
||||||
return _resolve_guest_session(request, response, has_byok)
|
|
||||||
|
|
||||||
token = _extract_session_token(raw_session) # gitleaks:allow — function name, not a secret
|
|
||||||
if not token:
|
if not token:
|
||||||
return _resolve_guest_session(request, response, has_byok)
|
return CloudUser(
|
||||||
|
user_id="anonymous",
|
||||||
|
tier="free",
|
||||||
|
db=_anon_db_path(),
|
||||||
|
has_byok=has_byok,
|
||||||
|
)
|
||||||
|
|
||||||
user_id = validate_session_jwt(token)
|
user_id = validate_session_jwt(token)
|
||||||
_ensure_provisioned(user_id)
|
_ensure_provisioned(user_id)
|
||||||
|
|
|
||||||
|
|
@ -60,19 +60,8 @@ class Settings:
|
||||||
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
|
||||||
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
|
||||||
|
|
||||||
# E2E test account — analytics logging is suppressed for this user_id so test
|
|
||||||
# runs don't pollute session counts. Set to the Directus UUID of the test user.
|
|
||||||
E2E_TEST_USER_ID: str | None = os.environ.get("E2E_TEST_USER_ID") or None
|
|
||||||
|
|
||||||
# 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")
|
||||||
# Use OrchestratedScheduler (coordinator-aware, multi-GPU fan-out) instead of
|
|
||||||
# LocalScheduler. Defaults to true in CLOUD_MODE; can be set independently
|
|
||||||
# for multi-GPU local rigs that don't need full cloud auth.
|
|
||||||
USE_ORCH_SCHEDULER: bool | None = (
|
|
||||||
None if os.environ.get("USE_ORCH_SCHEDULER") is None
|
|
||||||
else os.environ.get("USE_ORCH_SCHEDULER", "").lower() in ("1", "true", "yes")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-- Migration 030: open-package tracking
|
|
||||||
-- Adds opened_date to track when a multi-use item was first opened,
|
|
||||||
-- enabling secondary shelf-life windows (e.g. salsa: 1 year sealed → 2 weeks opened).
|
|
||||||
|
|
||||||
ALTER TABLE inventory_items ADD COLUMN opened_date TEXT;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- Migration 031: add disposal_reason for waste logging (#60)
|
|
||||||
-- status='discarded' already exists in the CHECK constraint from migration 002.
|
|
||||||
-- This column stores free-text reason (optional) and calm-framing presets.
|
|
||||||
ALTER TABLE inventory_items ADD COLUMN disposal_reason TEXT;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- 032_meal_plan_unique_week.sql
|
|
||||||
-- Prevent duplicate plans for the same week.
|
|
||||||
-- Existing duplicates must be resolved before applying (keep MIN(id) per week_start).
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_meal_plans_week_start ON meal_plans (week_start);
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
-- Migration 033: standalone shopping list
|
|
||||||
-- Items can be added manually, from recipe gap analysis, or from the recipe browser.
|
|
||||||
-- Affiliate links are computed at query time by the API layer (never stored).
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS shopping_list_items (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
quantity REAL,
|
|
||||||
unit TEXT,
|
|
||||||
category TEXT,
|
|
||||||
checked INTEGER NOT NULL DEFAULT 0, -- 0=want, 1=in-cart/checked off
|
|
||||||
notes TEXT,
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual', -- manual | recipe | meal_plan
|
|
||||||
recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_list_checked
|
|
||||||
ON shopping_list_items (checked, sort_order);
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-- Migration 034: async recipe generation job queue
|
|
||||||
CREATE TABLE IF NOT EXISTS recipe_jobs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
job_id TEXT NOT NULL UNIQUE,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
request TEXT NOT NULL,
|
|
||||||
result TEXT,
|
|
||||||
error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_jobs_job_id ON recipe_jobs (job_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipe_jobs_user_id ON recipe_jobs (user_id, created_at DESC);
|
|
||||||
332
app/db/store.py
332
app/db/store.py
|
|
@ -23,25 +23,12 @@ _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:
|
||||||
import os
|
|
||||||
self._db_path = str(db_path)
|
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")
|
||||||
run_migrations(self.conn, MIGRATIONS_DIR)
|
run_migrations(self.conn, MIGRATIONS_DIR)
|
||||||
|
|
||||||
# When RECIPE_DB_PATH is set (cloud mode), attach the shared read-only
|
|
||||||
# corpus DB as the "corpus" schema so per-user DBs can access recipe data.
|
|
||||||
# _cp (corpus prefix) is "corpus." in cloud mode, "" in local mode.
|
|
||||||
corpus_path = os.environ.get("RECIPE_DB_PATH", "")
|
|
||||||
if corpus_path:
|
|
||||||
self.conn.execute("ATTACH DATABASE ? AS corpus", (corpus_path,))
|
|
||||||
self._cp = "corpus."
|
|
||||||
self._corpus_path = corpus_path
|
|
||||||
else:
|
|
||||||
self._cp = ""
|
|
||||||
self._corpus_path = self._db_path
|
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
|
@ -231,8 +218,7 @@ class Store:
|
||||||
|
|
||||||
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
|
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
|
||||||
allowed = {"quantity", "unit", "location", "sublocation",
|
allowed = {"quantity", "unit", "location", "sublocation",
|
||||||
"purchase_date", "expiration_date", "opened_date",
|
"expiration_date", "status", "notes", "consumed_at"}
|
||||||
"status", "notes", "consumed_at", "disposal_reason"}
|
|
||||||
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return self.get_inventory_item(item_id)
|
return self.get_inventory_item(item_id)
|
||||||
|
|
@ -245,32 +231,6 @@ class Store:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return self.get_inventory_item(item_id)
|
return self.get_inventory_item(item_id)
|
||||||
|
|
||||||
def partial_consume_item(
|
|
||||||
self,
|
|
||||||
item_id: int,
|
|
||||||
consume_qty: float,
|
|
||||||
consumed_at: str,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Decrement quantity by consume_qty. Mark consumed when quantity reaches 0."""
|
|
||||||
row = self.get_inventory_item(item_id)
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
remaining = max(0.0, round(row["quantity"] - consume_qty, 6))
|
|
||||||
if remaining <= 0:
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE inventory_items SET quantity = 0, status = 'consumed',"
|
|
||||||
" consumed_at = ?, updated_at = datetime('now') WHERE id = ?",
|
|
||||||
(consumed_at, item_id),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE inventory_items SET quantity = ?, updated_at = datetime('now')"
|
|
||||||
" WHERE id = ?",
|
|
||||||
(remaining, item_id),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return self.get_inventory_item(item_id)
|
|
||||||
|
|
||||||
def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]:
|
def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]:
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
"""SELECT i.*, p.name as product_name, p.category
|
"""SELECT i.*, p.name as product_name, p.category
|
||||||
|
|
@ -385,9 +345,8 @@ class Store:
|
||||||
|
|
||||||
def _fts_ready(self) -> bool:
|
def _fts_ready(self) -> bool:
|
||||||
"""Return True if the recipes_fts virtual table exists."""
|
"""Return True if the recipes_fts virtual table exists."""
|
||||||
schema = "corpus" if self._cp else "main"
|
|
||||||
row = self._fetch_one(
|
row = self._fetch_one(
|
||||||
f"SELECT 1 FROM {schema}.sqlite_master WHERE type='table' AND name='recipes_fts'"
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='recipes_fts'"
|
||||||
)
|
)
|
||||||
return row is not None
|
return row is not None
|
||||||
|
|
||||||
|
|
@ -678,12 +637,10 @@ class Store:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Pull up to 10× limit candidates so ranking has enough headroom.
|
# Pull up to 10× limit candidates so ranking has enough headroom.
|
||||||
# FTS5 pseudo-column in WHERE uses bare table name, not schema-qualified.
|
|
||||||
c = self._cp
|
|
||||||
sql = f"""
|
sql = f"""
|
||||||
SELECT r.*
|
SELECT r.*
|
||||||
FROM {c}recipes_fts
|
FROM recipes_fts
|
||||||
JOIN {c}recipes r ON r.id = {c}recipes_fts.rowid
|
JOIN recipes r ON r.id = recipes_fts.rowid
|
||||||
WHERE recipes_fts MATCH ?
|
WHERE recipes_fts MATCH ?
|
||||||
{where_extra}
|
{where_extra}
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
|
|
@ -717,10 +674,9 @@ class Store:
|
||||||
"CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END"
|
"CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END"
|
||||||
for _ in ingredient_names
|
for _ in ingredient_names
|
||||||
)
|
)
|
||||||
c = self._cp
|
|
||||||
sql = f"""
|
sql = f"""
|
||||||
SELECT r.*, ({match_score}) AS match_count
|
SELECT r.*, ({match_score}) AS match_count
|
||||||
FROM {c}recipes r
|
FROM recipes r
|
||||||
WHERE ({like_clauses})
|
WHERE ({like_clauses})
|
||||||
{where_extra}
|
{where_extra}
|
||||||
ORDER BY match_count DESC, r.id ASC
|
ORDER BY match_count DESC, r.id ASC
|
||||||
|
|
@ -730,46 +686,7 @@ class Store:
|
||||||
return self._fetch_all(sql, tuple(all_params))
|
return self._fetch_all(sql, tuple(all_params))
|
||||||
|
|
||||||
def get_recipe(self, recipe_id: int) -> dict | None:
|
def get_recipe(self, recipe_id: int) -> dict | None:
|
||||||
row = self._fetch_one(f"SELECT * FROM {self._cp}recipes WHERE id = ?", (recipe_id,))
|
return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
|
||||||
if row is None and self._cp:
|
|
||||||
# Fall back to user's own assembled recipes in main schema
|
|
||||||
row = self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
|
|
||||||
return row
|
|
||||||
|
|
||||||
# --- Async recipe jobs ---
|
|
||||||
|
|
||||||
def create_recipe_job(self, job_id: str, user_id: str, request_json: str) -> sqlite3.Row:
|
|
||||||
return self._insert_returning(
|
|
||||||
"INSERT INTO recipe_jobs (job_id, user_id, status, request) VALUES (?,?,?,?) RETURNING *",
|
|
||||||
(job_id, user_id, "queued", request_json),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_recipe_job(self, job_id: str, user_id: str) -> sqlite3.Row | None:
|
|
||||||
return self._fetch_one(
|
|
||||||
"SELECT * FROM recipe_jobs WHERE job_id=? AND user_id=?",
|
|
||||||
(job_id, user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_recipe_job_running(self, job_id: str) -> None:
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE recipe_jobs SET status='running', updated_at=datetime('now') WHERE job_id=?",
|
|
||||||
(job_id,),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def complete_recipe_job(self, job_id: str, result_json: str) -> None:
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE recipe_jobs SET status='done', result=?, updated_at=datetime('now') WHERE job_id=?",
|
|
||||||
(result_json, job_id),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def fail_recipe_job(self, job_id: str, error: str) -> None:
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE recipe_jobs SET status='failed', error=?, updated_at=datetime('now') WHERE job_id=?",
|
|
||||||
(error, job_id),
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def upsert_built_recipe(
|
def upsert_built_recipe(
|
||||||
self,
|
self,
|
||||||
|
|
@ -820,7 +737,7 @@ class Store:
|
||||||
return {}
|
return {}
|
||||||
placeholders = ",".join("?" * len(names))
|
placeholders = ",".join("?" * len(names))
|
||||||
rows = self._fetch_all(
|
rows = self._fetch_all(
|
||||||
f"SELECT name, elements FROM {self._cp}ingredient_profiles WHERE name IN ({placeholders})",
|
f"SELECT name, elements FROM ingredient_profiles WHERE name IN ({placeholders})",
|
||||||
tuple(names),
|
tuple(names),
|
||||||
)
|
)
|
||||||
result: dict[str, list[str]] = {}
|
result: dict[str, list[str]] = {}
|
||||||
|
|
@ -961,25 +878,12 @@ class Store:
|
||||||
"title": "r.title ASC",
|
"title": "r.title ASC",
|
||||||
}.get(sort_by, "sr.saved_at DESC")
|
}.get(sort_by, "sr.saved_at DESC")
|
||||||
|
|
||||||
c = self._cp
|
|
||||||
# In corpus-attached (cloud) mode: try corpus recipes first, fall back
|
|
||||||
# to user's own assembled recipes. In local mode: single join suffices.
|
|
||||||
if c:
|
|
||||||
recipe_join = (
|
|
||||||
f"LEFT JOIN {c}recipes rc ON rc.id = sr.recipe_id "
|
|
||||||
"LEFT JOIN recipes rm ON rm.id = sr.recipe_id"
|
|
||||||
)
|
|
||||||
title_col = "COALESCE(rc.title, rm.title) AS title"
|
|
||||||
else:
|
|
||||||
recipe_join = "JOIN recipes rc ON rc.id = sr.recipe_id"
|
|
||||||
title_col = "rc.title"
|
|
||||||
|
|
||||||
if collection_id is not None:
|
if collection_id is not None:
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
f"""
|
f"""
|
||||||
SELECT sr.*, {title_col}
|
SELECT sr.*, r.title
|
||||||
FROM saved_recipes sr
|
FROM saved_recipes sr
|
||||||
{recipe_join}
|
JOIN recipes r ON r.id = sr.recipe_id
|
||||||
JOIN recipe_collection_members rcm ON rcm.saved_recipe_id = sr.id
|
JOIN recipe_collection_members rcm ON rcm.saved_recipe_id = sr.id
|
||||||
WHERE rcm.collection_id = ?
|
WHERE rcm.collection_id = ?
|
||||||
ORDER BY {order}
|
ORDER BY {order}
|
||||||
|
|
@ -988,9 +892,9 @@ class Store:
|
||||||
)
|
)
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
f"""
|
f"""
|
||||||
SELECT sr.*, {title_col}
|
SELECT sr.*, r.title
|
||||||
FROM saved_recipes sr
|
FROM saved_recipes sr
|
||||||
{recipe_join}
|
JOIN recipes r ON r.id = sr.recipe_id
|
||||||
ORDER BY {order}
|
ORDER BY {order}
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
@ -1005,26 +909,10 @@ class Store:
|
||||||
# ── recipe collections ────────────────────────────────────────────────
|
# ── recipe collections ────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_collection(self, name: str, description: str | None) -> dict:
|
def create_collection(self, name: str, description: str | None) -> dict:
|
||||||
# INSERT RETURNING * omits aggregate columns (e.g. member_count); re-query
|
return self._insert_returning(
|
||||||
# with the same SELECT used by get_collections() so the response shape is consistent.
|
"INSERT INTO recipe_collections (name, description) VALUES (?, ?) RETURNING *",
|
||||||
cur = self.conn.execute(
|
|
||||||
"INSERT INTO recipe_collections (name, description) VALUES (?, ?)",
|
|
||||||
(name, description),
|
(name, description),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
|
||||||
new_id = cur.lastrowid
|
|
||||||
row = self._fetch_one(
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
WHERE rc.id = ?
|
|
||||||
GROUP BY rc.id
|
|
||||||
""",
|
|
||||||
(new_id,),
|
|
||||||
)
|
|
||||||
return row # type: ignore[return-value]
|
|
||||||
|
|
||||||
def delete_collection(self, collection_id: int) -> None:
|
def delete_collection(self, collection_id: int) -> None:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
|
|
@ -1086,38 +974,17 @@ class Store:
|
||||||
# ── recipe browser ────────────────────────────────────────────────────
|
# ── recipe browser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_browser_categories(
|
def get_browser_categories(
|
||||||
self,
|
self, domain: str, keywords_by_category: dict[str, list[str]]
|
||||||
domain: str,
|
|
||||||
keywords_by_category: dict[str, list[str]],
|
|
||||||
has_subcategories_by_category: dict[str, bool] | None = None,
|
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return [{category, recipe_count, has_subcategories}] for each category.
|
"""Return [{category, recipe_count}] for each category in the domain.
|
||||||
|
|
||||||
keywords_by_category maps category name → keyword list for counting.
|
keywords_by_category maps category name to the keyword list used to
|
||||||
has_subcategories_by_category maps category name → bool (optional;
|
match against recipes.category and recipes.keywords.
|
||||||
defaults to False for all categories when omitted).
|
|
||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
for category, keywords in keywords_by_category.items():
|
for category, keywords in keywords_by_category.items():
|
||||||
count = self._count_recipes_for_keywords(keywords)
|
count = self._count_recipes_for_keywords(keywords)
|
||||||
results.append({
|
results.append({"category": category, "recipe_count": count})
|
||||||
"category": category,
|
|
||||||
"recipe_count": count,
|
|
||||||
"has_subcategories": (has_subcategories_by_category or {}).get(category, False),
|
|
||||||
})
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_browser_subcategories(
|
|
||||||
self, domain: str, keywords_by_subcategory: dict[str, list[str]]
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Return [{subcategory, recipe_count}] for each subcategory.
|
|
||||||
|
|
||||||
Mirrors get_browser_categories but for the second level.
|
|
||||||
"""
|
|
||||||
results = []
|
|
||||||
for subcat, keywords in keywords_by_subcategory.items():
|
|
||||||
count = self._count_recipes_for_keywords(keywords)
|
|
||||||
results.append({"subcategory": subcat, "recipe_count": count})
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -1129,16 +996,12 @@ class Store:
|
||||||
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
|
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
|
||||||
if not keywords:
|
if not keywords:
|
||||||
return 0
|
return 0
|
||||||
# Use corpus path as cache key so all cloud users share the same counts.
|
cache_key = (self._db_path, *sorted(keywords))
|
||||||
cache_key = (self._corpus_path, *sorted(keywords))
|
|
||||||
if cache_key in _COUNT_CACHE:
|
if cache_key in _COUNT_CACHE:
|
||||||
return _COUNT_CACHE[cache_key]
|
return _COUNT_CACHE[cache_key]
|
||||||
match_expr = self._browser_fts_query(keywords)
|
match_expr = self._browser_fts_query(keywords)
|
||||||
c = self._cp
|
|
||||||
# FTS5 pseudo-column in WHERE is always the bare (unqualified) table name,
|
|
||||||
# even when the table is accessed through an ATTACHed schema.
|
|
||||||
row = self.conn.execute(
|
row = self.conn.execute(
|
||||||
f"SELECT count(*) FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
|
"SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
|
||||||
(match_expr,),
|
(match_expr,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
count = row[0] if row else 0
|
count = row[0] if row else 0
|
||||||
|
|
@ -1147,76 +1010,41 @@ class Store:
|
||||||
|
|
||||||
def browse_recipes(
|
def browse_recipes(
|
||||||
self,
|
self,
|
||||||
keywords: list[str] | None,
|
keywords: list[str],
|
||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
pantry_items: list[str] | None = None,
|
pantry_items: list[str] | None = None,
|
||||||
q: str | None = None,
|
|
||||||
sort: str = "default",
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a page of recipes matching the keyword set.
|
"""Return a page of recipes matching the keyword set.
|
||||||
|
|
||||||
Pass keywords=None to browse all recipes without category filtering.
|
|
||||||
Each recipe row includes match_pct (float | None) when pantry_items
|
Each recipe row includes match_pct (float | None) when pantry_items
|
||||||
is provided. match_pct is the fraction of ingredient_names covered by
|
is provided. match_pct is the fraction of ingredient_names covered by
|
||||||
the pantry set — computed deterministically, no LLM needed.
|
the pantry set — computed deterministically, no LLM needed.
|
||||||
|
|
||||||
q: optional title substring filter (case-insensitive LIKE).
|
|
||||||
sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A).
|
|
||||||
"""
|
"""
|
||||||
if keywords is not None and not keywords:
|
if not keywords:
|
||||||
return {"recipes": [], "total": 0, "page": page}
|
return {"recipes": [], "total": 0, "page": page}
|
||||||
|
|
||||||
|
match_expr = self._browser_fts_query(keywords)
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
c = self._cp
|
|
||||||
|
|
||||||
order_clause = {
|
# Reuse cached count — avoids a second index scan on every page turn.
|
||||||
"alpha": "ORDER BY title ASC",
|
total = self._count_recipes_for_keywords(keywords)
|
||||||
"alpha_desc": "ORDER BY title DESC",
|
|
||||||
}.get(sort, "ORDER BY id ASC")
|
|
||||||
|
|
||||||
q_param = f"%{q.strip()}%" if q and q.strip() else None
|
rows = self._fetch_all(
|
||||||
cols = (
|
"""
|
||||||
f"SELECT id, title, category, keywords, ingredient_names,"
|
SELECT id, title, category, keywords, ingredient_names,
|
||||||
f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes"
|
calories, fat_g, protein_g, sodium_mg
|
||||||
|
FROM recipes
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT rowid FROM recipe_browser_fts
|
||||||
|
WHERE recipe_browser_fts MATCH ?
|
||||||
|
)
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(match_expr, page_size, offset),
|
||||||
)
|
)
|
||||||
|
|
||||||
if keywords is None:
|
|
||||||
if q_param:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE LOWER(title) LIKE LOWER(?)",
|
|
||||||
(q_param,),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} WHERE LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(q_param, page_size, offset),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(page_size, offset),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
match_expr = self._browser_fts_query(keywords)
|
|
||||||
fts_sub = f"id IN (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)"
|
|
||||||
if q_param:
|
|
||||||
total = self.conn.execute(
|
|
||||||
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?)",
|
|
||||||
(match_expr, q_param),
|
|
||||||
).fetchone()[0]
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(match_expr, q_param, page_size, offset),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Reuse cached count — avoids a second index scan on every page turn.
|
|
||||||
total = self._count_recipes_for_keywords(keywords)
|
|
||||||
rows = self._fetch_all(
|
|
||||||
f"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
|
|
||||||
(match_expr, page_size, offset),
|
|
||||||
)
|
|
||||||
|
|
||||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||||
recipes = []
|
recipes = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|
@ -1264,12 +1092,6 @@ class Store:
|
||||||
def get_meal_plan(self, plan_id: int) -> dict | None:
|
def get_meal_plan(self, plan_id: int) -> dict | None:
|
||||||
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
|
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
|
||||||
|
|
||||||
def update_meal_plan_types(self, plan_id: int, meal_types: list[str]) -> dict | None:
|
|
||||||
return self._fetch_one(
|
|
||||||
"UPDATE meal_plans SET meal_types = ? WHERE id = ? RETURNING *",
|
|
||||||
(json.dumps(meal_types), plan_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_meal_plans(self) -> list[dict]:
|
def list_meal_plans(self) -> list[dict]:
|
||||||
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
|
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
|
||||||
|
|
||||||
|
|
@ -1299,11 +1121,10 @@ class Store:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_plan_slots(self, plan_id: int) -> list[dict]:
|
def get_plan_slots(self, plan_id: int) -> list[dict]:
|
||||||
c = self._cp
|
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
f"""SELECT s.*, r.title AS recipe_title
|
"""SELECT s.*, r.name AS recipe_title
|
||||||
FROM meal_plan_slots s
|
FROM meal_plan_slots s
|
||||||
LEFT JOIN {c}recipes r ON r.id = s.recipe_id
|
LEFT JOIN recipes r ON r.id = s.recipe_id
|
||||||
WHERE s.plan_id = ?
|
WHERE s.plan_id = ?
|
||||||
ORDER BY s.day_of_week, s.meal_type""",
|
ORDER BY s.day_of_week, s.meal_type""",
|
||||||
(plan_id,),
|
(plan_id,),
|
||||||
|
|
@ -1311,11 +1132,10 @@ class Store:
|
||||||
|
|
||||||
def get_plan_recipes(self, plan_id: int) -> list[dict]:
|
def get_plan_recipes(self, plan_id: int) -> list[dict]:
|
||||||
"""Return full recipe rows for all recipes assigned to a plan."""
|
"""Return full recipe rows for all recipes assigned to a plan."""
|
||||||
c = self._cp
|
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
f"""SELECT DISTINCT r.*
|
"""SELECT DISTINCT r.*
|
||||||
FROM meal_plan_slots s
|
FROM meal_plan_slots s
|
||||||
JOIN {c}recipes r ON r.id = s.recipe_id
|
JOIN recipes r ON r.id = s.recipe_id
|
||||||
WHERE s.plan_id = ? AND s.recipe_id IS NOT NULL""",
|
WHERE s.plan_id = ? AND s.recipe_id IS NOT NULL""",
|
||||||
(plan_id,),
|
(plan_id,),
|
||||||
)
|
)
|
||||||
|
|
@ -1403,71 +1223,3 @@ class Store:
|
||||||
(pseudonym, directus_user_id),
|
(pseudonym, directus_user_id),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
# ── Shopping list ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def add_shopping_item(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
quantity: float | None = None,
|
|
||||||
unit: str | None = None,
|
|
||||||
category: str | None = None,
|
|
||||||
notes: str | None = None,
|
|
||||||
source: str = "manual",
|
|
||||||
recipe_id: int | None = None,
|
|
||||||
sort_order: int = 0,
|
|
||||||
) -> dict:
|
|
||||||
return self._insert_returning(
|
|
||||||
"""INSERT INTO shopping_list_items
|
|
||||||
(name, quantity, unit, category, notes, source, recipe_id, sort_order)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *""",
|
|
||||||
(name, quantity, unit, category, notes, source, recipe_id, sort_order),
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_shopping_items(self, include_checked: bool = True) -> list[dict]:
|
|
||||||
where = "" if include_checked else "WHERE checked = 0"
|
|
||||||
self.conn.row_factory = sqlite3.Row
|
|
||||||
rows = self.conn.execute(
|
|
||||||
f"SELECT * FROM shopping_list_items {where} ORDER BY checked, sort_order, id",
|
|
||||||
).fetchall()
|
|
||||||
return [self._row_to_dict(r) for r in rows]
|
|
||||||
|
|
||||||
def get_shopping_item(self, item_id: int) -> dict | None:
|
|
||||||
self.conn.row_factory = sqlite3.Row
|
|
||||||
row = self.conn.execute(
|
|
||||||
"SELECT * FROM shopping_list_items WHERE id = ?", (item_id,)
|
|
||||||
).fetchone()
|
|
||||||
return self._row_to_dict(row) if row else None
|
|
||||||
|
|
||||||
def update_shopping_item(self, item_id: int, **kwargs) -> dict | None:
|
|
||||||
allowed = {"name", "quantity", "unit", "category", "checked", "notes", "sort_order"}
|
|
||||||
fields = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
|
||||||
if not fields:
|
|
||||||
return self.get_shopping_item(item_id)
|
|
||||||
if "checked" in fields:
|
|
||||||
fields["checked"] = 1 if fields["checked"] else 0
|
|
||||||
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
|
||||||
values = list(fields.values()) + [item_id]
|
|
||||||
self.conn.execute(
|
|
||||||
f"UPDATE shopping_list_items SET {set_clause}, updated_at = datetime('now') WHERE id = ?",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return self.get_shopping_item(item_id)
|
|
||||||
|
|
||||||
def delete_shopping_item(self, item_id: int) -> bool:
|
|
||||||
cur = self.conn.execute(
|
|
||||||
"DELETE FROM shopping_list_items WHERE id = ?", (item_id,)
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return cur.rowcount > 0
|
|
||||||
|
|
||||||
def clear_checked_shopping_items(self) -> int:
|
|
||||||
cur = self.conn.execute("DELETE FROM shopping_list_items WHERE checked = 1")
|
|
||||||
self.conn.commit()
|
|
||||||
return cur.rowcount
|
|
||||||
|
|
||||||
def clear_all_shopping_items(self) -> int:
|
|
||||||
cur = self.conn.execute("DELETE FROM shopping_list_items")
|
|
||||||
self.conn.commit()
|
|
||||||
return cur.rowcount
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ from app.api.routes import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.meal_plan.affiliates import register_kiwi_programs
|
from app.services.meal_plan.affiliates import register_kiwi_programs
|
||||||
|
|
||||||
# Structured key=value log lines — grep/awk-friendly for log-based analytics.
|
|
||||||
# Without basicConfig, app-level INFO logs are silently dropped.
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,20 +89,9 @@ class InventoryItemUpdate(BaseModel):
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
sublocation: Optional[str] = None
|
sublocation: Optional[str] = None
|
||||||
purchase_date: Optional[date] = None
|
|
||||||
expiration_date: Optional[date] = None
|
expiration_date: Optional[date] = None
|
||||||
opened_date: Optional[date] = None
|
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
disposal_reason: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PartialConsumeRequest(BaseModel):
|
|
||||||
quantity: float = Field(..., gt=0, description="Amount to consume from this item")
|
|
||||||
|
|
||||||
|
|
||||||
class DiscardRequest(BaseModel):
|
|
||||||
reason: Optional[str] = Field(None, max_length=200)
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryItemResponse(BaseModel):
|
class InventoryItemResponse(BaseModel):
|
||||||
|
|
@ -117,14 +106,8 @@ class InventoryItemResponse(BaseModel):
|
||||||
sublocation: Optional[str]
|
sublocation: Optional[str]
|
||||||
purchase_date: Optional[str]
|
purchase_date: Optional[str]
|
||||||
expiration_date: Optional[str]
|
expiration_date: Optional[str]
|
||||||
opened_date: Optional[str] = None
|
|
||||||
opened_expiry_date: Optional[str] = None
|
|
||||||
secondary_state: Optional[str] = None
|
|
||||||
secondary_uses: Optional[List[str]] = None
|
|
||||||
secondary_warning: Optional[str] = None
|
|
||||||
status: str
|
status: str
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
disposal_reason: Optional[str] = None
|
|
||||||
source: str
|
source: str
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
@ -140,7 +123,6 @@ class BarcodeScanResult(BaseModel):
|
||||||
product: Optional[ProductResponse]
|
product: Optional[ProductResponse]
|
||||||
inventory_item: Optional[InventoryItemResponse]
|
inventory_item: Optional[InventoryItemResponse]
|
||||||
added_to_inventory: bool
|
added_to_inventory: bool
|
||||||
needs_manual_entry: bool = False
|
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,6 @@ class CreatePlanRequest(BaseModel):
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class UpdatePlanRequest(BaseModel):
|
|
||||||
meal_types: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class UpsertSlotRequest(BaseModel):
|
class UpsertSlotRequest(BaseModel):
|
||||||
recipe_id: int | None = None
|
recipe_id: int | None = None
|
||||||
servings: float = Field(2.0, gt=0)
|
servings: float = Field(2.0, gt=0)
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,6 @@ class RecipeSuggestion(BaseModel):
|
||||||
is_wildcard: bool = False
|
is_wildcard: bool = False
|
||||||
nutrition: NutritionPanel | None = None
|
nutrition: NutritionPanel | None = None
|
||||||
source_url: str | None = None
|
source_url: str | None = None
|
||||||
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
|
|
||||||
estimated_time_min: int | None = None # derived from step count + method signals
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryLink(BaseModel):
|
class GroceryLink(BaseModel):
|
||||||
|
|
@ -61,18 +59,6 @@ class RecipeResult(BaseModel):
|
||||||
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
|
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
|
||||||
|
|
||||||
|
|
||||||
class RecipeJobQueued(BaseModel):
|
|
||||||
job_id: str
|
|
||||||
status: str = "queued"
|
|
||||||
|
|
||||||
|
|
||||||
class RecipeJobStatus(BaseModel):
|
|
||||||
job_id: str
|
|
||||||
status: str
|
|
||||||
result: RecipeResult | None = None
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class NutritionFilters(BaseModel):
|
class NutritionFilters(BaseModel):
|
||||||
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
|
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
|
||||||
max_calories: float | None = None
|
max_calories: float | None = None
|
||||||
|
|
@ -83,10 +69,6 @@ class NutritionFilters(BaseModel):
|
||||||
|
|
||||||
class RecipeRequest(BaseModel):
|
class RecipeRequest(BaseModel):
|
||||||
pantry_items: list[str]
|
pantry_items: list[str]
|
||||||
# Maps product name → secondary state label for items past nominal expiry
|
|
||||||
# but still within their secondary use window (e.g. {"Bread": "stale"}).
|
|
||||||
# Used by the recipe engine to boost recipes suited to those specific states.
|
|
||||||
secondary_pantry_items: dict[str, str] = Field(default_factory=dict)
|
|
||||||
level: int = Field(default=1, ge=1, le=4)
|
level: int = Field(default=1, ge=1, le=4)
|
||||||
constraints: list[str] = Field(default_factory=list)
|
constraints: list[str] = Field(default_factory=list)
|
||||||
expiry_first: bool = False
|
expiry_first: bool = False
|
||||||
|
|
@ -101,10 +83,6 @@ class RecipeRequest(BaseModel):
|
||||||
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
||||||
excluded_ids: list[int] = Field(default_factory=list)
|
excluded_ids: list[int] = Field(default_factory=list)
|
||||||
shopping_mode: bool = False
|
shopping_mode: bool = False
|
||||||
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
|
||||||
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
|
||||||
max_time_min: int | None = None # filter by estimated cooking time ceiling
|
|
||||||
unit_system: str = "metric" # "metric" | "imperial"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Build Your Own schemas ──────────────────────────────────────────────────
|
# ── Build Your Own schemas ──────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"""Pydantic schemas for the shopping list endpoints."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItemCreate(BaseModel):
|
|
||||||
name: str = Field(..., min_length=1, max_length=200)
|
|
||||||
quantity: Optional[float] = None
|
|
||||||
unit: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
source: str = "manual"
|
|
||||||
recipe_id: Optional[int] = None
|
|
||||||
sort_order: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItemUpdate(BaseModel):
|
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
|
||||||
quantity: Optional[float] = None
|
|
||||||
unit: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
checked: Optional[bool] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryLinkOut(BaseModel):
|
|
||||||
ingredient: str
|
|
||||||
retailer: str
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItemResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
quantity: Optional[float]
|
|
||||||
unit: Optional[str]
|
|
||||||
category: Optional[str]
|
|
||||||
checked: bool
|
|
||||||
notes: Optional[str]
|
|
||||||
source: str
|
|
||||||
recipe_id: Optional[int]
|
|
||||||
sort_order: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
grocery_links: list[GroceryLinkOut] = []
|
|
||||||
|
|
||||||
|
|
||||||
class BulkAddFromRecipeRequest(BaseModel):
|
|
||||||
recipe_id: int
|
|
||||||
include_covered: bool = False # if True, add pantry-covered items too
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmPurchaseRequest(BaseModel):
|
|
||||||
"""Move a checked item into pantry inventory."""
|
|
||||||
location: str = "pantry"
|
|
||||||
quantity: Optional[float] = None # override the list quantity
|
|
||||||
unit: Optional[str] = None
|
|
||||||
|
|
@ -116,140 +116,6 @@ class ExpirationPredictor:
|
||||||
'prepared_foods': {'fridge': 4, 'freezer': 90},
|
'prepared_foods': {'fridge': 4, 'freezer': 90},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Secondary shelf life in days after a package is opened.
|
|
||||||
# Sources: USDA FoodKeeper app, FDA consumer guides.
|
|
||||||
# Only categories where opening significantly shortens shelf life are listed.
|
|
||||||
# Items not listed default to None (no secondary window tracked).
|
|
||||||
SHELF_LIFE_AFTER_OPENING: dict[str, int] = {
|
|
||||||
# Dairy — once opened, clock ticks fast
|
|
||||||
'dairy': 5,
|
|
||||||
'milk': 5,
|
|
||||||
'cream': 3,
|
|
||||||
'yogurt': 7,
|
|
||||||
'cheese': 14,
|
|
||||||
'butter': 30,
|
|
||||||
# Condiments — refrigerated after opening
|
|
||||||
'condiments': 30,
|
|
||||||
'ketchup': 30,
|
|
||||||
'mustard': 30,
|
|
||||||
'mayo': 14,
|
|
||||||
'salad_dressing': 30,
|
|
||||||
'soy_sauce': 90,
|
|
||||||
# Canned goods — once opened, very short
|
|
||||||
'canned_goods': 4,
|
|
||||||
# Beverages
|
|
||||||
'juice': 7,
|
|
||||||
'soda': 4,
|
|
||||||
# Bread / Bakery
|
|
||||||
'bread': 5,
|
|
||||||
'bakery': 3,
|
|
||||||
# Produce
|
|
||||||
'leafy_greens': 3,
|
|
||||||
'berries': 3,
|
|
||||||
# Pantry staples (open bag)
|
|
||||||
'chips': 14,
|
|
||||||
'cookies': 14,
|
|
||||||
'cereal': 30,
|
|
||||||
'flour': 90,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Post-expiry secondary use window.
|
|
||||||
# These are NOT spoilage extensions — they describe a qualitative state
|
|
||||||
# change where the ingredient is specifically suited for certain preparations.
|
|
||||||
# Sources: USDA FoodKeeper, food science, culinary tradition.
|
|
||||||
SECONDARY_WINDOW: dict[str, dict] = {
|
|
||||||
'bread': {
|
|
||||||
'window_days': 5,
|
|
||||||
'label': 'stale',
|
|
||||||
'uses': ['croutons', 'stuffing', 'bread pudding', 'French toast', 'panzanella'],
|
|
||||||
'warning': 'Check for mold before use — discard if any is visible.',
|
|
||||||
},
|
|
||||||
'bakery': {
|
|
||||||
'window_days': 3,
|
|
||||||
'label': 'day-old',
|
|
||||||
'uses': ['French toast', 'bread pudding', 'crumbles'],
|
|
||||||
'warning': 'Check for mold before use — discard if any is visible.',
|
|
||||||
},
|
|
||||||
'bananas': {
|
|
||||||
'window_days': 5,
|
|
||||||
'label': 'overripe',
|
|
||||||
'uses': ['banana bread', 'smoothies', 'pancakes', 'muffins'],
|
|
||||||
'warning': None,
|
|
||||||
},
|
|
||||||
'milk': {
|
|
||||||
'window_days': 3,
|
|
||||||
'label': 'sour',
|
|
||||||
'uses': ['pancakes', 'quick breads', 'baking', 'sauces'],
|
|
||||||
'warning': 'Use only in cooked recipes — do not drink.',
|
|
||||||
},
|
|
||||||
'dairy': {
|
|
||||||
'window_days': 2,
|
|
||||||
'label': 'sour',
|
|
||||||
'uses': ['pancakes', 'quick breads', 'baking'],
|
|
||||||
'warning': 'Use only in cooked recipes — do not drink.',
|
|
||||||
},
|
|
||||||
'cheese': {
|
|
||||||
'window_days': 14,
|
|
||||||
'label': 'well-aged',
|
|
||||||
'uses': ['broth', 'soups', 'risotto', 'gratins'],
|
|
||||||
'warning': None,
|
|
||||||
},
|
|
||||||
'rice': {
|
|
||||||
'window_days': 2,
|
|
||||||
'label': 'day-old',
|
|
||||||
'uses': ['fried rice', 'rice bowls', 'rice porridge'],
|
|
||||||
'warning': 'Refrigerate immediately after cooking — do not leave at room temp.',
|
|
||||||
},
|
|
||||||
'tortillas': {
|
|
||||||
'window_days': 5,
|
|
||||||
'label': 'stale',
|
|
||||||
'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'],
|
|
||||||
'warning': None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def days_after_opening(self, category: str | None) -> int | None:
|
|
||||||
"""Return days of shelf life remaining once a package is opened.
|
|
||||||
|
|
||||||
Returns None if the category is unknown or not tracked after opening
|
|
||||||
(e.g. frozen items, raw meat — category check irrelevant once opened).
|
|
||||||
"""
|
|
||||||
if not category:
|
|
||||||
return None
|
|
||||||
return self.SHELF_LIFE_AFTER_OPENING.get(category.lower())
|
|
||||||
|
|
||||||
def secondary_state(
|
|
||||||
self, category: str | None, expiry_date: str | None
|
|
||||||
) -> dict | None:
|
|
||||||
"""Return secondary use info if the item is in its post-expiry secondary window.
|
|
||||||
|
|
||||||
Returns a dict with label, uses, warning, days_past, and window_days when the
|
|
||||||
item is past its nominal expiry date but still within the secondary use window.
|
|
||||||
Returns None in all other cases (unknown category, no window defined, not yet
|
|
||||||
expired, or past the secondary window).
|
|
||||||
"""
|
|
||||||
if not category or not expiry_date:
|
|
||||||
return None
|
|
||||||
entry = self.SECONDARY_WINDOW.get(category.lower())
|
|
||||||
if not entry:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
from datetime import date
|
|
||||||
today = date.today()
|
|
||||||
exp = date.fromisoformat(expiry_date)
|
|
||||||
days_past = (today - exp).days
|
|
||||||
if 0 <= days_past <= entry['window_days']:
|
|
||||||
return {
|
|
||||||
'label': entry['label'],
|
|
||||||
'uses': list(entry['uses']),
|
|
||||||
'warning': entry['warning'],
|
|
||||||
'days_past': days_past,
|
|
||||||
'window_days': entry['window_days'],
|
|
||||||
}
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Keyword lists are checked in declaration order — most specific first.
|
# Keyword lists are checked in declaration order — most specific first.
|
||||||
# Rules:
|
# Rules:
|
||||||
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
||||||
|
|
|
||||||
|
|
@ -15,73 +15,64 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class OpenFoodFactsService:
|
class OpenFoodFactsService:
|
||||||
"""
|
"""
|
||||||
Service for interacting with the Open*Facts family of databases.
|
Service for interacting with the OpenFoodFacts API.
|
||||||
|
|
||||||
Primary: OpenFoodFacts (food products).
|
OpenFoodFacts is a free, open database of food products with
|
||||||
Fallback chain: Open Beauty Facts (personal care) → Open Products Facts (household).
|
ingredients, allergens, and nutrition facts.
|
||||||
All three databases share the same API path and JSON format.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BASE_URL = "https://world.openfoodfacts.org/api/v2"
|
BASE_URL = "https://world.openfoodfacts.org/api/v2"
|
||||||
USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)"
|
USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)"
|
||||||
|
|
||||||
# Fallback databases tried in order when OFFs returns no match.
|
|
||||||
# Same API format as OFFs — only the host differs.
|
|
||||||
_FALLBACK_DATABASES = [
|
|
||||||
"https://world.openbeautyfacts.org/api/v2",
|
|
||||||
"https://world.openproductsfacts.org/api/v2",
|
|
||||||
]
|
|
||||||
|
|
||||||
async def _lookup_in_database(
|
|
||||||
self, barcode: str, base_url: str, client: httpx.AsyncClient
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Try one Open*Facts database using an existing client. Returns parsed product dict or None."""
|
|
||||||
try:
|
|
||||||
response = await client.get(
|
|
||||||
f"{base_url}/product/{barcode}.json",
|
|
||||||
headers={"User-Agent": self.USER_AGENT},
|
|
||||||
timeout=10.0,
|
|
||||||
)
|
|
||||||
if response.status_code == 404:
|
|
||||||
return None
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
if data.get("status") != 1:
|
|
||||||
return None
|
|
||||||
return self._parse_product_data(data, barcode)
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
logger.debug("HTTP error for %s at %s: %s", barcode, base_url, e)
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Lookup failed for %s at %s: %s", barcode, base_url, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def lookup_product(self, barcode: str) -> Optional[Dict[str, Any]]:
|
async def lookup_product(self, barcode: str) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Look up a product by barcode, trying OFFs then fallback databases.
|
Look up a product by barcode in the OpenFoodFacts database.
|
||||||
|
|
||||||
A single httpx.AsyncClient is created for the whole lookup chain so that
|
|
||||||
connection pooling and TLS session reuse apply across all database attempts.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
barcode: UPC/EAN barcode (8-13 digits)
|
barcode: UPC/EAN barcode (8-13 digits)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with product information, or None if not found in any database.
|
Dictionary with product information, or None if not found
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
{
|
||||||
|
"name": "Organic Milk",
|
||||||
|
"brand": "Horizon",
|
||||||
|
"categories": ["Dairy", "Milk"],
|
||||||
|
"image_url": "https://...",
|
||||||
|
"nutrition_data": {...},
|
||||||
|
"raw_data": {...} # Full API response
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
async with httpx.AsyncClient() as client:
|
try:
|
||||||
result = await self._lookup_in_database(barcode, self.BASE_URL, client)
|
async with httpx.AsyncClient() as client:
|
||||||
if result:
|
url = f"{self.BASE_URL}/product/{barcode}.json"
|
||||||
return result
|
|
||||||
|
|
||||||
for db_url in self._FALLBACK_DATABASES:
|
response = await client.get(
|
||||||
result = await self._lookup_in_database(barcode, db_url, client)
|
url,
|
||||||
if result:
|
headers={"User-Agent": self.USER_AGENT},
|
||||||
logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
|
timeout=10.0,
|
||||||
return result
|
)
|
||||||
|
|
||||||
logger.info("Barcode %s not found in any Open*Facts database", barcode)
|
if response.status_code == 404:
|
||||||
return None
|
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("status") != 1:
|
||||||
|
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._parse_product_data(data, barcode)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"HTTP error looking up barcode {barcode}: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error looking up barcode {barcode}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]:
|
def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -123,9 +114,6 @@ class OpenFoodFactsService:
|
||||||
allergens = product.get("allergens_tags", [])
|
allergens = product.get("allergens_tags", [])
|
||||||
labels = product.get("labels_tags", [])
|
labels = product.get("labels_tags", [])
|
||||||
|
|
||||||
# Pack size detection: prefer explicit unit_count, fall back to serving count
|
|
||||||
pack_quantity, pack_unit = self._extract_pack_size(product)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"brand": brand,
|
"brand": brand,
|
||||||
|
|
@ -136,47 +124,9 @@ class OpenFoodFactsService:
|
||||||
"nutrition_data": nutrition_data,
|
"nutrition_data": nutrition_data,
|
||||||
"allergens": allergens,
|
"allergens": allergens,
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
"pack_quantity": pack_quantity,
|
|
||||||
"pack_unit": pack_unit,
|
|
||||||
"raw_data": product, # Store full response for debugging
|
"raw_data": product, # Store full response for debugging
|
||||||
}
|
}
|
||||||
|
|
||||||
def _extract_pack_size(self, product: Dict[str, Any]) -> tuple[float | None, str | None]:
|
|
||||||
"""Return (quantity, unit) for multi-pack products, or (None, None).
|
|
||||||
|
|
||||||
OFFs fields tried in order:
|
|
||||||
1. `number_of_units` (explicit count, highest confidence)
|
|
||||||
2. `serving_quantity` + `product_quantity_unit` (e.g. 6 x 150g yoghurt)
|
|
||||||
3. Parse `quantity` string like "4 x 113 g" or "6 pack"
|
|
||||||
|
|
||||||
Returns None, None when data is absent, ambiguous, or single-unit.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Field 1: explicit unit count
|
|
||||||
unit_count = product.get("number_of_units")
|
|
||||||
if unit_count:
|
|
||||||
try:
|
|
||||||
n = float(unit_count)
|
|
||||||
if n > 1:
|
|
||||||
return n, product.get("serving_size_unit") or "unit"
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Field 2: parse quantity string for "N x ..." pattern
|
|
||||||
qty_str = product.get("quantity", "")
|
|
||||||
if qty_str:
|
|
||||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*[xX×]\s*", qty_str.strip())
|
|
||||||
if m:
|
|
||||||
n = float(m.group(1))
|
|
||||||
if n > 1:
|
|
||||||
# Try to get a sensible sub-unit label from the rest
|
|
||||||
rest = qty_str[m.end():].strip()
|
|
||||||
unit_label = re.sub(r"[\d.,\s]+", "", rest).strip()[:20] or "unit"
|
|
||||||
return n, unit_label
|
|
||||||
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]:
|
def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extract nutrition facts from product data.
|
Extract nutrition facts from product data.
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,6 @@ Each domain provides a two-level category hierarchy for browsing the recipe corp
|
||||||
Keyword matching is case-insensitive against the recipes.category column and the
|
Keyword matching is case-insensitive against the recipes.category column and the
|
||||||
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
||||||
|
|
||||||
Category values are either:
|
|
||||||
- list[str] — flat keyword list (no subcategories)
|
|
||||||
- dict — {"keywords": list[str], "subcategories": {name: list[str]}}
|
|
||||||
keywords covers the whole category (used for "All X" browse);
|
|
||||||
subcategories each have their own narrower keyword list.
|
|
||||||
|
|
||||||
These are starter mappings based on the food.com dataset structure. Run:
|
These are starter mappings based on the food.com dataset structure. Run:
|
||||||
|
|
||||||
SELECT category, count(*) FROM recipes
|
SELECT category, count(*) FROM recipes
|
||||||
|
|
@ -25,467 +19,26 @@ DOMAINS: dict[str, dict] = {
|
||||||
"cuisine": {
|
"cuisine": {
|
||||||
"label": "Cuisine",
|
"label": "Cuisine",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Italian": {
|
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||||
"keywords": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
|
||||||
"subcategories": {
|
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
|
||||||
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
|
||||||
"involtini", "cannoli"],
|
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
|
||||||
"Neapolitan": ["neapolitan", "naples", "pizza napoletana",
|
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
|
||||||
"sfogliatelle", "ragù"],
|
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
|
||||||
"Tuscan": ["tuscan", "tuscany", "ribollita", "bistecca",
|
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
|
||||||
"pappardelle", "crostini"],
|
|
||||||
"Roman": ["roman", "rome", "cacio e pepe", "carbonara",
|
|
||||||
"amatriciana", "gricia", "supplì"],
|
|
||||||
"Venetian": ["venetian", "venice", "risotto", "bigoli",
|
|
||||||
"baccalà", "sarde in saor"],
|
|
||||||
"Ligurian": ["ligurian", "liguria", "pesto", "focaccia",
|
|
||||||
"trofie", "farinata"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Mexican": {
|
|
||||||
"keywords": ["mexican", "taco", "enchilada", "burrito", "salsa",
|
|
||||||
"guacamole", "mole", "tamale"],
|
|
||||||
"subcategories": {
|
|
||||||
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
|
|
||||||
"chapulines", "mezcal", "tasajo", "memelas"],
|
|
||||||
"Yucatecan": ["yucatecan", "yucatan", "cochinita pibil", "poc chuc",
|
|
||||||
"sopa de lima", "panuchos", "papadzules"],
|
|
||||||
"Veracruz": ["veracruz", "veracruzana", "huachinango",
|
|
||||||
"picadas", "enfrijoladas", "caldo de mariscos"],
|
|
||||||
"Street Food": ["taco", "elote", "tlacoyos", "torta", "tamale",
|
|
||||||
"quesadilla", "tostada", "sope", "gordita"],
|
|
||||||
"Mole": ["mole", "mole negro", "mole rojo", "mole verde",
|
|
||||||
"mole poblano", "mole amarillo", "pipián"],
|
|
||||||
"Baja / Cal-Mex": ["baja", "baja california", "cal-mex", "baja fish taco",
|
|
||||||
"fish taco", "carne asada fries", "california burrito",
|
|
||||||
"birria", "birria tacos", "quesabirria",
|
|
||||||
"lobster puerto nuevo", "tijuana", "ensenada",
|
|
||||||
"agua fresca", "caesar salad tijuana"],
|
|
||||||
"Mexico City": ["mexico city", "chilaquiles", "tlayuda cdmx",
|
|
||||||
"tacos de canasta", "torta ahogada", "pozole",
|
|
||||||
"chiles en nogada"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Asian": {
|
|
||||||
"keywords": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
|
||||||
"stir fry", "stir-fry", "ramen", "sushi", "malaysian",
|
|
||||||
"taiwanese", "singaporean", "burmese", "cambodian",
|
|
||||||
"laotian", "mongolian", "hong kong"],
|
|
||||||
"subcategories": {
|
|
||||||
"Korean": ["korean", "kimchi", "bibimbap", "bulgogi", "japchae",
|
|
||||||
"doenjang", "gochujang", "tteokbokki", "sundubu",
|
|
||||||
"galbi", "jjigae", "kbbq", "korean fried chicken"],
|
|
||||||
"Japanese": ["japanese", "sushi", "ramen", "tempura", "miso",
|
|
||||||
"teriyaki", "udon", "soba", "bento", "yakitori",
|
|
||||||
"tonkatsu", "onigiri", "okonomiyaki", "takoyaki",
|
|
||||||
"kaiseki", "izakaya"],
|
|
||||||
"Chinese": ["chinese", "dim sum", "fried rice", "dumplings", "wonton",
|
|
||||||
"spring roll", "szechuan", "sichuan", "cantonese",
|
|
||||||
"chow mein", "mapo tofu", "lo mein", "hot pot",
|
|
||||||
"peking duck", "char siu", "congee"],
|
|
||||||
"Thai": ["thai", "pad thai", "green curry", "red curry",
|
|
||||||
"coconut milk", "lemongrass", "satay", "tom yum",
|
|
||||||
"larb", "khao man gai", "massaman", "pad see ew"],
|
|
||||||
"Vietnamese": ["vietnamese", "pho", "banh mi", "spring rolls",
|
|
||||||
"vermicelli", "nuoc cham", "bun bo hue",
|
|
||||||
"banh xeo", "com tam", "bun cha"],
|
|
||||||
"Filipino": ["filipino", "adobo", "sinigang", "pancit", "lumpia",
|
|
||||||
"kare-kare", "lechon", "sisig", "halo-halo",
|
|
||||||
"dinuguan", "tinola", "bistek"],
|
|
||||||
"Indonesian": ["indonesian", "rendang", "nasi goreng", "gado-gado",
|
|
||||||
"tempeh", "sambal", "soto", "opor ayam",
|
|
||||||
"bakso", "mie goreng", "nasi uduk"],
|
|
||||||
"Malaysian": ["malaysian", "laksa", "nasi lemak", "char kway teow",
|
|
||||||
"satay malaysia", "roti canai", "bak kut teh",
|
|
||||||
"cendol", "mee goreng mamak", "curry laksa"],
|
|
||||||
"Taiwanese": ["taiwanese", "beef noodle soup", "lu rou fan",
|
|
||||||
"oyster vermicelli", "scallion pancake taiwan",
|
|
||||||
"pork chop rice", "three cup chicken",
|
|
||||||
"bubble tea", "stinky tofu", "ba wan"],
|
|
||||||
"Singaporean": ["singaporean", "chicken rice", "chili crab",
|
|
||||||
"singaporean laksa", "bak chor mee", "rojak",
|
|
||||||
"kaya toast", "nasi padang", "satay singapore"],
|
|
||||||
"Burmese": ["burmese", "myanmar", "mohinga", "laphet thoke",
|
|
||||||
"tea leaf salad", "ohn no khao swe",
|
|
||||||
"mont di", "nangyi thoke"],
|
|
||||||
"Hong Kong": ["hong kong", "hk style", "pineapple bun",
|
|
||||||
"wonton noodle soup", "hk milk tea", "egg tart",
|
|
||||||
"typhoon shelter crab", "char siu bao", "jook",
|
|
||||||
"congee hk", "silk stocking tea", "dan tat",
|
|
||||||
"siu mai hk", "cheung fun"],
|
|
||||||
"Cambodian": ["cambodian", "khmer", "amok", "lok lak",
|
|
||||||
"kuy teav", "bai sach chrouk", "nom banh chok",
|
|
||||||
"samlor korko", "beef loc lac"],
|
|
||||||
"Laotian": ["laotian", "lao", "larb", "tam mak hoong",
|
|
||||||
"or lam", "khao niaw", "ping kai",
|
|
||||||
"naem khao", "khao piak sen", "mok pa"],
|
|
||||||
"Mongolian": ["mongolian", "buuz", "khuushuur", "tsuivan",
|
|
||||||
"boodog", "airag", "khorkhog", "bansh",
|
|
||||||
"guriltai shol", "suutei tsai"],
|
|
||||||
"South Asian Fusion": ["south asian fusion", "indo-chinese",
|
|
||||||
"hakka chinese", "chilli chicken",
|
|
||||||
"manchurian", "schezwan"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Indian": {
|
|
||||||
"keywords": ["indian", "curry", "lentil", "dal", "tikka", "masala",
|
|
||||||
"biryani", "naan", "chutney", "pakistani", "sri lankan",
|
|
||||||
"bangladeshi", "nepali"],
|
|
||||||
"subcategories": {
|
|
||||||
"North Indian": ["north indian", "punjabi", "mughal", "tikka masala",
|
|
||||||
"naan", "tandoori", "butter chicken", "palak paneer",
|
|
||||||
"chole", "rajma", "aloo gobi"],
|
|
||||||
"South Indian": ["south indian", "tamil", "kerala", "dosa", "idli",
|
|
||||||
"sambar", "rasam", "coconut chutney", "appam",
|
|
||||||
"fish curry kerala", "puttu", "payasam"],
|
|
||||||
"Bengali": ["bengali", "mustard fish", "hilsa", "shorshe ilish",
|
|
||||||
"mishti doi", "rasgulla", "kosha mangsho"],
|
|
||||||
"Gujarati": ["gujarati", "dhokla", "thepla", "undhiyu",
|
|
||||||
"khandvi", "fafda", "gujarati dal"],
|
|
||||||
"Pakistani": ["pakistani", "nihari", "haleem", "seekh kebab",
|
|
||||||
"karahi", "biryani karachi", "chapli kebab",
|
|
||||||
"halwa puri", "paya"],
|
|
||||||
"Sri Lankan": ["sri lankan", "kottu roti", "hoppers", "pol sambol",
|
|
||||||
"sri lankan curry", "lamprais", "string hoppers",
|
|
||||||
"wambatu moju"],
|
|
||||||
"Bangladeshi": ["bangladeshi", "bangladesh", "dhaka biryani",
|
|
||||||
"shutki", "pitha", "hilsa curry", "kacchi biryani",
|
|
||||||
"bhuna khichuri", "doi maach", "rezala"],
|
|
||||||
"Nepali": ["nepali", "dal bhat", "momos", "sekuwa",
|
|
||||||
"sel roti", "gundruk", "thukpa"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Mediterranean": {
|
|
||||||
"keywords": ["mediterranean", "greek", "middle eastern", "turkish",
|
|
||||||
"lebanese", "jewish", "palestinian", "yemeni", "egyptian",
|
|
||||||
"syrian", "iraqi", "jordanian"],
|
|
||||||
"subcategories": {
|
|
||||||
"Greek": ["greek", "feta", "tzatziki", "moussaka", "spanakopita",
|
|
||||||
"souvlaki", "dolmades", "spanakopita", "tiropita",
|
|
||||||
"galaktoboureko"],
|
|
||||||
"Turkish": ["turkish", "kebab", "borek", "meze", "baklava",
|
|
||||||
"lahmacun", "menemen", "pide", "iskender",
|
|
||||||
"kisir", "simit"],
|
|
||||||
"Syrian": ["syrian", "fattet hummus", "kibbeh syria",
|
|
||||||
"muhammara", "maklouba syria", "sfeeha",
|
|
||||||
"halawet el jibn"],
|
|
||||||
"Lebanese": ["lebanese", "middle eastern", "hummus", "falafel",
|
|
||||||
"tabbouleh", "kibbeh", "fattoush", "manakish",
|
|
||||||
"kafta", "sfiha"],
|
|
||||||
"Jewish": ["jewish", "israeli", "ashkenazi", "sephardic",
|
|
||||||
"shakshuka", "sabich", "za'atar", "tahini",
|
|
||||||
"zhug", "zhoug", "s'khug", "z'houg",
|
|
||||||
"hawaiij", "hawaij", "hawayej",
|
|
||||||
"matzo", "latke", "rugelach", "babka", "challah",
|
|
||||||
"cholent", "gefilte fish", "brisket", "kugel",
|
|
||||||
"new york jewish", "new york deli", "pastrami",
|
|
||||||
"knish", "lox", "bagel and lox", "jewish deli"],
|
|
||||||
"Palestinian": ["palestinian", "musakhan", "maqluba", "knafeh",
|
|
||||||
"maftoul", "freekeh", "sumac chicken"],
|
|
||||||
"Yemeni": ["yemeni", "saltah", "lahoh", "bint al-sahn",
|
|
||||||
"zhug", "zhoug", "hulba", "fahsa",
|
|
||||||
"hawaiij", "hawaij", "hawayej"],
|
|
||||||
"Egyptian": ["egyptian", "koshari", "molokhia", "mahshi",
|
|
||||||
"ful medames", "ta'ameya", "feteer meshaltet"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"American": {
|
|
||||||
"keywords": ["american", "southern", "comfort food", "cajun", "creole",
|
|
||||||
"hawaiian", "tex-mex", "soul food"],
|
|
||||||
"subcategories": {
|
|
||||||
"Southern": ["southern", "soul food", "fried chicken",
|
|
||||||
"collard greens", "cornbread", "biscuits and gravy",
|
|
||||||
"mac and cheese", "sweet potato pie", "okra"],
|
|
||||||
"Cajun/Creole": ["cajun", "creole", "new orleans", "gumbo",
|
|
||||||
"jambalaya", "etouffee", "dirty rice", "po'boy",
|
|
||||||
"muffuletta", "red beans and rice"],
|
|
||||||
"Tex-Mex": ["tex-mex", "southwestern", "chili", "fajita",
|
|
||||||
"queso", "breakfast taco", "chile con carne"],
|
|
||||||
"New England": ["new england", "chowder", "lobster", "clam",
|
|
||||||
"maple", "yankee", "boston baked beans",
|
|
||||||
"johnnycake", "fish and chips"],
|
|
||||||
"Pacific Northwest": ["pacific northwest", "pnw", "dungeness crab",
|
|
||||||
"salmon", "cedar plank", "razor clam",
|
|
||||||
"geoduck", "chanterelle", "marionberry"],
|
|
||||||
"Hawaiian": ["hawaiian", "hawaii", "plate lunch", "loco moco",
|
|
||||||
"poke", "spam musubi", "kalua pig", "lau lau",
|
|
||||||
"haupia", "poi", "manapua", "garlic shrimp",
|
|
||||||
"saimin", "huli huli", "malasada"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"BBQ & Smoke": {
|
|
||||||
"keywords": ["bbq", "barbecue", "smoked", "pit", "smoke ring",
|
|
||||||
"low and slow", "brisket", "pulled pork", "ribs"],
|
|
||||||
"subcategories": {
|
|
||||||
"Texas BBQ": ["texas bbq", "central texas bbq", "brisket",
|
|
||||||
"beef ribs", "post oak", "salt and pepper rub",
|
|
||||||
"east texas bbq", "lockhart", "franklin style"],
|
|
||||||
"Carolina BBQ": ["carolina bbq", "north carolina bbq", "whole hog",
|
|
||||||
"vinegar sauce", "lexington style", "eastern nc",
|
|
||||||
"south carolina bbq", "mustard sauce"],
|
|
||||||
"Kansas City BBQ": ["kansas city bbq", "kc bbq", "burnt ends",
|
|
||||||
"sweet bbq sauce", "tomato molasses sauce",
|
|
||||||
"baby back ribs kc"],
|
|
||||||
"Memphis BBQ": ["memphis bbq", "dry rub ribs", "wet ribs",
|
|
||||||
"memphis style", "dry rub pork"],
|
|
||||||
"Alabama BBQ": ["alabama bbq", "white sauce", "alabama white sauce",
|
|
||||||
"smoked chicken alabama"],
|
|
||||||
"Kentucky BBQ": ["kentucky bbq", "mutton bbq", "owensboro bbq",
|
|
||||||
"black dip", "western kentucky barbecue"],
|
|
||||||
"St. Louis BBQ": ["st louis bbq", "st. louis ribs", "st louis cut ribs",
|
|
||||||
"st louis style spare ribs"],
|
|
||||||
"Backyard Grill": ["backyard bbq", "cookout", "grilled burgers",
|
|
||||||
"charcoal grill", "kettle grill", "tailgate"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"European": {
|
|
||||||
"keywords": ["french", "german", "spanish", "british", "irish", "scottish",
|
|
||||||
"welsh", "scandinavian", "nordic", "eastern european"],
|
|
||||||
"subcategories": {
|
|
||||||
"French": ["french", "provencal", "beurre", "crepe",
|
|
||||||
"ratatouille", "cassoulet", "bouillabaisse"],
|
|
||||||
"Spanish": ["spanish", "paella", "tapas", "gazpacho",
|
|
||||||
"tortilla espanola", "chorizo"],
|
|
||||||
"German": ["german", "bratwurst", "sauerkraut", "schnitzel",
|
|
||||||
"pretzel", "strudel"],
|
|
||||||
"British": ["british", "english", "pub food", "cornish",
|
|
||||||
"shepherd's pie", "bangers", "toad in the hole",
|
|
||||||
"coronation chicken", "london", "londoner",
|
|
||||||
"cornish pasty", "ploughman's"],
|
|
||||||
"Irish": ["irish", "ireland", "colcannon", "coddle",
|
|
||||||
"irish stew", "soda bread", "boxty", "champ"],
|
|
||||||
"Scottish": ["scottish", "scotland", "haggis", "cullen skink",
|
|
||||||
"cranachan", "scotch broth", "glaswegian",
|
|
||||||
"neeps and tatties", "tablet"],
|
|
||||||
"Scandinavian": ["scandinavian", "nordic", "swedish", "norwegian",
|
|
||||||
"danish", "finnish", "gravlax", "swedish meatballs",
|
|
||||||
"lefse", "smörgåsbord", "fika", "crispbread",
|
|
||||||
"cardamom bun", "herring", "æbleskiver",
|
|
||||||
"lingonberry", "lutefisk", "janssons frestelse",
|
|
||||||
"knäckebröd", "kladdkaka"],
|
|
||||||
"Eastern European": ["eastern european", "polish", "russian", "ukrainian",
|
|
||||||
"czech", "hungarian", "pierogi", "borscht",
|
|
||||||
"goulash", "kielbasa", "varenyky", "pelmeni"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Latin American": {
|
|
||||||
"keywords": ["latin american", "peruvian", "argentinian", "colombian",
|
|
||||||
"cuban", "caribbean", "brazilian", "venezuelan", "chilean"],
|
|
||||||
"subcategories": {
|
|
||||||
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
|
|
||||||
"aji amarillo", "causa", "leche de tigre",
|
|
||||||
"arroz con leche peru", "pollo a la brasa"],
|
|
||||||
"Brazilian": ["brazilian", "churrasco", "feijoada", "pao de queijo",
|
|
||||||
"brigadeiro", "coxinha", "moqueca", "vatapa",
|
|
||||||
"caipirinha", "acai bowl"],
|
|
||||||
"Colombian": ["colombian", "bandeja paisa", "arepas", "empanadas",
|
|
||||||
"sancocho", "ajiaco", "buñuelos", "changua"],
|
|
||||||
"Argentinian": ["argentinian", "asado", "chimichurri", "empanadas argentina",
|
|
||||||
"milanesa", "locro", "dulce de leche", "medialunas"],
|
|
||||||
"Venezuelan": ["venezuelan", "pabellón criollo", "arepas venezuela",
|
|
||||||
"hallacas", "cachapas", "tequeños", "caraotas"],
|
|
||||||
"Chilean": ["chilean", "cazuela", "pastel de choclo", "curanto",
|
|
||||||
"sopaipillas", "charquicán", "completo"],
|
|
||||||
"Cuban": ["cuban", "ropa vieja", "moros y cristianos",
|
|
||||||
"picadillo", "lechon cubano", "vaca frita",
|
|
||||||
"tostones", "platanos maduros"],
|
|
||||||
"Jamaican": ["jamaican", "jerk chicken", "jerk pork", "ackee saltfish",
|
|
||||||
"curry goat", "rice and peas", "escovitch",
|
|
||||||
"jamaican patty", "callaloo jamaica", "festival"],
|
|
||||||
"Puerto Rican": ["puerto rican", "mofongo", "pernil", "arroz con gandules",
|
|
||||||
"sofrito", "pasteles", "tostones pr", "tembleque",
|
|
||||||
"coquito", "asopao"],
|
|
||||||
"Dominican": ["dominican", "mangu", "sancocho dominicano",
|
|
||||||
"pollo guisado", "habichuelas guisadas",
|
|
||||||
"tostones dominicanos", "morir soñando"],
|
|
||||||
"Haitian": ["haitian", "griot", "pikliz", "riz et pois",
|
|
||||||
"joumou", "akra", "pain patate", "labouyi"],
|
|
||||||
"Trinidad": ["trinidadian", "doubles", "roti trinidad", "pelau",
|
|
||||||
"callaloo trinidad", "bake and shark",
|
|
||||||
"curry duck", "oil down"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Central American": {
|
|
||||||
"keywords": ["central american", "salvadoran", "guatemalan",
|
|
||||||
"honduran", "nicaraguan", "costa rican", "panamanian"],
|
|
||||||
"subcategories": {
|
|
||||||
"Salvadoran": ["salvadoran", "el salvador", "pupusas", "curtido",
|
|
||||||
"sopa de pata", "nuégados", "atol shuco"],
|
|
||||||
"Guatemalan": ["guatemalan", "pepián", "jocon", "kak'ik",
|
|
||||||
"hilachas", "rellenitos", "fiambre"],
|
|
||||||
"Costa Rican": ["costa rican", "gallo pinto", "casado",
|
|
||||||
"olla de carne", "arroz con leche cr",
|
|
||||||
"tres leches cr"],
|
|
||||||
"Honduran": ["honduran", "baleadas", "sopa de caracol",
|
|
||||||
"tapado", "machuca", "catrachitas"],
|
|
||||||
"Nicaraguan": ["nicaraguan", "nacatamal", "vigorón", "indio viejo",
|
|
||||||
"gallo pinto nicaragua", "güirilas"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"African": {
|
|
||||||
"keywords": ["african", "west african", "east african", "ethiopian",
|
|
||||||
"nigerian", "ghanaian", "kenyan", "south african",
|
|
||||||
"senegalese", "tunisian"],
|
|
||||||
"subcategories": {
|
|
||||||
"West African": ["west african", "nigerian", "ghanaian",
|
|
||||||
"jollof rice", "egusi soup", "fufu", "suya",
|
|
||||||
"groundnut stew", "kelewele", "kontomire",
|
|
||||||
"waakye", "ofam", "bitterleaf soup"],
|
|
||||||
"Senegalese": ["senegalese", "senegal", "thieboudienne",
|
|
||||||
"yassa", "mafe", "thiou", "ceebu jen",
|
|
||||||
"domoda"],
|
|
||||||
"Ethiopian & Eritrean": ["ethiopian", "eritrean", "injera", "doro wat",
|
|
||||||
"kitfo", "tibs", "shiro", "misir wat",
|
|
||||||
"gomen", "ful ethiopian", "tegamino"],
|
|
||||||
"East African": ["east african", "kenyan", "tanzanian", "ugandan",
|
|
||||||
"nyama choma", "ugali", "sukuma wiki",
|
|
||||||
"pilau kenya", "mandazi", "matoke",
|
|
||||||
"githeri", "irio"],
|
|
||||||
"North African": ["north african", "tunisian", "algerian", "libyan",
|
|
||||||
"brik", "lablabi", "merguez", "shakshuka tunisian",
|
|
||||||
"harissa tunisian", "couscous algerian"],
|
|
||||||
"South African": ["south african", "braai", "bobotie", "boerewors",
|
|
||||||
"bunny chow", "pap", "chakalaka", "biltong",
|
|
||||||
"malva pudding", "koeksister", "potjiekos"],
|
|
||||||
"Moroccan": ["moroccan", "tagine", "couscous morocco",
|
|
||||||
"harissa", "chermoula", "preserved lemon",
|
|
||||||
"pastilla", "mechoui", "bastilla"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Pacific & Oceania": {
|
|
||||||
"keywords": ["pacific", "oceania", "polynesian", "melanesian",
|
|
||||||
"micronesian", "maori", "fijian", "samoan", "tongan",
|
|
||||||
"hawaiian", "australian", "new zealand"],
|
|
||||||
"subcategories": {
|
|
||||||
"Māori / New Zealand": ["maori", "new zealand", "hangi", "rewena bread",
|
|
||||||
"boil-up", "paua", "kumara", "pavlova nz",
|
|
||||||
"whitebait fritter", "kina", "hokey pokey"],
|
|
||||||
"Australian": ["australian", "meat pie", "lamington",
|
|
||||||
"anzac biscuits", "damper", "barramundi",
|
|
||||||
"vegemite", "pavlova australia", "tim tam",
|
|
||||||
"sausage sizzle", "chiko roll", "fairy bread"],
|
|
||||||
"Fijian": ["fijian", "fiji", "kokoda", "lovo",
|
|
||||||
"rourou", "palusami fiji", "duruka",
|
|
||||||
"vakalolo"],
|
|
||||||
"Samoan": ["samoan", "samoa", "palusami", "oka",
|
|
||||||
"fa'ausi", "chop suey samoa", "sapasui",
|
|
||||||
"koko alaisa", "supo esi"],
|
|
||||||
"Tongan": ["tongan", "tonga", "lu pulu", "'ota 'ika",
|
|
||||||
"fekkai", "faikakai topai", "kapisi pulu"],
|
|
||||||
"Papua New Guinean": ["papua new guinea", "png", "mumu",
|
|
||||||
"sago", "aibika", "kaukau",
|
|
||||||
"taro png", "coconut crab"],
|
|
||||||
"Hawaiian": ["hawaiian", "hawaii", "poke", "loco moco",
|
|
||||||
"plate lunch", "kalua pig", "haupia",
|
|
||||||
"spam musubi", "poi", "malasada"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Central Asian & Caucasus": {
|
|
||||||
"keywords": ["central asian", "caucasus", "georgian", "armenian", "uzbek",
|
|
||||||
"afghan", "persian", "iranian", "azerbaijani", "kazakh"],
|
|
||||||
"subcategories": {
|
|
||||||
"Persian / Iranian": ["persian", "iranian", "ghormeh sabzi", "fesenjan",
|
|
||||||
"tahdig", "joojeh kabab", "ash reshteh",
|
|
||||||
"zereshk polo", "khoresh", "mast o khiar",
|
|
||||||
"kashk-e-bademjan", "mirza ghasemi",
|
|
||||||
"baghali polo"],
|
|
||||||
"Georgian": ["georgian", "georgia", "khachapuri", "khinkali",
|
|
||||||
"churchkhela", "ajapsandali", "satsivi",
|
|
||||||
"pkhali", "lobiani", "badrijani nigvzit"],
|
|
||||||
"Armenian": ["armenian", "dolma armenia", "lahmajoun",
|
|
||||||
"manti armenia", "ghapama", "basturma",
|
|
||||||
"harissa armenia", "nazook", "tolma"],
|
|
||||||
"Azerbaijani": ["azerbaijani", "azerbaijan", "plov azerbaijan",
|
|
||||||
"dolma azeri", "dushbara", "levengi",
|
|
||||||
"shah plov", "gutab"],
|
|
||||||
"Uzbek": ["uzbek", "uzbekistan", "plov", "samsa",
|
|
||||||
"lagman", "shashlik", "manti uzbek",
|
|
||||||
"non bread", "dimlama", "sumalak"],
|
|
||||||
"Afghan": ["afghan", "afghanistan", "kabuli pulao", "mantu",
|
|
||||||
"bolani", "qorma", "ashak", "shorwa",
|
|
||||||
"aushak", "borani banjan"],
|
|
||||||
"Kazakh": ["kazakh", "beshbarmak", "kuyrdak", "baursak",
|
|
||||||
"kurt", "shubat", "kazy"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"meal_type": {
|
"meal_type": {
|
||||||
"label": "Meal Type",
|
"label": "Meal Type",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Breakfast": {
|
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
|
||||||
"keywords": ["breakfast", "brunch", "eggs", "pancakes", "waffles",
|
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
||||||
"oatmeal", "muffin"],
|
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
|
||||||
"subcategories": {
|
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
|
||||||
"Eggs": ["egg", "omelette", "frittata", "quiche",
|
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
|
||||||
"scrambled", "benedict", "shakshuka"],
|
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||||
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
|
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||||
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
|
|
||||||
"coffee cake", "danish"],
|
|
||||||
"Oats & Grains": ["oatmeal", "granola", "porridge", "muesli",
|
|
||||||
"overnight oats"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Lunch": {
|
|
||||||
"keywords": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
|
||||||
"subcategories": {
|
|
||||||
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
|
|
||||||
"grilled cheese", "blt"],
|
|
||||||
"Salads": ["salad", "grain bowl", "chopped", "caesar",
|
|
||||||
"niçoise", "cobb"],
|
|
||||||
"Soups": ["soup", "bisque", "chowder", "gazpacho",
|
|
||||||
"minestrone", "lentil soup"],
|
|
||||||
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
|
|
||||||
"quesadilla"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Dinner": {
|
|
||||||
"keywords": ["dinner", "main dish", "entree", "main course", "supper"],
|
|
||||||
"subcategories": {
|
|
||||||
"Casseroles": ["casserole", "bake", "gratin", "lasagna",
|
|
||||||
"sheperd's pie", "pot pie"],
|
|
||||||
"Stews": ["stew", "braise", "slow cooker", "pot roast",
|
|
||||||
"daube", "ragù"],
|
|
||||||
"Grilled": ["grilled", "grill", "barbecue", "charred",
|
|
||||||
"kebab", "skewer"],
|
|
||||||
"Stir-Fries": ["stir fry", "stir-fry", "wok", "sauté",
|
|
||||||
"sauteed"],
|
|
||||||
"Roasts": ["roast", "roasted", "oven", "baked chicken",
|
|
||||||
"pot roast"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Snack": {
|
|
||||||
"keywords": ["snack", "appetizer", "finger food", "dip", "bite",
|
|
||||||
"starter"],
|
|
||||||
"subcategories": {
|
|
||||||
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
|
|
||||||
"salsa", "pate"],
|
|
||||||
"Finger Foods": ["finger food", "bite", "skewer", "slider",
|
|
||||||
"wing", "nugget"],
|
|
||||||
"Chips & Crackers": ["chip", "cracker", "crisp", "popcorn",
|
|
||||||
"pretzel"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Dessert": {
|
|
||||||
"keywords": ["dessert", "cake", "cookie", "pie", "sweet", "pudding",
|
|
||||||
"ice cream", "brownie"],
|
|
||||||
"subcategories": {
|
|
||||||
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
|
|
||||||
"cheesecake", "torte"],
|
|
||||||
"Cookies & Bars": ["cookie", "brownie", "blondie", "bar",
|
|
||||||
"biscotti", "shortbread"],
|
|
||||||
"Pies & Tarts": ["pie", "tart", "galette", "cobbler", "crisp",
|
|
||||||
"crumble"],
|
|
||||||
"Frozen": ["ice cream", "gelato", "sorbet", "frozen dessert",
|
|
||||||
"popsicle", "granita"],
|
|
||||||
"Puddings": ["pudding", "custard", "mousse", "panna cotta",
|
|
||||||
"flan", "creme brulee"],
|
|
||||||
"Candy": ["candy", "fudge", "truffle", "brittle",
|
|
||||||
"caramel", "toffee"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
|
||||||
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"dietary": {
|
"dietary": {
|
||||||
|
|
@ -503,128 +56,31 @@ DOMAINS: dict[str, dict] = {
|
||||||
"main_ingredient": {
|
"main_ingredient": {
|
||||||
"label": "Main Ingredient",
|
"label": "Main Ingredient",
|
||||||
"categories": {
|
"categories": {
|
||||||
# keywords use exact inferred_tag strings (main:X) — indexed into recipe_browser_fts.
|
"Chicken": ["chicken", "poultry", "turkey"],
|
||||||
"Chicken": {
|
"Beef": ["beef", "ground beef", "steak", "brisket", "pot roast"],
|
||||||
"keywords": ["main:Chicken"],
|
"Pork": ["pork", "bacon", "ham", "sausage", "prosciutto"],
|
||||||
"subcategories": {
|
"Fish": ["fish", "salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood"],
|
||||||
"Baked": ["baked chicken", "roast chicken", "chicken casserole",
|
"Pasta": ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"],
|
||||||
"chicken bake"],
|
"Vegetables": ["vegetable", "veggie", "cauliflower", "broccoli", "zucchini", "eggplant"],
|
||||||
"Grilled": ["grilled chicken", "chicken kebab", "bbq chicken",
|
"Eggs": ["egg", "frittata", "omelette", "omelet", "quiche"],
|
||||||
"chicken skewer"],
|
"Legumes": ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"],
|
||||||
"Fried": ["fried chicken", "chicken cutlet", "chicken schnitzel",
|
"Grains": ["rice", "quinoa", "barley", "farro", "oat", "grain"],
|
||||||
"crispy chicken"],
|
"Cheese": ["cheese", "ricotta", "mozzarella", "parmesan", "cheddar"],
|
||||||
"Stewed": ["chicken stew", "chicken soup", "coq au vin",
|
|
||||||
"chicken curry", "chicken braise"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Beef": {
|
|
||||||
"keywords": ["main:Beef"],
|
|
||||||
"subcategories": {
|
|
||||||
"Ground Beef": ["ground beef", "hamburger", "meatball", "meatloaf",
|
|
||||||
"bolognese", "burger"],
|
|
||||||
"Steak": ["steak", "sirloin", "ribeye", "flank steak",
|
|
||||||
"filet mignon", "t-bone"],
|
|
||||||
"Roasts": ["beef roast", "pot roast", "brisket", "prime rib",
|
|
||||||
"chuck roast"],
|
|
||||||
"Stews": ["beef stew", "beef braise", "beef bourguignon",
|
|
||||||
"short ribs"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Pork": {
|
|
||||||
"keywords": ["main:Pork"],
|
|
||||||
"subcategories": {
|
|
||||||
"Chops": ["pork chop", "pork loin", "pork cutlet"],
|
|
||||||
"Pulled/Slow": ["pulled pork", "pork shoulder", "pork butt",
|
|
||||||
"carnitas", "slow cooker pork"],
|
|
||||||
"Sausage": ["sausage", "bratwurst", "chorizo", "andouille",
|
|
||||||
"Italian sausage"],
|
|
||||||
"Ribs": ["pork ribs", "baby back ribs", "spare ribs",
|
|
||||||
"pork belly"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Fish": {
|
|
||||||
"keywords": ["main:Fish"],
|
|
||||||
"subcategories": {
|
|
||||||
"Salmon": ["salmon", "smoked salmon", "gravlax"],
|
|
||||||
"Tuna": ["tuna", "albacore", "ahi"],
|
|
||||||
"White Fish": ["cod", "tilapia", "halibut", "sole", "snapper",
|
|
||||||
"flounder", "bass"],
|
|
||||||
"Shellfish": ["shrimp", "prawn", "crab", "lobster", "scallop",
|
|
||||||
"mussel", "clam", "oyster"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Pasta": ["main:Pasta"],
|
|
||||||
"Vegetables": {
|
|
||||||
"keywords": ["main:Vegetables"],
|
|
||||||
"subcategories": {
|
|
||||||
"Root Veg": ["potato", "sweet potato", "carrot", "beet",
|
|
||||||
"parsnip", "turnip"],
|
|
||||||
"Leafy": ["spinach", "kale", "chard", "arugula",
|
|
||||||
"collard greens", "lettuce"],
|
|
||||||
"Brassicas": ["broccoli", "cauliflower", "brussels sprouts",
|
|
||||||
"cabbage", "bok choy"],
|
|
||||||
"Nightshades": ["tomato", "eggplant", "bell pepper", "zucchini",
|
|
||||||
"squash"],
|
|
||||||
"Mushrooms": ["mushroom", "portobello", "shiitake", "oyster mushroom",
|
|
||||||
"chanterelle"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"Eggs": ["main:Eggs"],
|
|
||||||
"Legumes": ["main:Legumes"],
|
|
||||||
"Grains": ["main:Grains"],
|
|
||||||
"Cheese": ["main:Cheese"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_category_def(domain: str, category: str) -> list[str] | dict | None:
|
|
||||||
"""Return the raw category definition, or None if not found."""
|
|
||||||
return DOMAINS.get(domain, {}).get("categories", {}).get(category)
|
|
||||||
|
|
||||||
|
|
||||||
def get_domain_labels() -> list[dict]:
|
def get_domain_labels() -> list[dict]:
|
||||||
"""Return [{id, label}] for all available domains."""
|
"""Return [{id, label}] for all available domains."""
|
||||||
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
||||||
|
|
||||||
|
|
||||||
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
||||||
"""Return the keyword list for the category (top-level, covers all subcategories).
|
"""Return the keyword list for a domain/category pair, or [] if not found."""
|
||||||
|
domain_data = DOMAINS.get(domain, {})
|
||||||
For flat categories returns the list directly.
|
categories = domain_data.get("categories", {})
|
||||||
For nested categories returns the 'keywords' key.
|
return categories.get(category, [])
|
||||||
Returns [] if category or domain not found.
|
|
||||||
"""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if cat_def is None:
|
|
||||||
return []
|
|
||||||
if isinstance(cat_def, list):
|
|
||||||
return cat_def
|
|
||||||
return cat_def.get("keywords", [])
|
|
||||||
|
|
||||||
|
|
||||||
def category_has_subcategories(domain: str, category: str) -> bool:
|
|
||||||
"""Return True when a category has a subcategory level."""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if not isinstance(cat_def, dict):
|
|
||||||
return False
|
|
||||||
return bool(cat_def.get("subcategories"))
|
|
||||||
|
|
||||||
|
|
||||||
def get_subcategory_names(domain: str, category: str) -> list[str]:
|
|
||||||
"""Return subcategory names for a category, or [] if none exist."""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if not isinstance(cat_def, dict):
|
|
||||||
return []
|
|
||||||
return list(cat_def.get("subcategories", {}).keys())
|
|
||||||
|
|
||||||
|
|
||||||
def get_keywords_for_subcategory(domain: str, category: str, subcategory: str) -> list[str]:
|
|
||||||
"""Return keyword list for a specific subcategory, or [] if not found."""
|
|
||||||
cat_def = _get_category_def(domain, category)
|
|
||||||
if not isinstance(cat_def, dict):
|
|
||||||
return []
|
|
||||||
return cat_def.get("subcategories", {}).get(subcategory, [])
|
|
||||||
|
|
||||||
|
|
||||||
def get_category_names(domain: str) -> list[str]:
|
def get_category_names(domain: str) -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -84,9 +84,8 @@ class ElementClassifier:
|
||||||
name = ingredient_name.lower().strip()
|
name = ingredient_name.lower().strip()
|
||||||
if not name:
|
if not name:
|
||||||
return IngredientProfile(name="", elements=[], source="heuristic")
|
return IngredientProfile(name="", elements=[], source="heuristic")
|
||||||
c = self._store._cp
|
|
||||||
row = self._store._fetch_one(
|
row = self._store._fetch_one(
|
||||||
f"SELECT * FROM {c}ingredient_profiles WHERE name = ?", (name,)
|
"SELECT * FROM ingredient_profiles WHERE name = ?", (name,)
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
return self._row_to_profile(row)
|
return self._row_to_profile(row)
|
||||||
|
|
|
||||||
|
|
@ -84,13 +84,7 @@ class LLMRecipeGenerator:
|
||||||
if template.aromatics:
|
if template.aromatics:
|
||||||
lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}")
|
lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}")
|
||||||
|
|
||||||
unit_line = (
|
|
||||||
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
|
|
||||||
if req.unit_system == "metric"
|
|
||||||
else "Use imperial units (oz, cups, Fahrenheit) for all quantities and temperatures."
|
|
||||||
)
|
|
||||||
lines += [
|
lines += [
|
||||||
unit_line,
|
|
||||||
"",
|
"",
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
|
||||||
"Title: <name of the dish>",
|
"Title: <name of the dish>",
|
||||||
|
|
@ -124,14 +118,8 @@ class LLMRecipeGenerator:
|
||||||
if allergy_list:
|
if allergy_list:
|
||||||
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
|
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
|
||||||
|
|
||||||
unit_line = (
|
|
||||||
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
|
|
||||||
if req.unit_system == "metric"
|
|
||||||
else "Use imperial units (oz, cups, Fahrenheit) for all quantities and temperatures."
|
|
||||||
)
|
|
||||||
lines += [
|
lines += [
|
||||||
"Treat any mystery ingredient as a wildcard — use your imagination.",
|
"Treat any mystery ingredient as a wildcard — use your imagination.",
|
||||||
unit_line,
|
|
||||||
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
|
||||||
"Title: <name of the dish>",
|
"Title: <name of the dish>",
|
||||||
"Ingredients: <comma-separated list>",
|
"Ingredients: <comma-separated list>",
|
||||||
|
|
@ -143,14 +131,12 @@ class LLMRecipeGenerator:
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
_SERVICE_TYPE = "cf-text"
|
_MODEL_CANDIDATES: list[str] = ["Ouro-2.6B-Thinking", "Ouro-1.4B"]
|
||||||
_TTL_S = 300.0
|
|
||||||
_CALLER = "kiwi-recipe"
|
|
||||||
|
|
||||||
def _get_llm_context(self):
|
def _get_llm_context(self):
|
||||||
"""Return a sync context manager that yields an Allocation or None.
|
"""Return a sync context manager that yields an Allocation or None.
|
||||||
|
|
||||||
When CF_ORCH_URL is set, uses CFOrchClient to acquire a cf-text allocation
|
When CF_ORCH_URL is set, uses CFOrchClient to acquire a vLLM allocation
|
||||||
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
|
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
|
||||||
when the env var is absent or CFOrchClient raises on construction.
|
when the env var is absent or CFOrchClient raises on construction.
|
||||||
"""
|
"""
|
||||||
|
|
@ -160,9 +146,10 @@ class LLMRecipeGenerator:
|
||||||
from circuitforge_orch.client import CFOrchClient
|
from circuitforge_orch.client import CFOrchClient
|
||||||
client = CFOrchClient(cf_orch_url)
|
client = CFOrchClient(cf_orch_url)
|
||||||
return client.allocate(
|
return client.allocate(
|
||||||
service=self._SERVICE_TYPE,
|
service="vllm",
|
||||||
ttl_s=self._TTL_S,
|
model_candidates=self._MODEL_CANDIDATES,
|
||||||
caller=self._CALLER,
|
ttl_s=300.0,
|
||||||
|
caller="kiwi-recipe",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
||||||
|
|
@ -181,19 +168,6 @@ class LLMRecipeGenerator:
|
||||||
try:
|
try:
|
||||||
alloc = ctx.__enter__()
|
alloc = ctx.__enter__()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
msg = str(exc)
|
|
||||||
# 429 = coordinator at capacity (all nodes at max_concurrent limit).
|
|
||||||
# Don't fall back to LLMRouter — it's also overloaded and the slow
|
|
||||||
# fallback causes nginx 504s. Return "" fast so the caller degrades
|
|
||||||
# gracefully (empty recipe result) rather than timing out.
|
|
||||||
if "429" in msg or "max_concurrent" in msg.lower():
|
|
||||||
logger.info("cf-orch at capacity — returning empty result (graceful degradation)")
|
|
||||||
if ctx is not None:
|
|
||||||
try:
|
|
||||||
ctx.__exit__(None, None, None)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
logger.debug("cf-orch allocation failed, falling back to LLMRouter: %s", exc)
|
logger.debug("cf-orch allocation failed, falling back to LLMRouter: %s", exc)
|
||||||
ctx = None # __enter__ raised — do not call __exit__
|
ctx = None # __enter__ raised — do not call __exit__
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
"""
|
|
||||||
Shopping locale configuration.
|
|
||||||
|
|
||||||
Maps a locale key to Amazon domain, currency metadata, and retailer availability.
|
|
||||||
Instacart and Walmart are US/CA-only; all other locales get Amazon only.
|
|
||||||
Amazon Fresh (&i=amazonfresh) is US-only — international domains use the general
|
|
||||||
grocery department (&rh=n:16310101) where available, plain search elsewhere.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
|
|
||||||
class LocaleConfig(TypedDict):
|
|
||||||
amazon_domain: str
|
|
||||||
amazon_grocery_dept: str # URL fragment for grocery department on this locale's site
|
|
||||||
currency_code: str
|
|
||||||
currency_symbol: str
|
|
||||||
instacart: bool
|
|
||||||
walmart: bool
|
|
||||||
|
|
||||||
|
|
||||||
LOCALES: dict[str, LocaleConfig] = {
|
|
||||||
"us": {
|
|
||||||
"amazon_domain": "amazon.com",
|
|
||||||
"amazon_grocery_dept": "i=amazonfresh",
|
|
||||||
"currency_code": "USD",
|
|
||||||
"currency_symbol": "$",
|
|
||||||
"instacart": True,
|
|
||||||
"walmart": True,
|
|
||||||
},
|
|
||||||
"ca": {
|
|
||||||
"amazon_domain": "amazon.ca",
|
|
||||||
"amazon_grocery_dept": "rh=n:6967215011", # Grocery dept on .ca # gitleaks:allow
|
|
||||||
"currency_code": "CAD",
|
|
||||||
"currency_symbol": "CA$",
|
|
||||||
"instacart": True,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"gb": {
|
|
||||||
"amazon_domain": "amazon.co.uk",
|
|
||||||
"amazon_grocery_dept": "rh=n:340831031", # Grocery dept on .co.uk
|
|
||||||
"currency_code": "GBP",
|
|
||||||
"currency_symbol": "£",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"au": {
|
|
||||||
"amazon_domain": "amazon.com.au",
|
|
||||||
"amazon_grocery_dept": "rh=n:5765081051", # Pantry/grocery on .com.au # gitleaks:allow
|
|
||||||
"currency_code": "AUD",
|
|
||||||
"currency_symbol": "A$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"nz": {
|
|
||||||
# NZ has no Amazon storefront — route to .com.au as nearest option
|
|
||||||
"amazon_domain": "amazon.com.au",
|
|
||||||
"amazon_grocery_dept": "rh=n:5765081051", # gitleaks:allow
|
|
||||||
"currency_code": "NZD",
|
|
||||||
"currency_symbol": "NZ$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"de": {
|
|
||||||
"amazon_domain": "amazon.de",
|
|
||||||
"amazon_grocery_dept": "rh=n:340843031", # Lebensmittel & Getränke
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"fr": {
|
|
||||||
"amazon_domain": "amazon.fr",
|
|
||||||
"amazon_grocery_dept": "rh=n:197858031",
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"it": {
|
|
||||||
"amazon_domain": "amazon.it",
|
|
||||||
"amazon_grocery_dept": "rh=n:525616031",
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"es": {
|
|
||||||
"amazon_domain": "amazon.es",
|
|
||||||
"amazon_grocery_dept": "rh=n:599364031",
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"nl": {
|
|
||||||
"amazon_domain": "amazon.nl",
|
|
||||||
"amazon_grocery_dept": "rh=n:16584827031",
|
|
||||||
"currency_code": "EUR",
|
|
||||||
"currency_symbol": "€",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"se": {
|
|
||||||
"amazon_domain": "amazon.se",
|
|
||||||
"amazon_grocery_dept": "rh=n:20741393031",
|
|
||||||
"currency_code": "SEK",
|
|
||||||
"currency_symbol": "kr",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"jp": {
|
|
||||||
"amazon_domain": "amazon.co.jp",
|
|
||||||
"amazon_grocery_dept": "rh=n:2246283051", # gitleaks:allow
|
|
||||||
"currency_code": "JPY",
|
|
||||||
"currency_symbol": "¥",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"in": {
|
|
||||||
"amazon_domain": "amazon.in",
|
|
||||||
"amazon_grocery_dept": "rh=n:2454178031", # gitleaks:allow
|
|
||||||
"currency_code": "INR",
|
|
||||||
"currency_symbol": "₹",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"mx": {
|
|
||||||
"amazon_domain": "amazon.com.mx",
|
|
||||||
"amazon_grocery_dept": "rh=n:10737659011",
|
|
||||||
"currency_code": "MXN",
|
|
||||||
"currency_symbol": "MX$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"br": {
|
|
||||||
"amazon_domain": "amazon.com.br",
|
|
||||||
"amazon_grocery_dept": "rh=n:17878420011",
|
|
||||||
"currency_code": "BRL",
|
|
||||||
"currency_symbol": "R$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
"sg": {
|
|
||||||
"amazon_domain": "amazon.sg",
|
|
||||||
"amazon_grocery_dept": "rh=n:6981647051", # gitleaks:allow
|
|
||||||
"currency_code": "SGD",
|
|
||||||
"currency_symbol": "S$",
|
|
||||||
"instacart": False,
|
|
||||||
"walmart": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_LOCALE = "us"
|
|
||||||
|
|
||||||
|
|
||||||
def get_locale(key: str) -> LocaleConfig:
|
|
||||||
"""Return locale config for *key*, falling back to US if unknown."""
|
|
||||||
return LOCALES.get(key, LOCALES[DEFAULT_LOCALE])
|
|
||||||
|
|
@ -155,24 +155,6 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# When a pantry item is in a secondary state (e.g. bread → "stale"), expand
|
|
||||||
# the pantry set with terms that recipe ingredients commonly use to describe
|
|
||||||
# that state. This lets "stale bread" in a recipe ingredient match a pantry
|
|
||||||
# entry that is simply called "Bread" but is past its nominal use-by date.
|
|
||||||
# Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state).
|
|
||||||
# Values are additional strings added to the pantry set for FTS coverage.
|
|
||||||
_SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = {
|
|
||||||
("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"],
|
|
||||||
("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry"],
|
|
||||||
("bananas", "overripe"): ["overripe bananas", "very ripe banana", "ripe bananas", "mashed banana"],
|
|
||||||
("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk"],
|
|
||||||
("dairy", "sour"): ["sour milk", "slightly sour milk"],
|
|
||||||
("cheese", "well-aged"): ["parmesan rind", "cheese rind", "aged cheese"],
|
|
||||||
("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice"],
|
|
||||||
("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Matches leading quantity/unit prefixes in recipe ingredient strings,
|
# Matches leading quantity/unit prefixes in recipe ingredient strings,
|
||||||
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
|
||||||
# "3 oz. butter" → "butter"
|
# "3 oz. butter" → "butter"
|
||||||
|
|
@ -302,24 +284,14 @@ def _prep_note_for(ingredient: str) -> str | None:
|
||||||
return template.format(ingredient=ingredient_name)
|
return template.format(ingredient=ingredient_name)
|
||||||
|
|
||||||
|
|
||||||
def _expand_pantry_set(
|
def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
|
||||||
pantry_items: list[str],
|
|
||||||
secondary_pantry_items: dict[str, str] | None = None,
|
|
||||||
) -> set[str]:
|
|
||||||
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
|
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
|
||||||
|
|
||||||
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
|
||||||
and adds the canonical form. This lets single-word recipe ingredients
|
and adds the canonical form. This lets single-word recipe ingredients
|
||||||
("hamburger", "chicken") match product-label pantry entries
|
("hamburger", "chicken") match product-label pantry entries
|
||||||
("burger patties", "rotisserie chicken").
|
("burger patties", "rotisserie chicken").
|
||||||
|
|
||||||
If secondary_pantry_items is provided (product_name → state label), items
|
|
||||||
in a secondary state also receive state-specific synonym expansion so that
|
|
||||||
recipe ingredients like "stale bread" or "day-old rice" are matched.
|
|
||||||
"""
|
"""
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
|
||||||
_predictor = ExpirationPredictor()
|
|
||||||
|
|
||||||
expanded: set[str] = set()
|
expanded: set[str] = set()
|
||||||
for item in pantry_items:
|
for item in pantry_items:
|
||||||
lower = item.lower().strip()
|
lower = item.lower().strip()
|
||||||
|
|
@ -327,15 +299,6 @@ def _expand_pantry_set(
|
||||||
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
|
||||||
if pattern in lower:
|
if pattern in lower:
|
||||||
expanded.add(canonical)
|
expanded.add(canonical)
|
||||||
|
|
||||||
# Secondary state expansion — adds terms like "stale bread", "day-old rice"
|
|
||||||
if secondary_pantry_items and item in secondary_pantry_items:
|
|
||||||
state_label = secondary_pantry_items[item]
|
|
||||||
category = _predictor.get_category_from_product(item)
|
|
||||||
if category:
|
|
||||||
synonyms = _SECONDARY_STATE_SYNONYMS.get((category, state_label), [])
|
|
||||||
expanded.update(synonyms)
|
|
||||||
|
|
||||||
return expanded
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -599,19 +562,6 @@ def _hard_day_sort_tier(
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|
||||||
def _estimate_time_min(directions: list[str], complexity: str) -> int:
|
|
||||||
"""Rough cooking time estimate from step count and method complexity.
|
|
||||||
|
|
||||||
Not precise — intended for filtering and display hints only.
|
|
||||||
"""
|
|
||||||
steps = len(directions)
|
|
||||||
if complexity == "easy":
|
|
||||||
return max(5, 10 + steps * 3)
|
|
||||||
if complexity == "involved":
|
|
||||||
return max(20, 30 + steps * 6)
|
|
||||||
return max(10, 20 + steps * 4) # moderate
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_method_complexity(
|
def _classify_method_complexity(
|
||||||
directions: list[str],
|
directions: list[str],
|
||||||
available_equipment: list[str] | None = None,
|
available_equipment: list[str] | None = None,
|
||||||
|
|
@ -671,7 +621,7 @@ class RecipeEngine:
|
||||||
|
|
||||||
profiles = self._classifier.classify_batch(req.pantry_items)
|
profiles = self._classifier.classify_batch(req.pantry_items)
|
||||||
gaps = self._classifier.identify_gaps(profiles)
|
gaps = self._classifier.identify_gaps(profiles)
|
||||||
pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None)
|
pantry_set = _expand_pantry_set(req.pantry_items)
|
||||||
|
|
||||||
if req.level >= 3:
|
if req.level >= 3:
|
||||||
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
||||||
|
|
@ -749,11 +699,6 @@ class RecipeEngine:
|
||||||
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
|
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# "Can make now" toggle: drop any recipe that still has missing ingredients
|
|
||||||
# after swaps are applied. Swapped items count as covered.
|
|
||||||
if req.pantry_match_only and missing:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# L1 match ratio gate: drop results where less than 60% of the recipe's
|
# L1 match ratio gate: drop results where less than 60% of the recipe's
|
||||||
# ingredients are in the pantry. Prevents low-signal results like a
|
# ingredients are in the pantry. Prevents low-signal results like a
|
||||||
# 10-ingredient recipe matching on only one common item.
|
# 10-ingredient recipe matching on only one common item.
|
||||||
|
|
@ -762,21 +707,16 @@ class RecipeEngine:
|
||||||
if match_ratio < _L1_MIN_MATCH_RATIO:
|
if match_ratio < _L1_MIN_MATCH_RATIO:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse directions — needed for complexity, hard_day_mode, and time estimate.
|
|
||||||
directions: list[str] = row.get("directions") or []
|
|
||||||
if isinstance(directions, str):
|
|
||||||
try:
|
|
||||||
directions = json.loads(directions)
|
|
||||||
except Exception:
|
|
||||||
directions = [directions]
|
|
||||||
|
|
||||||
# Compute complexity for every suggestion (used for badge + filter).
|
|
||||||
row_complexity = _classify_method_complexity(directions, available_equipment)
|
|
||||||
row_time_min = _estimate_time_min(directions, row_complexity)
|
|
||||||
|
|
||||||
# Filter and tier-rank by hard_day_mode
|
# Filter and tier-rank by hard_day_mode
|
||||||
if req.hard_day_mode:
|
if req.hard_day_mode:
|
||||||
if row_complexity == "involved":
|
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
|
continue
|
||||||
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
|
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
|
||||||
title=row.get("title", ""),
|
title=row.get("title", ""),
|
||||||
|
|
@ -784,14 +724,6 @@ class RecipeEngine:
|
||||||
directions=directions,
|
directions=directions,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Complexity filter (#58)
|
|
||||||
if req.complexity_filter and row_complexity != req.complexity_filter:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Max time filter (#58)
|
|
||||||
if req.max_time_min is not None and row_time_min > req.max_time_min:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Level 2: also add dietary constraint swaps from substitution_pairs
|
# Level 2: also add dietary constraint swaps from substitution_pairs
|
||||||
if req.level == 2 and req.constraints:
|
if req.level == 2 and req.constraints:
|
||||||
for ing in ingredient_names:
|
for ing in ingredient_names:
|
||||||
|
|
@ -841,8 +773,6 @@ class RecipeEngine:
|
||||||
level=req.level,
|
level=req.level,
|
||||||
nutrition=nutrition if has_nutrition else None,
|
nutrition=nutrition if has_nutrition else None,
|
||||||
source_url=_build_source_url(row),
|
source_url=_build_source_url(row),
|
||||||
complexity=row_complexity,
|
|
||||||
estimated_time_min=row_time_min,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# Sort corpus results — assembly templates are now served from a dedicated tab.
|
# Sort corpus results — assembly templates are now served from a dedicated tab.
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,11 @@ class SubstitutionEngine:
|
||||||
ingredient_name: str,
|
ingredient_name: str,
|
||||||
constraint: str,
|
constraint: str,
|
||||||
) -> list[SubstitutionSwap]:
|
) -> list[SubstitutionSwap]:
|
||||||
c = self._store._cp
|
rows = self._store._fetch_all("""
|
||||||
rows = self._store._fetch_all(f"""
|
|
||||||
SELECT substitute_name, constraint_label,
|
SELECT substitute_name, constraint_label,
|
||||||
fat_delta, moisture_delta, glutamate_delta, protein_delta,
|
fat_delta, moisture_delta, glutamate_delta, protein_delta,
|
||||||
occurrence_count, compensation_hints
|
occurrence_count, compensation_hints
|
||||||
FROM {c}substitution_pairs
|
FROM substitution_pairs
|
||||||
WHERE original_name = ? AND constraint_label = ?
|
WHERE original_name = ? AND constraint_label = ?
|
||||||
ORDER BY occurrence_count DESC
|
ORDER BY occurrence_count DESC
|
||||||
""", (ingredient_name.lower(), constraint))
|
""", (ingredient_name.lower(), constraint))
|
||||||
|
|
|
||||||
|
|
@ -112,21 +112,6 @@ _TIME_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
|
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
_MAIN_INGREDIENT_SIGNALS: list[tuple[str, list[str]]] = [
|
|
||||||
("main:Chicken", ["chicken", "poultry", "turkey"]),
|
|
||||||
("main:Beef", ["beef", "ground beef", "steak", "brisket", "pot roast"]),
|
|
||||||
("main:Pork", ["pork", "bacon", "ham", "sausage", "prosciutto"]),
|
|
||||||
("main:Fish", ["salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood", "fish"]),
|
|
||||||
("main:Pasta", ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"]),
|
|
||||||
("main:Vegetables", ["broccoli", "cauliflower", "zucchini", "eggplant", "carrot",
|
|
||||||
"vegetable", "veggie"]),
|
|
||||||
("main:Eggs", ["egg", "frittata", "omelette", "omelet", "quiche"]),
|
|
||||||
("main:Legumes", ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"]),
|
|
||||||
("main:Grains", ["rice", "quinoa", "barley", "farro", "oat", "grain"]),
|
|
||||||
("main:Cheese", ["cheddar", "mozzarella", "parmesan", "ricotta", "brie",
|
|
||||||
"cheese"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
# food.com corpus tag -> normalized tags
|
# food.com corpus tag -> normalized tags
|
||||||
_CORPUS_TAG_MAP: dict[str, list[str]] = {
|
_CORPUS_TAG_MAP: dict[str, list[str]] = {
|
||||||
"european": ["cuisine:Italian", "cuisine:French", "cuisine:German",
|
"european": ["cuisine:Italian", "cuisine:French", "cuisine:German",
|
||||||
|
|
@ -247,7 +232,6 @@ def infer_tags(
|
||||||
tags.update(_match_signals(text, _CUISINE_SIGNALS))
|
tags.update(_match_signals(text, _CUISINE_SIGNALS))
|
||||||
tags.update(_match_signals(text, _DIETARY_SIGNALS))
|
tags.update(_match_signals(text, _DIETARY_SIGNALS))
|
||||||
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
|
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
|
||||||
tags.update(_match_signals(text, _MAIN_INGREDIENT_SIGNALS))
|
|
||||||
|
|
||||||
# 3. Time signals from corpus keywords + text
|
# 3. Time signals from corpus keywords + text
|
||||||
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
|
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback", "recipe_llm"})
|
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).
|
||||||
|
|
@ -88,8 +88,6 @@ def run_task(
|
||||||
try:
|
try:
|
||||||
if task_type == "expiry_llm_fallback":
|
if task_type == "expiry_llm_fallback":
|
||||||
_run_expiry_llm_fallback(db_path, job_id, params)
|
_run_expiry_llm_fallback(db_path, job_id, params)
|
||||||
elif task_type == "recipe_llm":
|
|
||||||
_run_recipe_llm(db_path, job_id, params)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown kiwi task type: {task_type!r}")
|
raise ValueError(f"Unknown kiwi task type: {task_type!r}")
|
||||||
_update_task_status(db_path, task_id, "completed")
|
_update_task_status(db_path, task_id, "completed")
|
||||||
|
|
@ -145,41 +143,3 @@ def _run_expiry_llm_fallback(
|
||||||
expiry,
|
expiry,
|
||||||
days,
|
days,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_recipe_llm(db_path: Path, _job_id_int: int, params: str | None) -> None:
|
|
||||||
"""Run LLM recipe generation for an async recipe job.
|
|
||||||
|
|
||||||
params JSON keys:
|
|
||||||
job_id (required) — recipe_jobs.job_id string (e.g. "rec_a1b2c3...")
|
|
||||||
|
|
||||||
Creates its own Store — follows same pattern as _suggest_in_thread.
|
|
||||||
MUST call store.fail_recipe_job() before re-raising so recipe_jobs.status
|
|
||||||
doesn't stay 'running' while background_tasks shows 'failed'.
|
|
||||||
"""
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.models.schemas.recipe import RecipeRequest
|
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
|
||||||
|
|
||||||
p = json.loads(params or "{}")
|
|
||||||
recipe_job_id: str = p.get("job_id", "")
|
|
||||||
if not recipe_job_id:
|
|
||||||
raise ValueError("recipe_llm: 'job_id' is required in params")
|
|
||||||
|
|
||||||
store = Store(db_path)
|
|
||||||
try:
|
|
||||||
store.update_recipe_job_running(recipe_job_id)
|
|
||||||
row = store._fetch_one(
|
|
||||||
"SELECT request FROM recipe_jobs WHERE job_id=?", (recipe_job_id,)
|
|
||||||
)
|
|
||||||
if row is None:
|
|
||||||
raise ValueError(f"recipe_llm: recipe_jobs row not found: {recipe_job_id!r}")
|
|
||||||
req = RecipeRequest.model_validate_json(row["request"])
|
|
||||||
result = RecipeEngine(store).suggest(req)
|
|
||||||
store.complete_recipe_job(recipe_job_id, result.model_dump_json())
|
|
||||||
log.info("recipe_llm: job %s completed (%d suggestion(s))", recipe_job_id, len(result.suggestions))
|
|
||||||
except Exception as exc:
|
|
||||||
store.fail_recipe_job(recipe_job_id, str(exc))
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
# app/tasks/scheduler.py
|
# app/tasks/scheduler.py
|
||||||
"""Kiwi LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler.
|
"""Kiwi LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler."""
|
||||||
|
|
||||||
Local mode (CLOUD_MODE unset): LocalScheduler — simple FIFO, no coordinator.
|
|
||||||
Cloud mode (CLOUD_MODE=true): OrchestratedScheduler — coordinator-aware, fans
|
|
||||||
out concurrent jobs across all registered cf-orch GPU nodes.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -12,68 +7,15 @@ from pathlib import Path
|
||||||
from circuitforge_core.tasks.scheduler import (
|
from circuitforge_core.tasks.scheduler import (
|
||||||
TaskScheduler,
|
TaskScheduler,
|
||||||
get_scheduler as _base_get_scheduler,
|
get_scheduler as _base_get_scheduler,
|
||||||
reset_scheduler as _reset_local, # re-export for tests
|
reset_scheduler, # re-export for tests
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.cloud_session import CLOUD_MODE
|
|
||||||
from app.core.config import settings
|
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
|
||||||
|
|
||||||
|
|
||||||
def _orch_available() -> bool:
|
|
||||||
"""Return True if circuitforge_orch is installed in this environment."""
|
|
||||||
try:
|
|
||||||
import circuitforge_orch # noqa: F401
|
|
||||||
return True
|
|
||||||
except ImportError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _use_orch() -> bool:
|
|
||||||
"""Return True if the OrchestratedScheduler should be used.
|
|
||||||
|
|
||||||
Priority order:
|
|
||||||
1. USE_ORCH_SCHEDULER env var — explicit override always wins.
|
|
||||||
2. CLOUD_MODE=true — use orch in managed cloud deployments.
|
|
||||||
3. circuitforge_orch installed — paid+ local users who have cf-orch
|
|
||||||
set up get coordinator-aware scheduling (local GPU first) automatically.
|
|
||||||
"""
|
|
||||||
override = settings.USE_ORCH_SCHEDULER
|
|
||||||
if override is not None:
|
|
||||||
return override
|
|
||||||
return CLOUD_MODE or _orch_available()
|
|
||||||
|
|
||||||
|
|
||||||
def get_scheduler(db_path: Path) -> TaskScheduler:
|
def get_scheduler(db_path: Path) -> TaskScheduler:
|
||||||
"""Return the process-level TaskScheduler singleton for Kiwi.
|
"""Return the process-level TaskScheduler singleton for Kiwi."""
|
||||||
|
|
||||||
OrchestratedScheduler: coordinator-aware, fans out concurrent jobs across
|
|
||||||
all registered cf-orch GPU nodes. Active when USE_ORCH_SCHEDULER=true,
|
|
||||||
CLOUD_MODE=true, or circuitforge_orch is installed locally (paid+ users
|
|
||||||
running their own cf-orch stack get this automatically; local GPU is
|
|
||||||
preferred by the coordinator's allocation queue).
|
|
||||||
|
|
||||||
LocalScheduler: serial FIFO, no coordinator dependency. Free-tier local
|
|
||||||
installs without circuitforge_orch installed use this automatically.
|
|
||||||
"""
|
|
||||||
if _use_orch():
|
|
||||||
try:
|
|
||||||
from circuitforge_orch.scheduler import get_orch_scheduler
|
|
||||||
except ImportError:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
"circuitforge_orch not installed — falling back to LocalScheduler"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return get_orch_scheduler(
|
|
||||||
db_path=db_path,
|
|
||||||
run_task_fn=run_task,
|
|
||||||
task_types=LLM_TASK_TYPES,
|
|
||||||
vram_budgets=VRAM_BUDGETS,
|
|
||||||
coordinator_url=settings.COORDINATOR_URL,
|
|
||||||
service_name="kiwi",
|
|
||||||
)
|
|
||||||
|
|
||||||
return _base_get_scheduler(
|
return _base_get_scheduler(
|
||||||
db_path=db_path,
|
db_path=db_path,
|
||||||
run_task_fn=run_task,
|
run_task_fn=run_task,
|
||||||
|
|
@ -82,15 +24,3 @@ def get_scheduler(db_path: Path) -> TaskScheduler:
|
||||||
coordinator_url=settings.COORDINATOR_URL,
|
coordinator_url=settings.COORDINATOR_URL,
|
||||||
service_name="kiwi",
|
service_name="kiwi",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def reset_scheduler() -> None:
|
|
||||||
"""Shut down and clear the active scheduler singleton. TEST TEARDOWN ONLY."""
|
|
||||||
if _use_orch():
|
|
||||||
try:
|
|
||||||
from circuitforge_orch.scheduler import reset_orch_scheduler
|
|
||||||
reset_orch_scheduler()
|
|
||||||
return
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
_reset_local()
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ services:
|
||||||
environment:
|
environment:
|
||||||
CLOUD_MODE: "true"
|
CLOUD_MODE: "true"
|
||||||
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
|
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
|
||||||
RECIPE_DB_PATH: /devl/kiwi-corpus/recipes.db
|
|
||||||
KIWI_BASE_URL: https://menagerie.circuitforge.tech/kiwi
|
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).
|
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
|
||||||
|
|
@ -28,8 +27,6 @@ services:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
||||||
# Recipe corpus — shared read-only NFS-backed SQLite (3.1M recipes, 2.9GB)
|
|
||||||
- /Library/Assets/kiwi/kiwi.db:/devl/kiwi-corpus/recipes.db:ro
|
|
||||||
# LLM config — shared with other CF products; read-only in container
|
# LLM config — shared with other CF products; read-only in container
|
||||||
- ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro
|
- ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ services:
|
||||||
# Docker can follow the symlink inside the container.
|
# Docker can follow the symlink inside the container.
|
||||||
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
|
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
|
||||||
|
|
||||||
# cf-orch agent sidecar: registers this machine as GPU node "sif" with the coordinator.
|
# cf-orch agent sidecar: registers kiwi as a GPU node with the coordinator.
|
||||||
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
|
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
|
||||||
# agent makes the local VRAM usage visible on the orchestrator dashboard.
|
# agent makes kiwi's VRAM usage visible on the orchestrator dashboard.
|
||||||
cf-orch-agent:
|
cf-orch-agent:
|
||||||
image: kiwi-api # reuse local api image — cf-core already installed there
|
image: kiwi-api # reuse local api image — cf-core already installed there
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|
@ -21,7 +21,7 @@ services:
|
||||||
command: >
|
command: >
|
||||||
conda run -n kiwi cf-orch agent
|
conda run -n kiwi cf-orch agent
|
||||||
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
||||||
--node-id sif
|
--node-id kiwi
|
||||||
--host 0.0.0.0
|
--host 0.0.0.0
|
||||||
--port 7702
|
--port 7702
|
||||||
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}
|
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
# Kiwi — LLM backend configuration
|
|
||||||
#
|
|
||||||
# Copy to ~/.config/circuitforge/llm.yaml (shared across all CF products)
|
|
||||||
# or to config/llm.yaml (Kiwi-local, takes precedence).
|
|
||||||
#
|
|
||||||
# Kiwi uses LLMs for:
|
|
||||||
# - Expiry prediction fallback (unknown products not in the lookup table)
|
|
||||||
# - Meal planning suggestions
|
|
||||||
#
|
|
||||||
# Local inference (Ollama / vLLM) is the default path — no API key required.
|
|
||||||
# BYOK (bring your own key): set api_key_env to point at your API key env var.
|
|
||||||
# cf-orch trunk: set CF_ORCH_URL env var to allocate cf-text on-demand via
|
|
||||||
# the coordinator instead of hitting a static URL.
|
|
||||||
|
|
||||||
backends:
|
|
||||||
ollama:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: true
|
|
||||||
base_url: http://localhost:11434/v1
|
|
||||||
model: llama3.2:3b
|
|
||||||
api_key: ollama
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
vllm:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: http://localhost:8000/v1
|
|
||||||
model: __auto__ # resolved from /v1/models at runtime
|
|
||||||
api_key: ''
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
# ── cf-orch trunk services ──────────────────────────────────────────────────
|
|
||||||
# These allocate via cf-orch rather than connecting to a static URL.
|
|
||||||
# cf-orch starts the service on-demand and returns its live URL.
|
|
||||||
# Set CF_ORCH_URL env var or fill in url below; leave enabled: false if
|
|
||||||
# cf-orch is not deployed in your environment.
|
|
||||||
|
|
||||||
cf_text:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: http://localhost:8008/v1 # fallback when cf-orch is not available
|
|
||||||
model: __auto__
|
|
||||||
api_key: any
|
|
||||||
supports_images: false
|
|
||||||
cf_orch:
|
|
||||||
service: cf-text
|
|
||||||
# model_candidates: leave empty to use the service's default_model,
|
|
||||||
# or specify a catalog alias (e.g. "qwen2.5-3b").
|
|
||||||
model_candidates: []
|
|
||||||
ttl_s: 3600
|
|
||||||
|
|
||||||
# ── Cloud / BYOK ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
anthropic:
|
|
||||||
type: anthropic
|
|
||||||
enabled: false
|
|
||||||
model: claude-haiku-4-5-20251001
|
|
||||||
api_key_env: ANTHROPIC_API_KEY
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
openai:
|
|
||||||
type: openai_compat
|
|
||||||
enabled: false
|
|
||||||
base_url: https://api.openai.com/v1
|
|
||||||
model: gpt-4o-mini
|
|
||||||
api_key_env: OPENAI_API_KEY
|
|
||||||
supports_images: false
|
|
||||||
|
|
||||||
fallback_order:
|
|
||||||
- cf_text
|
|
||||||
- ollama
|
|
||||||
- vllm
|
|
||||||
- anthropic
|
|
||||||
- openai
|
|
||||||
|
|
@ -8,10 +8,8 @@ server {
|
||||||
# Proxy API requests to the FastAPI container via Docker bridge network.
|
# Proxy API requests to the FastAPI container via Docker bridge network.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8512;
|
proxy_pass http://api:8512;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $host;
|
||||||
# Prefer X-Real-IP set by Caddy (real client address); fall back to $remote_addr
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
# when accessed directly on LAN without Caddy in the path.
|
|
||||||
proxy_set_header X-Real-IP $http_x_real_ip;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
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.
|
||||||
|
|
@ -20,22 +18,6 @@ server {
|
||||||
client_max_body_size 20m;
|
client_max_body_size 20m;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Direct-port LAN access (localhost:8515): when VITE_API_BASE='/kiwi', the frontend
|
|
||||||
# builds API calls as /kiwi/api/v1/... — proxy these to the API container.
|
|
||||||
# Through Caddy the /kiwi prefix is stripped before reaching nginx, so this block
|
|
||||||
# is only active for direct-port access without Caddy in the path.
|
|
||||||
# Longer prefix (/kiwi/api/ = 10 chars) beats ^~/kiwi/ (6 chars) per nginx rules.
|
|
||||||
location /kiwi/api/ {
|
|
||||||
rewrite ^/kiwi(/api/.*)$ $1 break;
|
|
||||||
proxy_pass http://api:8512;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Real-IP $http_x_real_ip;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
|
||||||
proxy_set_header X-CF-Session $http_x_cf_session;
|
|
||||||
client_max_body_size 20m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
|
# 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
|
# 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.
|
# at /assets/... in nginx's root. Alias /kiwi/ → root so direct port access works.
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
# Installation
|
|
||||||
|
|
||||||
Kiwi runs as a Docker Compose stack: a FastAPI backend and a Vue 3 frontend served by nginx. No external services are required for the core feature set.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker and Docker Compose
|
|
||||||
- 500 MB disk for images + space for your pantry database
|
|
||||||
|
|
||||||
## Quick setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi
|
|
||||||
cd kiwi
|
|
||||||
cp .env.example .env
|
|
||||||
./manage.sh build
|
|
||||||
./manage.sh start
|
|
||||||
```
|
|
||||||
|
|
||||||
The web UI opens at `http://localhost:8511`. The FastAPI backend is at `http://localhost:8512`.
|
|
||||||
|
|
||||||
## manage.sh commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `./manage.sh start` | Start all services |
|
|
||||||
| `./manage.sh stop` | Stop all services |
|
|
||||||
| `./manage.sh restart` | Restart all services |
|
|
||||||
| `./manage.sh status` | Show running containers |
|
|
||||||
| `./manage.sh logs` | Tail logs (all services) |
|
|
||||||
| `./manage.sh build` | Rebuild images |
|
|
||||||
| `./manage.sh open` | Open browser to the web UI |
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
Copy `.env.example` to `.env` and configure:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Required — generate a random secret
|
|
||||||
SECRET_KEY=your-random-secret-here
|
|
||||||
|
|
||||||
# Optional — LLM backend for AI features (receipt OCR, recipe suggestions)
|
|
||||||
# See LLM Setup guide for details
|
|
||||||
LLM_BACKEND=ollama # ollama | openai-compatible | vllm
|
|
||||||
LLM_BASE_URL=http://localhost:11434
|
|
||||||
LLM_MODEL=llama3.1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data location
|
|
||||||
|
|
||||||
By default, Kiwi stores its SQLite database in `./data/kiwi.db` inside the repo directory. The `data/` folder is bind-mounted into the container so your pantry survives image rebuilds.
|
|
||||||
|
|
||||||
## Updating
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
./manage.sh build
|
|
||||||
./manage.sh restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Database migrations run automatically on startup.
|
|
||||||
|
|
||||||
## Uninstalling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./manage.sh stop
|
|
||||||
docker compose down -v # removes containers and volumes
|
|
||||||
rm -rf data/ # removes local database
|
|
||||||
```
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
# LLM Backend Setup (Optional)
|
|
||||||
|
|
||||||
An LLM backend unlocks **receipt OCR**, **recipe suggestions (L3–L4)**, and **style auto-classification**. Everything else works without one.
|
|
||||||
|
|
||||||
You can use any OpenAI-compatible inference server: Ollama, vLLM, LM Studio, a local llama.cpp server, or a commercial API.
|
|
||||||
|
|
||||||
## BYOK — Bring Your Own Key
|
|
||||||
|
|
||||||
BYOK means you provide your own LLM backend. Paid AI features are unlocked at **any tier** when a valid backend is configured. You pay for your own inference; Kiwi just uses it.
|
|
||||||
|
|
||||||
## Choosing a backend
|
|
||||||
|
|
||||||
| Backend | Best for | Notes |
|
|
||||||
|---------|----------|-------|
|
|
||||||
| **Ollama** | Local, easy setup | Recommended for getting started |
|
|
||||||
| **vLLM** | Local, high throughput | Better for faster hardware |
|
|
||||||
| **OpenAI API** | No local GPU | Requires paid API key |
|
|
||||||
| **Anthropic API** | No local GPU | Requires paid API key |
|
|
||||||
|
|
||||||
## Ollama setup (recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Ollama
|
|
||||||
curl -fsSL https://ollama.ai/install.sh | sh
|
|
||||||
|
|
||||||
# Pull a model — llama3.1 8B works well for recipe tasks
|
|
||||||
ollama pull llama3.1
|
|
||||||
|
|
||||||
# Verify it's running
|
|
||||||
ollama list
|
|
||||||
```
|
|
||||||
|
|
||||||
In your Kiwi `.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
LLM_BACKEND=ollama
|
|
||||||
LLM_BASE_URL=http://host.docker.internal:11434
|
|
||||||
LLM_MODEL=llama3.1
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! note "Docker networking"
|
|
||||||
Use `host.docker.internal` instead of `localhost` when Ollama is running on your host and Kiwi is in Docker.
|
|
||||||
|
|
||||||
## OpenAI-compatible API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
LLM_BACKEND=openai
|
|
||||||
LLM_BASE_URL=https://api.openai.com/v1
|
|
||||||
LLM_API_KEY=sk-your-key-here
|
|
||||||
LLM_MODEL=gpt-4o-mini
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verify the connection
|
|
||||||
|
|
||||||
In the Kiwi **Settings** page, the LLM status indicator shows whether the backend is reachable. A green checkmark means OCR and L3–L4 recipe suggestions are active.
|
|
||||||
|
|
||||||
## What LLM is used for
|
|
||||||
|
|
||||||
| Feature | LLM required |
|
|
||||||
|---------|-------------|
|
|
||||||
| Receipt OCR (line-item extraction) | Yes |
|
|
||||||
| Recipe suggestions L1 (pantry match) | No |
|
|
||||||
| Recipe suggestions L2 (substitution) | No |
|
|
||||||
| Recipe suggestions L3 (style templates) | Yes |
|
|
||||||
| Recipe suggestions L4 (full generation) | Yes |
|
|
||||||
| Style auto-classifier | Yes |
|
|
||||||
|
|
||||||
L1 and L2 suggestions use deterministic matching — they work without any LLM configured. See [Recipe Engine](../reference/recipe-engine.md) for the full algorithm breakdown.
|
|
||||||
|
|
||||||
## Model recommendations
|
|
||||||
|
|
||||||
- **Receipt OCR**: any model with vision capability (LLaVA, GPT-4o, etc.)
|
|
||||||
- **Recipe suggestions**: 7B–13B instruction-tuned models work well; larger models produce more creative L4 output
|
|
||||||
- **Style classification**: small models handle this fine (3B+)
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
# Quick Start
|
|
||||||
|
|
||||||
This guide walks you through adding your first pantry item and getting a recipe suggestion. No LLM backend needed for these steps.
|
|
||||||
|
|
||||||
## 1. Add an item by barcode
|
|
||||||
|
|
||||||
Open the **Inventory** tab. Tap the barcode icon or click **Scan barcode**, then point your camera at a product barcode. Kiwi looks up the product in the open barcode database and adds it to your pantry.
|
|
||||||
|
|
||||||
If the barcode isn't recognized, you'll be prompted to enter the product name and details manually.
|
|
||||||
|
|
||||||
## 2. Add an item manually
|
|
||||||
|
|
||||||
Click **Add item** and fill in:
|
|
||||||
|
|
||||||
- **Name** — what is it? (e.g., "Canned chickpeas")
|
|
||||||
- **Quantity** — how many or how much
|
|
||||||
- **Expiry date** — when does it expire? (optional but recommended)
|
|
||||||
- **Category** — used for dietary filtering and pantry stats
|
|
||||||
|
|
||||||
## 3. Upload a receipt
|
|
||||||
|
|
||||||
Click **Receipts** in the sidebar, then **Upload receipt**. Take a photo of a grocery receipt or upload an image from your device.
|
|
||||||
|
|
||||||
- **Free tier**: the receipt is stored for you to review; line items are entered manually
|
|
||||||
- **Paid / BYOK**: OCR runs automatically and extracts items for you to approve
|
|
||||||
|
|
||||||
## 4. Browse recipes
|
|
||||||
|
|
||||||
Click **Recipes** in the sidebar. The recipe browser shows your **pantry match percentage** for each recipe — how much of the ingredient list you already have.
|
|
||||||
|
|
||||||
Use the filters to narrow by:
|
|
||||||
|
|
||||||
- **Cuisine** — Italian, Mexican, Japanese, etc.
|
|
||||||
- **Meal type** — breakfast, lunch, dinner, snack
|
|
||||||
- **Dietary** — vegetarian, vegan, gluten-free, dairy-free, etc.
|
|
||||||
- **Main ingredient** — chicken, pasta, lentils, etc.
|
|
||||||
|
|
||||||
## 5. Get a suggestion based on what's expiring
|
|
||||||
|
|
||||||
Click **Leftover mode** (the clock icon or toggle). Kiwi re-ranks suggestions to surface recipes that use your nearly-expired items first.
|
|
||||||
|
|
||||||
Free accounts get 5 leftover-mode requests per day. Paid accounts get unlimited.
|
|
||||||
|
|
||||||
## 6. Save a recipe
|
|
||||||
|
|
||||||
Click the bookmark icon on any recipe card to save it. You can add:
|
|
||||||
|
|
||||||
- **Notes** — cooking tips, modifications, family preferences
|
|
||||||
- **Star rating** — 0 to 5 stars
|
|
||||||
- **Style tags** — quick, comforting, weeknight, etc.
|
|
||||||
|
|
||||||
Saved recipes appear in the **Saved** tab. Paid accounts can organize them into named collections.
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
# Kiwi — Pantry Tracker
|
|
||||||
|
|
||||||
**Stop throwing food away. Cook what you already have.**
|
|
||||||
|
|
||||||
Kiwi tracks your pantry, watches for expiry dates, and suggests recipes based on what's about to go bad. Scan barcodes, photograph receipts, and let Kiwi tell you what to make for dinner — without needing an AI backend to do it.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Kiwi does
|
|
||||||
|
|
||||||
- **Inventory tracking** — add items by barcode scan, receipt photo, or manual entry
|
|
||||||
- **Expiry alerts** — know what's about to go bad before it does
|
|
||||||
- **Recipe browser** — browse by cuisine, meal type, dietary preference, or main ingredient; see pantry match percentage inline
|
|
||||||
- **Leftover mode** — prioritize nearly-expired items when getting recipe suggestions
|
|
||||||
- **Receipt OCR** — extract line items from receipt photos automatically (Paid / BYOK)
|
|
||||||
- **Recipe suggestions** — four levels from pantry-match corpus to full LLM generation (Paid / BYOK)
|
|
||||||
- **Saved recipes** — bookmark any recipe with notes, 0–5 star rating, and style tags
|
|
||||||
- **CSV export** — export your full pantry inventory anytime
|
|
||||||
|
|
||||||
## Quick links
|
|
||||||
|
|
||||||
- [Installation](getting-started/installation.md) — local self-hosted setup
|
|
||||||
- [Quick Start](getting-started/quick-start.md) — add your first item and get a recipe
|
|
||||||
- [LLM Setup](getting-started/llm-setup.md) — unlock AI features with your own backend
|
|
||||||
- [Tier System](reference/tier-system.md) — what's free vs. paid
|
|
||||||
|
|
||||||
## No AI required
|
|
||||||
|
|
||||||
Inventory tracking, barcode scanning, expiry alerts, the recipe browser, saved recipes, and CSV export all work without any LLM configured. AI features (receipt OCR, recipe suggestions, style auto-classification) are optional and BYOK-unlockable at any tier.
|
|
||||||
|
|
||||||
## Free and open core
|
|
||||||
|
|
||||||
Discovery and pipeline code is MIT-licensed. AI features are BSL 1.1 — free for personal non-commercial self-hosting, commercial SaaS requires a license. See the [tier table](reference/tier-system.md) for the full breakdown.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
(function(){var s=document.createElement("script");s.defer=true;s.dataset.domain="docs.circuitforge.tech,circuitforge.tech";s.dataset.api="https://analytics.circuitforge.tech/api/event";s.src="https://analytics.circuitforge.tech/js/script.js";document.head.appendChild(s);})();
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
# Architecture
|
|
||||||
|
|
||||||
Kiwi is a self-contained Docker Compose stack with a Vue 3 (SPA) frontend and a FastAPI backend backed by SQLite.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
|-------|-----------|
|
|
||||||
| Frontend | Vue 3 + TypeScript + Vite |
|
|
||||||
| Backend | FastAPI (Python 3.11+) |
|
|
||||||
| Database | SQLite (via circuitforge-core) |
|
|
||||||
| Auth (cloud) | CF session cookie → Directus JWT |
|
|
||||||
| Licensing | Heimdall (RS256 JWT, offline-capable) |
|
|
||||||
| LLM inference | Pluggable — Ollama, vLLM, OpenAI-compatible |
|
|
||||||
| Barcode lookup | Open Food Facts / UPC Database API |
|
|
||||||
| OCR | LLM vision model (configurable) |
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
User -->|browser| Vue3[Vue 3 SPA]
|
|
||||||
Vue3 -->|/api/*| FastAPI
|
|
||||||
FastAPI -->|SQL| SQLite[(SQLite DB)]
|
|
||||||
FastAPI -->|HTTP| LLM[LLM Backend]
|
|
||||||
FastAPI -->|HTTP| Barcode[Barcode DB API]
|
|
||||||
FastAPI -->|JWT| Heimdall[Heimdall License]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Compose services
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
api:
|
|
||||||
# FastAPI backend — network_mode: host in dev
|
|
||||||
# Exposed at port 8512
|
|
||||||
web:
|
|
||||||
# Vue 3 SPA served by nginx
|
|
||||||
# Exposed at port 8511
|
|
||||||
```
|
|
||||||
|
|
||||||
In development, the API uses host networking so nginx can reach it at `172.17.0.1:8512` (Docker bridge gateway).
|
|
||||||
|
|
||||||
## Database
|
|
||||||
|
|
||||||
SQLite at `./data/kiwi.db`. The schema is managed by numbered migration files in `app/db/migrations/`. Migrations run automatically on startup — the startup script applies any new `*.sql` files in order.
|
|
||||||
|
|
||||||
Key tables:
|
|
||||||
|
|
||||||
| Table | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `products` | Product catalog (shared, barcode-keyed) |
|
|
||||||
| `pantry_items` | User's pantry (quantity, expiry, notes) |
|
|
||||||
| `recipes` | Recipe corpus |
|
|
||||||
| `saved_recipes` | User-bookmarked recipes |
|
|
||||||
| `collections` | Named recipe collections (Paid) |
|
|
||||||
| `receipts` | Receipt uploads and OCR results |
|
|
||||||
| `user_preferences` | User settings (dietary, LLM config) |
|
|
||||||
|
|
||||||
## Cloud mode
|
|
||||||
|
|
||||||
In cloud mode (managed instance at `menagerie.circuitforge.tech/kiwi`), each user gets their own SQLite database isolated under `/devl/kiwi-cloud-data/<user_id>/kiwi.db`. The cloud compose stack adds:
|
|
||||||
|
|
||||||
- `CLOUD_MODE=true` environment variable
|
|
||||||
- Directus JWT validation for session resolution
|
|
||||||
- Heimdall tier check on AI feature endpoints
|
|
||||||
|
|
||||||
The same codebase runs in both local and cloud modes — the cloud session middleware is a thin wrapper around the local auth logic.
|
|
||||||
|
|
||||||
## LLM integration
|
|
||||||
|
|
||||||
Kiwi uses `circuitforge-core`'s LLM router, which abstracts over Ollama, vLLM, and OpenAI-compatible APIs. The router is configured via environment variables at startup. All LLM calls are asynchronous and non-blocking — if the backend is unavailable, Kiwi falls back to the highest deterministic level (L2) and returns results without waiting.
|
|
||||||
|
|
||||||
## Privacy
|
|
||||||
|
|
||||||
- No PII is logged in production
|
|
||||||
- Pantry data stays on your machine in self-hosted mode
|
|
||||||
- Cloud mode: data stored per-user on Heimdall server, not shared with third parties, not used for training
|
|
||||||
- LLM calls include pantry context in the prompt — if using a cloud API, that context leaves your machine
|
|
||||||
- Using a local LLM backend (Ollama, vLLM) keeps all data on-device
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
# Recipe Engine
|
|
||||||
|
|
||||||
Kiwi uses a four-level recipe suggestion system. Each level adds more intelligence and better results, but requires more resources. Levels 1–2 are fully deterministic and work without any LLM. Levels 3–4 require an LLM backend.
|
|
||||||
|
|
||||||
## Level overview
|
|
||||||
|
|
||||||
| Level | Name | LLM required | Description |
|
|
||||||
|-------|------|-------------|-------------|
|
|
||||||
| L1 | Pantry match | No | Rank existing corpus by ingredient overlap |
|
|
||||||
| L2 | Substitution | No | Suggest swaps for missing ingredients |
|
|
||||||
| L3 | Style templates | Yes | Generate recipe variations from style templates |
|
|
||||||
| L4 | Full generation | Yes | Generate new recipes from scratch |
|
|
||||||
|
|
||||||
## L1 — Pantry match
|
|
||||||
|
|
||||||
The simplest level. Kiwi scores every recipe in the corpus by how many of its ingredients you already have:
|
|
||||||
|
|
||||||
```
|
|
||||||
score = (matched ingredients) / (total ingredients)
|
|
||||||
```
|
|
||||||
|
|
||||||
Recipes are sorted by this score descending. If leftover mode is active, the score is further weighted by expiry proximity.
|
|
||||||
|
|
||||||
This works entirely offline with no LLM — just set arithmetic on your current pantry.
|
|
||||||
|
|
||||||
## L2 — Substitution
|
|
||||||
|
|
||||||
L2 extends L1 by suggesting substitutions for missing ingredients. When a recipe scores well but you're missing one or two items, Kiwi checks a substitution table to see if something in your pantry could stand in:
|
|
||||||
|
|
||||||
- Buttermilk → plain yogurt + lemon juice
|
|
||||||
- Heavy cream → evaporated milk
|
|
||||||
- Fresh herbs → dried herbs (adjusted quantity)
|
|
||||||
|
|
||||||
Substitutions are sourced from a curated table — no LLM involved. L2 raises the effective match score for recipes where a reasonable substitute exists.
|
|
||||||
|
|
||||||
## L3 — Style templates
|
|
||||||
|
|
||||||
L3 uses the LLM to generate recipe variations from a style template. Rather than generating fully free-form text, it fills in a structured template:
|
|
||||||
|
|
||||||
```
|
|
||||||
[protein] + [vegetable] + [starch] + [sauce/flavor profile]
|
|
||||||
```
|
|
||||||
|
|
||||||
The template is populated from your pantry contents and the style tags you've set (e.g., "quick", "Italian"). The LLM fills in the techniques, proportions, and instructions.
|
|
||||||
|
|
||||||
Style templates produce consistent, practical results with less hallucination risk than fully open-ended generation.
|
|
||||||
|
|
||||||
## L4 — Full generation
|
|
||||||
|
|
||||||
L4 gives the LLM full creative freedom. Kiwi passes:
|
|
||||||
|
|
||||||
- Your full pantry inventory
|
|
||||||
- Your dietary preferences
|
|
||||||
- Any expiring items (if leftover mode is active)
|
|
||||||
- Your saved recipe history and style tags
|
|
||||||
|
|
||||||
The LLM generates a new recipe optimized for your situation. Results are more creative than L1–L3 but require a capable model (7B+ recommended) and take longer to generate.
|
|
||||||
|
|
||||||
## Escalation
|
|
||||||
|
|
||||||
When you click **Suggest**, Kiwi tries each level in order and returns results as soon as a level produces usable output:
|
|
||||||
|
|
||||||
1. L1 and L2 run immediately (no LLM)
|
|
||||||
2. If no good matches exist (all scores < 30%), Kiwi escalates to L3
|
|
||||||
3. If L3 produces no results (LLM unavailable or error), Kiwi falls back to best L1 result
|
|
||||||
4. L4 is only triggered explicitly by the user ("Generate something new")
|
|
||||||
|
|
||||||
## Tier gates
|
|
||||||
|
|
||||||
| Level | Free | Paid | BYOK (any tier) |
|
|
||||||
|-------|------|------|-----------------|
|
|
||||||
| L1 — Pantry match | ✓ | ✓ | ✓ |
|
|
||||||
| L2 — Substitution | ✓ | ✓ | ✓ |
|
|
||||||
| L3 — Style templates | — | ✓ | ✓ |
|
|
||||||
| L4 — Full generation | — | ✓ | ✓ |
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
# Tier System
|
|
||||||
|
|
||||||
Kiwi uses CircuitForge's standard four-tier model. The free tier covers the full pantry tracking workflow. AI features are gated behind Paid or BYOK.
|
|
||||||
|
|
||||||
## Feature matrix
|
|
||||||
|
|
||||||
| Feature | Free | Paid | Premium |
|
|
||||||
|---------|------|------|---------|
|
|
||||||
| **Inventory** | | | |
|
|
||||||
| Inventory CRUD | ✓ | ✓ | ✓ |
|
|
||||||
| Barcode scan | ✓ | ✓ | ✓ |
|
|
||||||
| Receipt upload | ✓ | ✓ | ✓ |
|
|
||||||
| Expiry alerts | ✓ | ✓ | ✓ |
|
|
||||||
| CSV export | ✓ | ✓ | ✓ |
|
|
||||||
| **Recipes** | | | |
|
|
||||||
| Recipe browser | ✓ | ✓ | ✓ |
|
|
||||||
| Pantry match (L1) | ✓ | ✓ | ✓ |
|
|
||||||
| Substitution (L2) | ✓ | ✓ | ✓ |
|
|
||||||
| Style templates (L3) | BYOK | ✓ | ✓ |
|
|
||||||
| Full generation (L4) | BYOK | ✓ | ✓ |
|
|
||||||
| Leftover mode | 5/day | Unlimited | Unlimited |
|
|
||||||
| **Saved recipes** | | | |
|
|
||||||
| Save + notes + star rating | ✓ | ✓ | ✓ |
|
|
||||||
| Style tags (manual) | ✓ | ✓ | ✓ |
|
|
||||||
| LLM style auto-classifier | — | BYOK | ✓ |
|
|
||||||
| Named collections | — | ✓ | ✓ |
|
|
||||||
| Meal planning | — | ✓ | ✓ |
|
|
||||||
| **OCR** | | | |
|
|
||||||
| Receipt OCR | BYOK | ✓ | ✓ |
|
|
||||||
| **Account** | | | |
|
|
||||||
| Multi-household | — | — | ✓ |
|
|
||||||
|
|
||||||
**BYOK** = Bring Your Own LLM backend. Configure a local or cloud inference endpoint and these features activate at any tier. See [LLM Setup](../getting-started/llm-setup.md).
|
|
||||||
|
|
||||||
## Pricing
|
|
||||||
|
|
||||||
| Tier | Monthly | Lifetime |
|
|
||||||
|------|---------|----------|
|
|
||||||
| Free | $0 | — |
|
|
||||||
| Paid | $8/mo | $129 |
|
|
||||||
| Premium | $16/mo | $249 |
|
|
||||||
|
|
||||||
Lifetime licenses are available at [circuitforge.tech](https://circuitforge.tech).
|
|
||||||
|
|
||||||
## Self-hosting
|
|
||||||
|
|
||||||
Self-hosted Kiwi is free under the MIT license (inventory/pipeline) and BSL 1.1 (AI features, free for personal non-commercial use). You run it on your own hardware with your own LLM backend. No subscription required.
|
|
||||||
|
|
||||||
The cloud-managed instance at `menagerie.circuitforge.tech/kiwi` runs the same codebase and requires a CircuitForge account.
|
|
||||||
|
|
||||||
## Free key
|
|
||||||
|
|
||||||
Claim a free Paid-tier key (30 days) at [circuitforge.tech](https://circuitforge.tech/free-key). No credit card required.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB |
|
|
@ -1,39 +0,0 @@
|
||||||
# Barcode Scanning
|
|
||||||
|
|
||||||
Kiwi's barcode scanner uses your device camera to look up products instantly. It works for UPC-A, UPC-E, EAN-13, EAN-8, and QR codes on packaged foods.
|
|
||||||
|
|
||||||
## How to scan
|
|
||||||
|
|
||||||
1. Open the **Inventory** tab
|
|
||||||
2. Click the **Scan barcode** button (camera icon)
|
|
||||||
3. Hold the barcode in the camera frame
|
|
||||||
4. Kiwi decodes it and looks up the product
|
|
||||||
|
|
||||||
## What happens after a scan
|
|
||||||
|
|
||||||
**Product found in database:**
|
|
||||||
Kiwi fills in the product name, category, and any nutritional metadata from the open barcode database. You confirm the quantity and expiry date, then save.
|
|
||||||
|
|
||||||
**Product not found:**
|
|
||||||
You'll see a manual entry form with the raw barcode pre-filled. Add a name and the product is saved to your personal pantry (not contributed to the shared database).
|
|
||||||
|
|
||||||
## Supported formats
|
|
||||||
|
|
||||||
| Format | Common use |
|
|
||||||
|--------|-----------|
|
|
||||||
| UPC-A (12 digit) | US grocery products |
|
|
||||||
| EAN-13 (13 digit) | International grocery products |
|
|
||||||
| UPC-E (compressed) | Small packaging |
|
|
||||||
| EAN-8 | Small packaging |
|
|
||||||
| QR Code | Some specialty products |
|
|
||||||
|
|
||||||
## Tips for reliable scanning
|
|
||||||
|
|
||||||
- **Good lighting**: scanning works best in well-lit conditions
|
|
||||||
- **Steady hand**: hold the camera still for 1–2 seconds
|
|
||||||
- **Fill the frame**: bring the barcode close enough to fill most of the camera view
|
|
||||||
- **Flat surface**: wrinkled or curved barcodes are harder to decode
|
|
||||||
|
|
||||||
## Manual barcode entry
|
|
||||||
|
|
||||||
If camera scanning isn't available (browser permissions denied, no camera, etc.), you can type the barcode number directly into the **Barcode** field on the manual add form.
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Inventory
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The inventory is your pantry. Every item you add gives Kiwi the data it needs to show pantry match percentages, flag expiry, and rank recipe suggestions.
|
|
||||||
|
|
||||||
## Adding items
|
|
||||||
|
|
||||||
### By barcode
|
|
||||||
|
|
||||||
Tap the barcode scanner icon. Point your camera at the barcode on the product. Kiwi checks the open barcode database and fills in the product name and category.
|
|
||||||
|
|
||||||
If the product isn't in the database, you'll see a manual entry form pre-filled with whatever was decoded from the barcode — just add a name and save.
|
|
||||||
|
|
||||||
### By receipt
|
|
||||||
|
|
||||||
Upload a receipt photo in the **Receipts** tab. After the receipt is processed, approved items are added to your pantry in bulk. See [Receipt OCR](receipt-ocr.md) for details.
|
|
||||||
|
|
||||||
### Manually
|
|
||||||
|
|
||||||
Click **Add item** and fill in:
|
|
||||||
|
|
||||||
| Field | Required | Notes |
|
|
||||||
|-------|----------|-------|
|
|
||||||
| Name | Yes | What is it? |
|
|
||||||
| Quantity | Yes | Number + unit (e.g., "2 cans", "500 g") |
|
|
||||||
| Expiry date | No | Used for expiry alerts and leftover mode |
|
|
||||||
| Category | No | Helps with dietary filtering |
|
|
||||||
| Notes | No | Storage instructions, opened date, etc. |
|
|
||||||
|
|
||||||
## Editing and deleting
|
|
||||||
|
|
||||||
Click any item in the list to edit its quantity, expiry date, or notes. Items can be deleted individually or in bulk via the selection checkbox.
|
|
||||||
|
|
||||||
## Expiry alerts
|
|
||||||
|
|
||||||
Kiwi flags items approaching expiry with a color indicator:
|
|
||||||
|
|
||||||
- **Red**: expires within 2 days
|
|
||||||
- **Orange**: expires within 7 days
|
|
||||||
- **Yellow**: expires within 14 days
|
|
||||||
|
|
||||||
The **Leftover mode** uses this same expiry window to prioritize nearly-expired items in recipe rankings.
|
|
||||||
|
|
||||||
## Inventory stats
|
|
||||||
|
|
||||||
The stats panel (top of the Inventory page) shows:
|
|
||||||
|
|
||||||
- Total items in pantry
|
|
||||||
- Items expiring this week
|
|
||||||
- Breakdown by category
|
|
||||||
- Items added this month
|
|
||||||
|
|
||||||
## CSV export
|
|
||||||
|
|
||||||
Click **Export** to download your full pantry as a CSV file. The export includes name, quantity, category, expiry date, and notes for every item.
|
|
||||||
|
|
||||||
## Bulk operations
|
|
||||||
|
|
||||||
- Select multiple items with the checkbox column
|
|
||||||
- **Delete selected** — remove items in bulk
|
|
||||||
- **Mark as used** — remove items you've cooked with (coming in Phase 3)
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# Leftover Mode
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Leftover mode re-ranks recipe suggestions to surface dishes that use your nearly-expired items first. It's the fastest way to answer "what should I cook before this goes bad?"
|
|
||||||
|
|
||||||
## Activating leftover mode
|
|
||||||
|
|
||||||
Click the **clock icon** or the **Leftover mode** toggle in the recipe browser. The recipe list immediately re-sorts to prioritize recipes that use items expiring within the next 7 days.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
When leftover mode is active, Kiwi weights the pantry match score toward items closer to their expiry date. A recipe that uses your 3-day-old spinach and day-old mushrooms ranks higher than a recipe that only uses shelf-stable pantry staples — even if the pantry match percentage is similar.
|
|
||||||
|
|
||||||
Items without an expiry date set are not weighted for leftover mode purposes. Setting expiry dates when you add items makes leftover mode much more useful.
|
|
||||||
|
|
||||||
## Rate limits
|
|
||||||
|
|
||||||
| Tier | Leftover mode requests |
|
|
||||||
|------|----------------------|
|
|
||||||
| Free | 5 per day |
|
|
||||||
| Paid | Unlimited |
|
|
||||||
| Premium | Unlimited |
|
|
||||||
|
|
||||||
A "request" is each time you activate leftover mode or click **Refresh**. The re-sort count resets at midnight.
|
|
||||||
|
|
||||||
## What counts as "nearly expired"
|
|
||||||
|
|
||||||
The leftover mode window uses the same thresholds as the expiry indicators:
|
|
||||||
|
|
||||||
- **Expiring within 2 days** — highest priority
|
|
||||||
- **Expiring within 7 days** — elevated priority
|
|
||||||
- **Expiring within 14 days** — mildly elevated priority
|
|
||||||
|
|
||||||
Items past their expiry date are still included (Kiwi doesn't remove them automatically) but displayed with a red indicator. Use your judgment — some items are fine past date, others aren't.
|
|
||||||
|
|
||||||
## Combining with filters
|
|
||||||
|
|
||||||
Leftover mode stacks with the dietary and cuisine filters. You can activate leftover mode and filter by "Vegetarian" or "Under 30 minutes" to narrow down to recipes that both use expiring items and match your constraints.
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
# Receipt OCR
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Receipt OCR automatically extracts grocery line items from a photo of your receipt and adds them to your pantry after you approve. It's available on the Paid tier and BYOK-unlockable on Free.
|
|
||||||
|
|
||||||
## Upload a receipt
|
|
||||||
|
|
||||||
1. Click **Receipts** in the sidebar
|
|
||||||
2. Click **Upload receipt**
|
|
||||||
3. Take a photo or select an image from your device
|
|
||||||
|
|
||||||
Supported formats: JPEG, PNG, HEIC, WebP. Maximum file size: 10 MB.
|
|
||||||
|
|
||||||
## How OCR processing works
|
|
||||||
|
|
||||||
When a receipt is uploaded:
|
|
||||||
|
|
||||||
1. **OCR runs** — the LLM reads the receipt image and identifies line items, quantities, and prices
|
|
||||||
2. **Review screen** — you see each extracted item with its detected quantity
|
|
||||||
3. **Approve or edit** — correct any mistakes, remove items you don't want tracked
|
|
||||||
4. **Confirm** — approved items are added to your pantry in bulk
|
|
||||||
|
|
||||||
The whole flow is designed around human approval — Kiwi never silently adds items to your pantry. You always see what's being imported and can adjust before confirming.
|
|
||||||
|
|
||||||
## Reviewing extracted items
|
|
||||||
|
|
||||||
Each extracted line item shows:
|
|
||||||
|
|
||||||
- **Product name** — as extracted from the receipt
|
|
||||||
- **Quantity** — detected from the receipt text (e.g., "2 × Canned Tomatoes")
|
|
||||||
- **Confidence** — how certain the OCR is about this item
|
|
||||||
- **Edit** — correct the name or quantity inline
|
|
||||||
- **Remove** — exclude this item from the import
|
|
||||||
|
|
||||||
Low-confidence items are flagged with a yellow indicator. Review those carefully — store abbreviations and handwriting can trip up the extractor.
|
|
||||||
|
|
||||||
## Free tier behavior
|
|
||||||
|
|
||||||
On the Free tier without a BYOK backend configured:
|
|
||||||
|
|
||||||
- Receipts are stored and displayed
|
|
||||||
- OCR does **not** run automatically
|
|
||||||
- You can enter items from the receipt manually using the item list view
|
|
||||||
|
|
||||||
To enable automatic OCR on Free tier, configure a [BYOK LLM backend](../getting-started/llm-setup.md).
|
|
||||||
|
|
||||||
## Tips for better results
|
|
||||||
|
|
||||||
- **Flatten the receipt**: lay it on a flat surface rather than crumpling
|
|
||||||
- **Include the full receipt**: get all four edges in frame
|
|
||||||
- **Good lighting**: avoid glare on thermal paper
|
|
||||||
- **Fresh receipts**: faded thermal receipts (older than a few months) are harder to read
|
|
||||||
|
|
||||||
## Re-running OCR
|
|
||||||
|
|
||||||
If OCR produced poor results, you can trigger a re-run from the receipt detail view. Each re-run uses a fresh extraction — previous results are discarded.
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
# Recipe Browser
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The recipe browser lets you explore the full recipe corpus filtered by cuisine, meal type, dietary preference, and main ingredient. Your **pantry match percentage** is shown on every recipe card so you can see at a glance what you can cook tonight.
|
|
||||||
|
|
||||||
## Browsing by domain
|
|
||||||
|
|
||||||
The recipe corpus is organized into three domains:
|
|
||||||
|
|
||||||
| Domain | Examples |
|
|
||||||
|--------|---------|
|
|
||||||
| **Cuisine** | Italian, Mexican, Japanese, Indian, Mediterranean, West African, ... |
|
|
||||||
| **Meal type** | Breakfast, Lunch, Dinner, Snack, Dessert, Drink |
|
|
||||||
| **Dietary** | Vegetarian, Vegan, Gluten-free, Dairy-free, Low-carb, Nut-free |
|
|
||||||
|
|
||||||
Click a domain tile to see its categories. Click a category to browse the recipes inside it.
|
|
||||||
|
|
||||||
## Pantry match percentage
|
|
||||||
|
|
||||||
Every recipe card shows what percentage of the ingredient list you already have in your pantry. This updates as your inventory changes.
|
|
||||||
|
|
||||||
- **100%**: you have everything — cook it now
|
|
||||||
- **70–99%**: almost there, minor shopping needed
|
|
||||||
- **< 50%**: you'd need to buy most of the ingredients
|
|
||||||
|
|
||||||
## Filtering
|
|
||||||
|
|
||||||
Use the filter bar to narrow results:
|
|
||||||
|
|
||||||
- **Dietary** — show only recipes matching your dietary preferences
|
|
||||||
- **Min pantry match** — hide recipes below a match threshold
|
|
||||||
- **Time** — prep + cook time total
|
|
||||||
- **Sort** — by pantry match (default), alphabetical, or rating (for saved recipes)
|
|
||||||
|
|
||||||
## Recipe detail
|
|
||||||
|
|
||||||
Click any recipe card to open the full recipe:
|
|
||||||
|
|
||||||
- Ingredient list with **in pantry / not in pantry** indicators
|
|
||||||
- Step-by-step instructions
|
|
||||||
- Substitution suggestions for missing ingredients
|
|
||||||
- Nutritional summary
|
|
||||||
- **Bookmark** button to save with notes and rating
|
|
||||||
|
|
||||||
## Getting suggestions
|
|
||||||
|
|
||||||
The recipe browser shows the **full corpus** sorted by pantry match. For AI-powered suggestions tailored to what's expiring, use [Leftover Mode](leftover-mode.md) or the **Suggest** button (Paid / BYOK).
|
|
||||||
|
|
||||||
See [Recipe Engine](../reference/recipe-engine.md) for how the four suggestion levels work.
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
# Saved Recipes
|
|
||||||
|
|
||||||
Save any recipe from the browser to your personal collection. Add notes, a star rating, and style tags to build a library of recipes you love.
|
|
||||||
|
|
||||||
## Saving a recipe
|
|
||||||
|
|
||||||
Click the **bookmark icon** on any recipe card or the **Save** button in the recipe detail view. The recipe is immediately saved to your **Saved** tab.
|
|
||||||
|
|
||||||
## Notes and ratings
|
|
||||||
|
|
||||||
On each saved recipe you can add:
|
|
||||||
|
|
||||||
- **Notes** — your modifications, family feedback, what you'd change next time
|
|
||||||
- **Star rating** — 0 to 5 stars; used to sort your collection
|
|
||||||
- **Style tags** — free-text labels like "quick", "comforting", "weeknight", "meal prep"
|
|
||||||
|
|
||||||
Click the pencil icon on a saved recipe to edit these fields.
|
|
||||||
|
|
||||||
## Style tags
|
|
||||||
|
|
||||||
Style tags are free-text — type anything that helps you find the recipe later. Common tags used by Kiwi users:
|
|
||||||
|
|
||||||
`quick` · `weeknight` · `comforting` · `meal prep` · `kid-friendly` · `hands-off` · `summer` · `one-pot`
|
|
||||||
|
|
||||||
**Paid tier and above:** the LLM style auto-classifier can suggest tags based on the recipe's ingredients and instructions. Click **Auto-tag** on any saved recipe to get suggestions you can accept or dismiss.
|
|
||||||
|
|
||||||
## Collections (Paid)
|
|
||||||
|
|
||||||
On the Paid tier, you can organize saved recipes into named collections:
|
|
||||||
|
|
||||||
1. Click **New collection** in the Saved tab
|
|
||||||
2. Give it a name (e.g., "Weeknight dinners", "Holiday baking")
|
|
||||||
3. Add recipes to the collection from the saved recipe list or directly when saving
|
|
||||||
|
|
||||||
Collections are listed in the sidebar of the Saved tab. A recipe can belong to multiple collections.
|
|
||||||
|
|
||||||
## Sorting and filtering saved recipes
|
|
||||||
|
|
||||||
Sort by:
|
|
||||||
- **Date saved** (newest first, default)
|
|
||||||
- **Star rating** (highest first)
|
|
||||||
- **Pantry match** (how many ingredients you currently have)
|
|
||||||
- **Alphabetical**
|
|
||||||
|
|
||||||
Filter by:
|
|
||||||
- **Collection** (Paid)
|
|
||||||
- **Style tag**
|
|
||||||
- **Star rating** (e.g., show only 4+ star recipes)
|
|
||||||
- **Dietary**
|
|
||||||
|
|
||||||
## Removing a recipe
|
|
||||||
|
|
||||||
Click the bookmark icon again (or the **Remove** button in the detail view) to unsave a recipe. Your notes and rating are lost when you unsave — there's no archive.
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
# Settings
|
|
||||||
|
|
||||||
The Settings page lets you configure your LLM backend, dietary preferences, notification behavior, and account details.
|
|
||||||
|
|
||||||
## LLM backend
|
|
||||||
|
|
||||||
Shows the currently configured inference backend and its connection status. A green indicator means Kiwi can reach the backend and AI features are active. A red indicator means the backend is unreachable — check the URL and whether the server is running.
|
|
||||||
|
|
||||||
To change or add a backend, edit your `.env` file and restart:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
LLM_BACKEND=ollama
|
|
||||||
LLM_BASE_URL=http://host.docker.internal:11434
|
|
||||||
LLM_MODEL=llama3.1
|
|
||||||
```
|
|
||||||
|
|
||||||
See [LLM Backend Setup](../getting-started/llm-setup.md) for full configuration options.
|
|
||||||
|
|
||||||
## Dietary preferences
|
|
||||||
|
|
||||||
Set your default dietary filters here. These are applied automatically when you browse recipes and get suggestions:
|
|
||||||
|
|
||||||
- Vegetarian
|
|
||||||
- Vegan
|
|
||||||
- Gluten-free
|
|
||||||
- Dairy-free
|
|
||||||
- Nut-free
|
|
||||||
- Low-carb
|
|
||||||
- Halal
|
|
||||||
- Kosher
|
|
||||||
|
|
||||||
Dietary preferences are stored locally and not shared with any server.
|
|
||||||
|
|
||||||
## Expiry alert thresholds
|
|
||||||
|
|
||||||
Configure when Kiwi starts flagging items:
|
|
||||||
|
|
||||||
| Indicator | Default |
|
|
||||||
|-----------|---------|
|
|
||||||
| Red (urgent) | 2 days |
|
|
||||||
| Orange (soon) | 7 days |
|
|
||||||
| Yellow (upcoming) | 14 days |
|
|
||||||
|
|
||||||
## Notification settings
|
|
||||||
|
|
||||||
Kiwi can send browser notifications when items are about to expire. Enable this in Settings by clicking **Allow notifications**. Your browser will ask for permission.
|
|
||||||
|
|
||||||
Notifications are sent once per day for items entering the red (2-day) window.
|
|
||||||
|
|
||||||
## Account and tier
|
|
||||||
|
|
||||||
Shows your current tier (Free / Paid / Premium) and account email (cloud mode only). Includes a link to manage your subscription.
|
|
||||||
|
|
||||||
## Affiliate links
|
|
||||||
|
|
||||||
When browsing recipes that call for specialty ingredients, Kiwi may show eBay links to find them at a discount. You can:
|
|
||||||
|
|
||||||
- **Disable affiliate links entirely** — turn off all affiliate link insertion
|
|
||||||
- **Use your own affiliate ID** — if you have an eBay Partner Network (EPN) ID, enter it here and your ID will be used instead of CircuitForge's (Premium tier)
|
|
||||||
|
|
||||||
## Export
|
|
||||||
|
|
||||||
Click **Export pantry** to download your full inventory as a CSV file. The export includes all items, quantities, categories, expiry dates, and notes.
|
|
||||||
|
|
@ -23,9 +23,6 @@
|
||||||
.app-body { display: flex; flex-direction: column; flex: 1; }
|
.app-body { display: flex; flex-direction: column; flex: 1; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- Plausible analytics: cookie-free, GDPR-compliant, self-hosted.
|
|
||||||
Skips localhost/127.0.0.1. Reports to hostname + circuitforge.tech rollup. -->
|
|
||||||
<script>(function(){if(/localhost|127\.0\.0\.1/.test(location.hostname))return;var s=document.createElement('script');s.defer=true;s.dataset.domain=location.hostname+',circuitforge.tech';s.dataset.api='https://analytics.circuitforge.tech/api/event';s.src='https://analytics.circuitforge.tech/js/script.js';document.head.appendChild(s);})();</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -58,15 +58,6 @@
|
||||||
<span class="sidebar-label">Meal Plan</span>
|
<span class="sidebar-label">Meal Plan</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button :class="['sidebar-item', { active: currentTab === 'shopping' }]" @click="switchTab('shopping')" aria-label="Shopping List">
|
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
|
||||||
<path d="M16 10a4 4 0 01-8 0"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sidebar-label">Shopping</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
|
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
|
@ -88,24 +79,21 @@
|
||||||
|
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div v-if="mountedTabs.has('inventory')" v-show="currentTab === 'inventory'" class="tab-content fade-in">
|
<div v-show="currentTab === 'inventory'" class="tab-content fade-in">
|
||||||
<InventoryList />
|
<InventoryList />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mountedTabs.has('receipts')" v-show="currentTab === 'receipts'" class="tab-content fade-in">
|
<div v-show="currentTab === 'receipts'" class="tab-content fade-in">
|
||||||
<ReceiptsView />
|
<ReceiptsView />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
|
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
|
||||||
<RecipesView />
|
<RecipesView />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mountedTabs.has('settings')" v-show="currentTab === 'settings'" class="tab-content fade-in">
|
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
||||||
<SettingsView />
|
<SettingsView />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mountedTabs.has('mealplan')" v-show="currentTab === 'mealplan'" class="tab-content">
|
<div v-show="currentTab === 'mealplan'" class="tab-content">
|
||||||
<MealPlanView />
|
<MealPlanView />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mountedTabs.has('shopping')" v-show="currentTab === 'shopping'" class="tab-content fade-in">
|
|
||||||
<ShoppingView />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -156,14 +144,6 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Meal Plan</span>
|
<span class="nav-label">Meal Plan</span>
|
||||||
</button>
|
</button>
|
||||||
<button :class="['nav-item', { active: currentTab === 'shopping' }]" @click="switchTab('shopping')" aria-label="Shopping List">
|
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
|
||||||
<path d="M16 10a4 4 0 01-8 0"/>
|
|
||||||
</svg>
|
|
||||||
<span class="nav-label">Shopping</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
||||||
|
|
@ -204,26 +184,21 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, onMounted } 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 RecipesView from './components/RecipesView.vue'
|
||||||
import SettingsView from './components/SettingsView.vue'
|
import SettingsView from './components/SettingsView.vue'
|
||||||
import MealPlanView from './components/MealPlanView.vue'
|
import MealPlanView from './components/MealPlanView.vue'
|
||||||
import ShoppingView from './components/ShoppingView.vue'
|
|
||||||
import FeedbackButton from './components/FeedbackButton.vue'
|
import FeedbackButton from './components/FeedbackButton.vue'
|
||||||
import { useInventoryStore } from './stores/inventory'
|
import { useInventoryStore } from './stores/inventory'
|
||||||
import { useEasterEggs } from './composables/useEasterEggs'
|
import { useEasterEggs } from './composables/useEasterEggs'
|
||||||
import { householdAPI, bootstrapSession } from './services/api'
|
import { householdAPI } from './services/api'
|
||||||
|
|
||||||
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan' | 'shopping'
|
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
|
||||||
|
|
||||||
const currentTab = ref<Tab>('recipes')
|
const currentTab = ref<Tab>('recipes')
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
// Lazy-mount: tabs mount on first visit and stay mounted (KeepAlive-like behaviour).
|
|
||||||
// Only 'recipes' is in the initial set so non-active tabs don't mount simultaneously
|
|
||||||
// on page load — eliminates concurrent onMounted calls across all tab components.
|
|
||||||
const mountedTabs = reactive(new Set<Tab>(['recipes']))
|
|
||||||
const inventoryStore = useInventoryStore()
|
const inventoryStore = useInventoryStore()
|
||||||
const { kiwiVisible, kiwiDirection } = useEasterEggs()
|
const { kiwiVisible, kiwiDirection } = useEasterEggs()
|
||||||
|
|
||||||
|
|
@ -243,7 +218,6 @@ function onWordmarkClick() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchTab(tab: Tab) {
|
async function switchTab(tab: Tab) {
|
||||||
mountedTabs.add(tab)
|
|
||||||
currentTab.value = tab
|
currentTab.value = tab
|
||||||
if (tab === 'recipes' && inventoryStore.items.length === 0) {
|
if (tab === 'recipes' && inventoryStore.items.length === 0) {
|
||||||
await inventoryStore.fetchItems()
|
await inventoryStore.fetchItems()
|
||||||
|
|
@ -251,10 +225,6 @@ async function switchTab(tab: Tab) {
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Session bootstrap — logs auth= + tier= server-side for log-based analytics.
|
|
||||||
// Fire-and-forget: failure doesn't affect UX.
|
|
||||||
bootstrapSession()
|
|
||||||
|
|
||||||
// Pre-fetch inventory so Recipes tab has data on first load
|
// Pre-fetch inventory so Recipes tab has data on first load
|
||||||
if (inventoryStore.items.length === 0) {
|
if (inventoryStore.items.length === 0) {
|
||||||
await inventoryStore.fetchItems()
|
await inventoryStore.fetchItems()
|
||||||
|
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
<template>
|
|
||||||
<Transition name="modal">
|
|
||||||
<div v-if="show" class="modal-overlay" @click="handleCancel">
|
|
||||||
<div class="modal-container" @click.stop>
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3>{{ title }}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>{{ message }}</p>
|
|
||||||
|
|
||||||
<!-- Partial quantity input -->
|
|
||||||
<div v-if="inputType === 'quantity'" class="action-input-row">
|
|
||||||
<label class="action-input-label">{{ inputLabel }}</label>
|
|
||||||
<div class="qty-input-group">
|
|
||||||
<input
|
|
||||||
v-model.number="inputNumber"
|
|
||||||
type="number"
|
|
||||||
:min="0.01"
|
|
||||||
:max="inputMax"
|
|
||||||
step="0.5"
|
|
||||||
class="action-number-input"
|
|
||||||
:aria-label="inputLabel"
|
|
||||||
/>
|
|
||||||
<span class="qty-unit">{{ inputUnit }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn-use-all" @click="inputNumber = inputMax">
|
|
||||||
Use all ({{ inputMax }} {{ inputUnit }})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reason select -->
|
|
||||||
<div v-if="inputType === 'select'" class="action-input-row">
|
|
||||||
<label class="action-input-label">{{ inputLabel }}</label>
|
|
||||||
<select v-model="inputSelect" class="action-select" :aria-label="inputLabel">
|
|
||||||
<option value="">— skip —</option>
|
|
||||||
<option v-for="opt in inputOptions" :key="opt" :value="opt">{{ opt }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" @click="handleCancel">Cancel</button>
|
|
||||||
<button :class="['btn', `btn-${type}`]" @click="handleConfirm">
|
|
||||||
{{ confirmText }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
show: boolean
|
|
||||||
title?: string
|
|
||||||
message: string
|
|
||||||
confirmText?: string
|
|
||||||
type?: 'primary' | 'danger' | 'warning' | 'secondary'
|
|
||||||
inputType?: 'quantity' | 'select' | null
|
|
||||||
inputLabel?: string
|
|
||||||
inputMax?: number
|
|
||||||
inputUnit?: string
|
|
||||||
inputOptions?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
title: 'Confirm',
|
|
||||||
confirmText: 'Confirm',
|
|
||||||
type: 'primary',
|
|
||||||
inputType: null,
|
|
||||||
inputLabel: '',
|
|
||||||
inputMax: 1,
|
|
||||||
inputUnit: '',
|
|
||||||
inputOptions: () => [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
confirm: [value: number | string | undefined]
|
|
||||||
cancel: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const inputNumber = ref<number>(props.inputMax)
|
|
||||||
const inputSelect = ref<string>('')
|
|
||||||
|
|
||||||
watch(() => props.inputMax, (v) => { inputNumber.value = v })
|
|
||||||
watch(() => props.show, (v) => {
|
|
||||||
if (v) {
|
|
||||||
inputNumber.value = props.inputMax
|
|
||||||
inputSelect.value = ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleConfirm() {
|
|
||||||
if (props.inputType === 'quantity') {
|
|
||||||
const qty = Math.min(Math.max(0.01, inputNumber.value || props.inputMax), props.inputMax)
|
|
||||||
emit('confirm', qty)
|
|
||||||
} else if (props.inputType === 'select') {
|
|
||||||
emit('confirm', inputSelect.value || undefined)
|
|
||||||
} else {
|
|
||||||
emit('confirm', undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
emit('cancel')
|
|
||||||
}
|
|
||||||
</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: 9999;
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-container {
|
|
||||||
background: var(--color-bg-elevated);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
max-width: 480px;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-input-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-input-label {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-input-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-number-input {
|
|
||||||
width: 90px;
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-unit {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-use-all {
|
|
||||||
align-self: flex-start;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-select {
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-bg-primary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: var(--spacing-sm) var(--spacing-lg);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover { background: var(--color-bg-primary); }
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--gradient-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--color-error);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: var(--color-error-dark);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning {
|
|
||||||
background: var(--color-warning);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active { transition: opacity 0.3s ease; }
|
|
||||||
.modal-enter-active .modal-container,
|
|
||||||
.modal-leave-active .modal-container { transition: transform 0.3s ease; }
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to { opacity: 0; }
|
|
||||||
.modal-enter-from .modal-container,
|
|
||||||
.modal-leave-to .modal-container { transform: scale(0.9) translateY(-20px); }
|
|
||||||
</style>
|
|
||||||
|
|
@ -75,21 +75,6 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Screenshot <span class="text-muted text-xs">(optional, max 5 MB)</span></label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="form-input-file"
|
|
||||||
@change="onScreenshotChange"
|
|
||||||
ref="fileInput"
|
|
||||||
/>
|
|
||||||
<div v-if="screenshotPreview" class="screenshot-preview">
|
|
||||||
<img :src="screenshotPreview" alt="Screenshot preview" />
|
|
||||||
<button class="screenshot-remove btn-link" type="button" @click="clearScreenshot" aria-label="Remove screenshot">Remove</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="stepError" class="feedback-error">{{ stepError }}</p>
|
<p v-if="stepError" class="feedback-error">{{ stepError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -155,30 +140,6 @@ import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ currentTab?: string }>()
|
const props = defineProps<{ currentTab?: string }>()
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const screenshotB64 = ref<string | null>(null)
|
|
||||||
const screenshotPreview = ref<string | null>(null)
|
|
||||||
const screenshotFilename = ref('screenshot.png')
|
|
||||||
|
|
||||||
function onScreenshotChange(event: Event) {
|
|
||||||
const file = (event.target as HTMLInputElement).files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
screenshotFilename.value = file.name
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
const result = e.target?.result as string
|
|
||||||
screenshotB64.value = result
|
|
||||||
screenshotPreview.value = result
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearScreenshot() {
|
|
||||||
screenshotB64.value = null
|
|
||||||
screenshotPreview.value = null
|
|
||||||
if (fileInput.value) fileInput.value.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
|
||||||
|
|
||||||
// Probe once on mount — hidden until confirmed enabled so button never flashes
|
// Probe once on mount — hidden until confirmed enabled so button never flashes
|
||||||
|
|
@ -231,7 +192,6 @@ function reset() {
|
||||||
submitted.value = false
|
submitted.value = false
|
||||||
issueUrl.value = ''
|
issueUrl.value = ''
|
||||||
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
|
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
|
||||||
clearScreenshot()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextStep() {
|
function nextStep() {
|
||||||
|
|
@ -266,23 +226,6 @@ async function submit() {
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
issueUrl.value = data.issue_url
|
issueUrl.value = data.issue_url
|
||||||
|
|
||||||
// Upload screenshot if provided
|
|
||||||
if (screenshotB64.value) {
|
|
||||||
try {
|
|
||||||
await fetch(`${apiBase}/api/v1/feedback/attach`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
issue_number: data.issue_number,
|
|
||||||
filename: screenshotFilename.value,
|
|
||||||
image_b64: screenshotB64.value,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
// Non-fatal: if attach fails, the issue was still filed
|
|
||||||
} catch { /* ignore attach errors */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
submitError.value = 'Network error — please try again.'
|
submitError.value = 'Network error — please try again.'
|
||||||
|
|
@ -574,57 +517,6 @@ async function submit() {
|
||||||
.text-xs { font-size: 0.75rem; line-height: 1.5; }
|
.text-xs { font-size: 0.75rem; line-height: 1.5; }
|
||||||
.font-semibold { font-weight: 600; }
|
.font-semibold { font-weight: 600; }
|
||||||
|
|
||||||
/* ── Screenshot attachment ────────────────────────────────────────────── */
|
|
||||||
.form-input-file {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.form-input-file:focus { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
|
|
||||||
|
|
||||||
.screenshot-preview {
|
|
||||||
margin-top: var(--spacing-xs);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
.screenshot-preview img {
|
|
||||||
max-width: 160px;
|
|
||||||
max-height: 100px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.screenshot-remove {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 4px;
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
.screenshot-remove:hover { color: var(--color-error); }
|
|
||||||
|
|
||||||
.btn-link {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: inherit;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transition */
|
/* Transition */
|
||||||
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
|
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
|
||||||
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
|
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,11 @@
|
||||||
<div class="stat-num text-amber">{{ stats.available_items }}</div>
|
<div class="stat-num text-amber">{{ stats.available_items }}</div>
|
||||||
<div class="stat-lbl">Available</div>
|
<div class="stat-lbl">Available</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="stat-strip-item">
|
||||||
:class="['stat-strip-item', 'stat-clickable', { 'stat-active': expiryView === 'soon' }]"
|
|
||||||
@click="toggleExpiryView('soon')"
|
|
||||||
@keydown.enter="toggleExpiryView('soon')"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
:aria-label="`${store.expiringItems.length} items expiring soon — tap to view`"
|
|
||||||
>
|
|
||||||
<div class="stat-num text-warning">{{ store.expiringItems.length }}</div>
|
<div class="stat-num text-warning">{{ store.expiringItems.length }}</div>
|
||||||
<div class="stat-lbl">Expiring</div>
|
<div class="stat-lbl">Expiring</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="stat-strip-item">
|
||||||
:class="['stat-strip-item', 'stat-clickable', { 'stat-active': expiryView === 'expired' }]"
|
|
||||||
@click="toggleExpiryView('expired')"
|
|
||||||
@keydown.enter="toggleExpiryView('expired')"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
:aria-label="`${store.expiredItems.length} expired items — tap to view`"
|
|
||||||
>
|
|
||||||
<div class="stat-num text-error">{{ store.expiredItems.length }}</div>
|
<div class="stat-num text-error">{{ store.expiredItems.length }}</div>
|
||||||
<div class="stat-lbl">Expired</div>
|
<div class="stat-lbl">Expired</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -259,162 +245,9 @@
|
||||||
<div class="inventory-section">
|
<div class="inventory-section">
|
||||||
<!-- Filter chips -->
|
<!-- Filter chips -->
|
||||||
<div class="inventory-header">
|
<div class="inventory-header">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">Pantry</h2>
|
||||||
{{ expiryView === 'soon' ? 'Expiring Soon' : expiryView === 'expired' ? 'Expired Items' : 'Pantry' }}
|
|
||||||
</h2>
|
|
||||||
<button v-if="expiryView" @click="expiryView = null" class="btn-text expiry-back-btn" type="button">
|
|
||||||
← All items
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expiry Panel -->
|
|
||||||
<template v-if="expiryView === 'soon'">
|
|
||||||
<div v-if="!store.expiringItems.length" class="empty-state">
|
|
||||||
<p class="text-secondary">Nothing expiring in the next 7 days.</p>
|
|
||||||
</div>
|
|
||||||
<div v-else class="expiry-panel">
|
|
||||||
<!-- Urgent: ≤3 days -->
|
|
||||||
<div v-if="urgentItems.length" class="expiry-group">
|
|
||||||
<div class="expiry-group-label expiry-group-urgent">Use within 3 days</div>
|
|
||||||
<div
|
|
||||||
v-for="item in urgentItems"
|
|
||||||
:key="item.id"
|
|
||||||
class="expiry-item-row"
|
|
||||||
>
|
|
||||||
<span :class="['loc-dot', `loc-dot-${item.location}`]"></span>
|
|
||||||
<div class="expiry-item-name">
|
|
||||||
<span class="inv-name">{{ item.product_name || 'Unknown' }}</span>
|
|
||||||
<span v-if="item.category" class="inv-category">{{ item.category }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="expiry-item-right">
|
|
||||||
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
|
|
||||||
<span :class="['expiry-badge', getExpiryBadgeClass(item.expiration_date!)]">
|
|
||||||
{{ daysLabel(item.expiration_date!) }}
|
|
||||||
</span>
|
|
||||||
<div class="inv-actions">
|
|
||||||
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Use">
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
|
||||||
<polyline points="4 10 8 14 16 6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button @click="markAsDiscarded(item)" class="btn-icon btn-icon-discard" aria-label="Not used">
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
|
||||||
<path d="M4 4l12 12M4 16L16 4"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Soon: 4-7 days -->
|
|
||||||
<div v-if="soonItems.length" class="expiry-group">
|
|
||||||
<div class="expiry-group-label expiry-group-soon">Coming up (4–7 days)</div>
|
|
||||||
<div
|
|
||||||
v-for="item in soonItems"
|
|
||||||
:key="item.id"
|
|
||||||
class="expiry-item-row"
|
|
||||||
>
|
|
||||||
<span :class="['loc-dot', `loc-dot-${item.location}`]"></span>
|
|
||||||
<div class="expiry-item-name">
|
|
||||||
<span class="inv-name">{{ item.product_name || 'Unknown' }}</span>
|
|
||||||
<span v-if="item.category" class="inv-category">{{ item.category }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="expiry-item-right">
|
|
||||||
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
|
|
||||||
<span :class="['expiry-badge', getExpiryBadgeClass(item.expiration_date!)]">
|
|
||||||
{{ daysLabel(item.expiration_date!) }}
|
|
||||||
</span>
|
|
||||||
<div class="inv-actions">
|
|
||||||
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Use">
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
|
||||||
<polyline points="4 10 8 14 16 6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button @click="markAsDiscarded(item)" class="btn-icon btn-icon-discard" aria-label="Not used">
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
|
||||||
<path d="M4 4l12 12M4 16L16 4"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Expired panel -->
|
|
||||||
<template v-else-if="expiryView === 'expired'">
|
|
||||||
<div v-if="!store.expiredItems.length" class="empty-state">
|
|
||||||
<p class="text-secondary">No expired items.</p>
|
|
||||||
</div>
|
|
||||||
<div v-else class="expiry-panel">
|
|
||||||
<!-- Items with a secondary use window -->
|
|
||||||
<div v-if="secondaryStateItems.length" class="expiry-group">
|
|
||||||
<div class="expiry-group-label expiry-group-secondary">Still useful with the right recipe</div>
|
|
||||||
<div
|
|
||||||
v-for="item in secondaryStateItems"
|
|
||||||
:key="item.id"
|
|
||||||
class="expiry-item-row expiry-item-secondary"
|
|
||||||
>
|
|
||||||
<span :class="['loc-dot', `loc-dot-${item.location}`]"></span>
|
|
||||||
<div class="expiry-item-name">
|
|
||||||
<span class="inv-name">{{ item.product_name || 'Unknown' }}</span>
|
|
||||||
<span class="secondary-state-badge">{{ item.secondary_state }}</span>
|
|
||||||
<span v-if="item.secondary_uses?.length" class="secondary-uses-text">
|
|
||||||
Good for: {{ item.secondary_uses!.slice(0, 3).join(', ') }}
|
|
||||||
</span>
|
|
||||||
<span v-if="item.secondary_warning" class="secondary-warning-text">
|
|
||||||
⚠ {{ item.secondary_warning }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="expiry-item-right">
|
|
||||||
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
|
|
||||||
<div class="inv-actions">
|
|
||||||
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Use now">
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
|
||||||
<polyline points="4 10 8 14 16 6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button @click="markAsDiscarded(item)" class="btn-icon btn-icon-discard" aria-label="Not used">
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
|
||||||
<path d="M4 4l12 12M4 16L16 4"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Truly expired — past secondary window -->
|
|
||||||
<div v-if="trulyExpiredItems.length" class="expiry-group">
|
|
||||||
<div class="expiry-group-label expiry-group-done">Time to let it go</div>
|
|
||||||
<div
|
|
||||||
v-for="item in trulyExpiredItems"
|
|
||||||
:key="item.id"
|
|
||||||
class="expiry-item-row"
|
|
||||||
>
|
|
||||||
<span :class="['loc-dot', `loc-dot-${item.location}`]"></span>
|
|
||||||
<div class="expiry-item-name">
|
|
||||||
<span class="inv-name">{{ item.product_name || 'Unknown' }}</span>
|
|
||||||
<span v-if="item.category" class="inv-category">{{ item.category }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="expiry-item-right">
|
|
||||||
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
|
|
||||||
<span class="expiry-badge expiry-expired">{{ daysLabel(item.expiration_date!) }}</span>
|
|
||||||
<div class="inv-actions">
|
|
||||||
<button @click="markAsDiscarded(item)" class="btn-icon btn-icon-discard" aria-label="Not used">
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
|
||||||
<path d="M4 4l12 12M4 16L16 4"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Normal filter + list view -->
|
|
||||||
<template v-else>
|
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<div class="filter-chip-row">
|
<div class="filter-chip-row">
|
||||||
<button
|
<button
|
||||||
|
|
@ -488,18 +321,11 @@
|
||||||
|
|
||||||
<!-- Right side: qty + expiry + actions -->
|
<!-- Right side: qty + expiry + actions -->
|
||||||
<div class="inv-row-right">
|
<div class="inv-row-right">
|
||||||
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
|
<span class="inv-qty">{{ item.quantity }}<span class="inv-unit"> {{ item.unit }}</span></span>
|
||||||
|
|
||||||
<!-- Opened expiry takes priority over sell-by date -->
|
|
||||||
<span
|
<span
|
||||||
v-if="item.opened_expiry_date"
|
v-if="item.expiration_date"
|
||||||
:class="['expiry-badge', 'expiry-opened', getExpiryBadgeClass(item.opened_expiry_date)]"
|
|
||||||
:title="`Opened · ${formatDateFull(item.opened_expiry_date)}`"
|
|
||||||
>📂 {{ formatDateShort(item.opened_expiry_date) }}</span>
|
|
||||||
<span
|
|
||||||
v-else-if="item.expiration_date"
|
|
||||||
:class="['expiry-badge', getExpiryBadgeClass(item.expiration_date)]"
|
:class="['expiry-badge', getExpiryBadgeClass(item.expiration_date)]"
|
||||||
:title="formatDateFull(item.expiration_date)"
|
|
||||||
>{{ formatDateShort(item.expiration_date) }}</span>
|
>{{ formatDateShort(item.expiration_date) }}</span>
|
||||||
|
|
||||||
<div class="inv-actions">
|
<div class="inv-actions">
|
||||||
|
|
@ -508,41 +334,11 @@
|
||||||
<path d="M13.586 3.586a2 2 0 112.828 2.828L7 14.828 4 16l1.172-3L13.586 3.586z"/>
|
<path d="M13.586 3.586a2 2 0 112.828 2.828L7 14.828 4 16l1.172-3L13.586 3.586z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Mark consumed">
|
||||||
v-if="!item.opened_date && item.status === 'available'"
|
|
||||||
@click="markAsOpened(item)"
|
|
||||||
class="btn-icon btn-icon-open"
|
|
||||||
aria-label="Mark as opened today"
|
|
||||||
title="I opened this today"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
|
||||||
<path d="M5 8V6a7 7 0 0114 0v2"/>
|
|
||||||
<rect x="3" y="8" width="14" height="10" rx="2"/>
|
|
||||||
<circle cx="10" cy="13" r="1.5" fill="currentColor"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="item.status === 'available'"
|
|
||||||
@click="markAsConsumed(item)"
|
|
||||||
class="btn-icon btn-icon-success"
|
|
||||||
:aria-label="item.quantity > 1 ? `Use some (${item.quantity} ${item.unit})` : 'Mark as used'"
|
|
||||||
:title="item.quantity > 1 ? `Use some or all (${item.quantity} ${item.unit})` : 'Mark as used'"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||||
<polyline points="4 10 8 14 16 6"/>
|
<polyline points="4 10 8 14 16 6"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="item.status === 'available'"
|
|
||||||
@click="markAsDiscarded(item)"
|
|
||||||
class="btn-icon btn-icon-discard"
|
|
||||||
aria-label="Mark as not used"
|
|
||||||
title="I didn't use this (went bad, too much, etc)"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
|
||||||
<path d="M4 4l12 12M4 16L16 4"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button @click="confirmDelete(item)" class="btn-icon btn-icon-danger" aria-label="Delete">
|
<button @click="confirmDelete(item)" class="btn-icon btn-icon-danger" aria-label="Delete">
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||||
<polyline points="3 6 5 6 17 6"/>
|
<polyline points="3 6 5 6 17 6"/>
|
||||||
|
|
@ -554,20 +350,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template><!-- end v-else normal view -->
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export -->
|
<!-- Export -->
|
||||||
<div class="card export-card">
|
<div class="card export-card">
|
||||||
<h2 class="section-title">Export</h2>
|
<h2 class="section-title">Export</h2>
|
||||||
<div class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
|
<div class="flex gap-sm" style="margin-top: var(--spacing-sm)">
|
||||||
<button @click="exportJSON" class="btn btn-primary">Download JSON (full backup)</button>
|
|
||||||
<button @click="exportCSV" class="btn btn-secondary">Download CSV</button>
|
<button @click="exportCSV" class="btn btn-secondary">Download CSV</button>
|
||||||
<button @click="exportExcel" class="btn btn-secondary">Download Excel</button>
|
<button @click="exportExcel" class="btn btn-secondary">Download Excel</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-secondary" style="margin-top: var(--spacing-xs)">
|
|
||||||
JSON includes pantry + saved recipes. Import it into another Kiwi instance any time.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Modal -->
|
<!-- Edit Modal -->
|
||||||
|
|
@ -589,22 +380,6 @@
|
||||||
@cancel="confirmDialog.show = false"
|
@cancel="confirmDialog.show = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Action Dialog (partial consume / discard reason) -->
|
|
||||||
<ActionDialog
|
|
||||||
:show="actionDialog.show"
|
|
||||||
:title="actionDialog.title"
|
|
||||||
:message="actionDialog.message"
|
|
||||||
:type="actionDialog.type"
|
|
||||||
:confirm-text="actionDialog.confirmText"
|
|
||||||
:input-type="actionDialog.inputType"
|
|
||||||
:input-label="actionDialog.inputLabel"
|
|
||||||
:input-max="actionDialog.inputMax"
|
|
||||||
:input-unit="actionDialog.inputUnit"
|
|
||||||
:input-options="actionDialog.inputOptions"
|
|
||||||
@confirm="(v) => { actionDialog.onConfirm(v); actionDialog.show = false }"
|
|
||||||
@cancel="actionDialog.show = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Toast Notification -->
|
<!-- Toast Notification -->
|
||||||
<ToastNotification
|
<ToastNotification
|
||||||
:show="toast.show"
|
:show="toast.show"
|
||||||
|
|
@ -620,66 +395,18 @@
|
||||||
import { ref, computed, onMounted, reactive } from 'vue'
|
import { ref, computed, onMounted, reactive } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useInventoryStore } from '../stores/inventory'
|
import { useInventoryStore } from '../stores/inventory'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
|
||||||
import { inventoryAPI } from '../services/api'
|
import { inventoryAPI } from '../services/api'
|
||||||
import type { InventoryItem } from '../services/api'
|
import type { InventoryItem } from '../services/api'
|
||||||
import { formatQuantity } from '../utils/units'
|
|
||||||
import EditItemModal from './EditItemModal.vue'
|
import EditItemModal from './EditItemModal.vue'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
import ActionDialog from './ActionDialog.vue'
|
|
||||||
import ToastNotification from './ToastNotification.vue'
|
import ToastNotification from './ToastNotification.vue'
|
||||||
|
|
||||||
const store = useInventoryStore()
|
const store = useInventoryStore()
|
||||||
const settingsStore = useSettingsStore()
|
|
||||||
const { items, stats, loading, locationFilter, statusFilter } = storeToRefs(store)
|
const { items, stats, loading, locationFilter, statusFilter } = storeToRefs(store)
|
||||||
|
|
||||||
const filteredItems = computed(() => store.filteredItems)
|
const filteredItems = computed(() => store.filteredItems)
|
||||||
const editingItem = ref<InventoryItem | null>(null)
|
const editingItem = ref<InventoryItem | null>(null)
|
||||||
|
|
||||||
// Expiry view
|
|
||||||
const expiryView = ref<'soon' | 'expired' | null>(null)
|
|
||||||
|
|
||||||
function toggleExpiryView(mode: 'soon' | 'expired') {
|
|
||||||
expiryView.value = expiryView.value === mode ? null : mode
|
|
||||||
// Ensure available items are loaded so computeds have data
|
|
||||||
if (expiryView.value && statusFilter.value !== 'available' && statusFilter.value !== 'all') {
|
|
||||||
statusFilter.value = 'available'
|
|
||||||
store.fetchItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const urgentItems = computed(() =>
|
|
||||||
store.expiringItems.filter((item) => {
|
|
||||||
if (!item.expiration_date) return false
|
|
||||||
const diff = Math.ceil((new Date(item.expiration_date).getTime() - Date.now()) / 86_400_000)
|
|
||||||
return diff <= 3
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const soonItems = computed(() =>
|
|
||||||
store.expiringItems.filter((item) => {
|
|
||||||
if (!item.expiration_date) return false
|
|
||||||
const diff = Math.ceil((new Date(item.expiration_date).getTime() - Date.now()) / 86_400_000)
|
|
||||||
return diff > 3
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const secondaryStateItems = computed(() =>
|
|
||||||
store.expiredItems.filter((item) => item.secondary_state != null)
|
|
||||||
)
|
|
||||||
|
|
||||||
const trulyExpiredItems = computed(() =>
|
|
||||||
store.expiredItems.filter((item) => item.secondary_state == null)
|
|
||||||
)
|
|
||||||
|
|
||||||
function daysLabel(dateStr: string): string {
|
|
||||||
const diff = Math.ceil((new Date(dateStr).getTime() - Date.now()) / 86_400_000)
|
|
||||||
if (diff < 0) return `${Math.abs(diff)}d ago`
|
|
||||||
if (diff === 0) return 'today'
|
|
||||||
if (diff === 1) return '1 day'
|
|
||||||
return `${diff} days`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan mode toggle
|
// Scan mode toggle
|
||||||
const scanMode = ref<'gun' | 'camera' | 'manual'>('gun')
|
const scanMode = ref<'gun' | 'camera' | 'manual'>('gun')
|
||||||
|
|
||||||
|
|
@ -716,20 +443,6 @@ const confirmDialog = reactive({
|
||||||
onConfirm: () => {},
|
onConfirm: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const actionDialog = reactive({
|
|
||||||
show: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
type: 'primary' as 'primary' | 'danger' | 'warning' | 'secondary',
|
|
||||||
confirmText: 'Confirm',
|
|
||||||
inputType: null as 'quantity' | 'select' | null,
|
|
||||||
inputLabel: '',
|
|
||||||
inputMax: 1,
|
|
||||||
inputUnit: '',
|
|
||||||
inputOptions: [] as string[],
|
|
||||||
onConfirm: (_v: number | string | undefined) => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Toast Notification
|
// Toast Notification
|
||||||
const toast = reactive({
|
const toast = reactive({
|
||||||
show: false,
|
show: false,
|
||||||
|
|
@ -839,75 +552,24 @@ async function confirmDelete(item: InventoryItem) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAsOpened(item: InventoryItem) {
|
async function markAsConsumed(item: InventoryItem) {
|
||||||
try {
|
showConfirm(
|
||||||
await inventoryAPI.openItem(item.id)
|
`Mark ${item.product_name || 'item'} as consumed?`,
|
||||||
await refreshItems()
|
async () => {
|
||||||
showToast(`${item.product_name || 'Item'} marked as opened — tracking freshness`, 'info')
|
|
||||||
} catch {
|
|
||||||
showToast('Could not mark item as opened', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markAsConsumed(item: InventoryItem) {
|
|
||||||
const isMulti = item.quantity > 1
|
|
||||||
const label = item.product_name || 'item'
|
|
||||||
Object.assign(actionDialog, {
|
|
||||||
show: true,
|
|
||||||
title: 'Mark as Used',
|
|
||||||
message: isMulti
|
|
||||||
? `How much of ${label} did you use?`
|
|
||||||
: `Mark ${label} as used?`,
|
|
||||||
type: 'primary',
|
|
||||||
confirmText: isMulti ? 'Use' : 'Mark as Used',
|
|
||||||
inputType: isMulti ? 'quantity' : null,
|
|
||||||
inputLabel: 'Amount used:',
|
|
||||||
inputMax: item.quantity,
|
|
||||||
inputUnit: item.unit,
|
|
||||||
inputOptions: [],
|
|
||||||
onConfirm: async (val: number | string | undefined) => {
|
|
||||||
const qty = isMulti ? (val as number) : undefined
|
|
||||||
try {
|
try {
|
||||||
await inventoryAPI.consumeItem(item.id, qty)
|
await inventoryAPI.consumeItem(item.id)
|
||||||
await refreshItems()
|
await refreshItems()
|
||||||
const verb = qty !== undefined && qty < item.quantity ? 'partially used' : 'marked as used'
|
showToast(`${item.product_name || 'item'} marked as consumed`, 'success')
|
||||||
showToast(`${label} ${verb}`, 'success')
|
} catch (err) {
|
||||||
} catch {
|
showToast('Failed to mark item as consumed', 'error')
|
||||||
showToast('Could not update item', 'error')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
{
|
||||||
}
|
title: 'Mark as Consumed',
|
||||||
|
type: 'primary',
|
||||||
function markAsDiscarded(item: InventoryItem) {
|
confirmText: 'Mark as Consumed',
|
||||||
const label = item.product_name || 'item'
|
}
|
||||||
Object.assign(actionDialog, {
|
)
|
||||||
show: true,
|
|
||||||
title: 'Item Not Used',
|
|
||||||
message: `${label} — what happened to it?`,
|
|
||||||
type: 'secondary',
|
|
||||||
confirmText: 'Log It',
|
|
||||||
inputType: 'select',
|
|
||||||
inputLabel: 'Reason (optional):',
|
|
||||||
inputMax: 1,
|
|
||||||
inputUnit: '',
|
|
||||||
inputOptions: [
|
|
||||||
'went bad before I could use it',
|
|
||||||
'too much — had excess',
|
|
||||||
'changed my mind',
|
|
||||||
'other',
|
|
||||||
],
|
|
||||||
onConfirm: async (val: number | string | undefined) => {
|
|
||||||
const reason = typeof val === 'string' && val ? val : undefined
|
|
||||||
try {
|
|
||||||
await inventoryAPI.discardItem(item.id, reason)
|
|
||||||
await refreshItems()
|
|
||||||
showToast(`${label} logged as not used`, 'info')
|
|
||||||
} catch {
|
|
||||||
showToast('Could not update item', 'error')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scanner Gun Functions
|
// Scanner Gun Functions
|
||||||
|
|
@ -926,22 +588,13 @@ async function handleScannerGunInput() {
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
const item = result.results[0]
|
if (result.success && result.barcodes_found > 0) {
|
||||||
if (item?.added_to_inventory) {
|
const item = result.results[0]
|
||||||
const productName = item.product?.name || 'item'
|
|
||||||
const productBrand = item.product?.brand ? ` (${item.product.brand})` : ''
|
|
||||||
scannerResults.value.push({
|
scannerResults.value.push({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: `Added: ${productName}${productBrand} to ${scannerLocation.value}`,
|
message: `Added: ${item.product_name || 'item'} to ${scannerLocation.value}`,
|
||||||
})
|
})
|
||||||
await refreshItems()
|
await refreshItems()
|
||||||
} else if (item?.needs_manual_entry) {
|
|
||||||
// Barcode not found in any database — guide user to manual entry
|
|
||||||
scannerResults.value.push({
|
|
||||||
type: 'warning',
|
|
||||||
message: `Barcode ${barcode} not found. Fill in the details below.`,
|
|
||||||
})
|
|
||||||
scanMode.value = 'manual'
|
|
||||||
} else {
|
} else {
|
||||||
scannerResults.value.push({
|
scannerResults.value.push({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
|
@ -956,7 +609,7 @@ async function handleScannerGunInput() {
|
||||||
} finally {
|
} finally {
|
||||||
scannerLoading.value = false
|
scannerLoading.value = false
|
||||||
scannerBarcode.value = ''
|
scannerBarcode.value = ''
|
||||||
if (scanMode.value === 'gun') scannerGunInput.value?.focus()
|
scannerGunInput.value?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1065,40 +718,20 @@ function exportExcel() {
|
||||||
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
|
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportJSON() {
|
// Short date for compact row display
|
||||||
const apiUrl = import.meta.env.VITE_API_URL || '/api/v1'
|
function formatDateShort(dateStr: string): string {
|
||||||
window.open(`${apiUrl}/export/json`, '_blank')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full date string for tooltip (accessible label)
|
|
||||||
function formatDateFull(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
const expiry = new Date(dateStr)
|
const expiry = new Date(dateStr)
|
||||||
expiry.setHours(0, 0, 0, 0)
|
expiry.setHours(0, 0, 0, 0)
|
||||||
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
const cal = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
||||||
if (diffDays < 0) return `Expired ${cal}`
|
|
||||||
if (diffDays === 0) return `Expires today (${cal})`
|
|
||||||
if (diffDays === 1) return `Expires tomorrow (${cal})`
|
|
||||||
return `Expires in ${diffDays} days (${cal})`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short date for compact row display
|
|
||||||
function formatDateShort(dateStr: string): string {
|
|
||||||
const today = new Date()
|
|
||||||
today.setHours(0, 0, 0, 0)
|
|
||||||
const expiry = new Date(dateStr)
|
|
||||||
expiry.setHours(0, 0, 0, 0)
|
|
||||||
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
|
||||||
const cal = new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
||||||
|
|
||||||
if (diffDays < 0) return `${Math.abs(diffDays)}d ago`
|
if (diffDays < 0) return `${Math.abs(diffDays)}d ago`
|
||||||
if (diffDays === 0) return 'today'
|
if (diffDays === 0) return 'today'
|
||||||
if (diffDays === 1) return `tmrw · ${cal}`
|
if (diffDays === 1) return 'tmrw'
|
||||||
if (diffDays <= 14) return `${diffDays}d · ${cal}`
|
if (diffDays <= 14) return `${diffDays}d`
|
||||||
return cal
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExpiryBadgeClass(expiryStr: string): string {
|
function getExpiryBadgeClass(expiryStr: string): string {
|
||||||
|
|
@ -1511,30 +1144,6 @@ function getItemClass(item: InventoryItem): string {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "I opened this today" button */
|
|
||||||
.btn-icon-open {
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-open:hover {
|
|
||||||
background: var(--color-warning-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "Item not used" discard button — muted, not alarming */
|
|
||||||
.btn-icon-discard {
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon-discard:hover {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Opened badge — distinct icon prefix signals this is after-open expiry */
|
|
||||||
.expiry-opened {
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action icons inline */
|
/* Action icons inline */
|
||||||
.inv-actions {
|
.inv-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1608,12 +1217,6 @@ function getItemClass(item: InventoryItem): string {
|
||||||
border: 1px solid var(--color-info-border);
|
border: 1px solid var(--color-info-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-warning {
|
|
||||||
background: var(--color-warning-bg, #fffbeb);
|
|
||||||
color: var(--color-warning-dark, #92400e);
|
|
||||||
border: 1px solid var(--color-warning-border, #fcd34d);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
EXPORT CARD
|
EXPORT CARD
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
@ -1707,125 +1310,4 @@ function getItemClass(item: InventoryItem): string {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
STATS — clickable badges
|
|
||||||
============================================ */
|
|
||||||
.stat-clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-clickable:hover {
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-clickable.stat-active {
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
box-shadow: inset 0 -2px 0 var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
EXPIRY PANEL
|
|
||||||
============================================ */
|
|
||||||
.expiry-back-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-back-btn:hover {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-group-label {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
padding: var(--spacing-xs) 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-group-urgent { color: var(--color-error); }
|
|
||||||
.expiry-group-soon { color: var(--color-warning); }
|
|
||||||
.expiry-group-secondary { color: var(--color-success); }
|
|
||||||
.expiry-group-done { color: var(--color-text-muted); }
|
|
||||||
|
|
||||||
.expiry-item-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding: var(--spacing-sm) 0;
|
|
||||||
border-bottom: 1px solid var(--color-border-subtle, var(--color-border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-item-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-item-secondary {
|
|
||||||
background: color-mix(in srgb, var(--color-success) 5%, transparent);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-item-name {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expiry-item-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary-state-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
|
||||||
color: var(--color-success);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary-uses-text {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary-warning-text {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
|
|
||||||
<div v-show="!collapsed" class="grid-body">
|
<div v-show="!collapsed" class="grid-body">
|
||||||
<!-- Day headers -->
|
<!-- Day headers -->
|
||||||
<div class="day-headers" :class="{ 'headers-editing': editing }">
|
<div class="day-headers">
|
||||||
<div class="meal-type-col-spacer" />
|
<div class="meal-type-col-spacer" />
|
||||||
<div
|
<div
|
||||||
v-for="(day, i) in DAY_LABELS"
|
v-for="(day, i) in DAY_LABELS"
|
||||||
|
|
@ -26,35 +26,11 @@
|
||||||
|
|
||||||
<!-- One row per meal type -->
|
<!-- One row per meal type -->
|
||||||
<div
|
<div
|
||||||
v-for="(mealType, idx) in activeMealTypes"
|
v-for="mealType in activeMealTypes"
|
||||||
:key="mealType"
|
:key="mealType"
|
||||||
class="meal-row"
|
class="meal-row"
|
||||||
:class="{ 'row-editing': editing }"
|
|
||||||
>
|
>
|
||||||
<div class="meal-type-label" :class="{ 'label-editing': editing }">
|
<div class="meal-type-label">{{ mealType }}</div>
|
||||||
<template v-if="editing">
|
|
||||||
<button
|
|
||||||
class="reorder-btn"
|
|
||||||
:disabled="idx === 0 || mealTypeChanging"
|
|
||||||
aria-label="Move up"
|
|
||||||
@click="onMoveUp(idx)"
|
|
||||||
>↑</button>
|
|
||||||
<button
|
|
||||||
class="reorder-btn"
|
|
||||||
:disabled="idx === activeMealTypes.length - 1 || mealTypeChanging"
|
|
||||||
aria-label="Move down"
|
|
||||||
@click="onMoveDown(idx)"
|
|
||||||
>↓</button>
|
|
||||||
<span class="label-text">{{ mealType }}</span>
|
|
||||||
<button
|
|
||||||
class="remove-btn"
|
|
||||||
:disabled="activeMealTypes.length <= 1 || mealTypeChanging"
|
|
||||||
:aria-label="`Remove ${mealType}`"
|
|
||||||
@click="$emit('remove-meal-type', mealType)"
|
|
||||||
>✕</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>{{ mealType }}</template>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
v-for="dayIndex in 7"
|
v-for="dayIndex in 7"
|
||||||
:key="dayIndex - 1"
|
:key="dayIndex - 1"
|
||||||
|
|
@ -70,17 +46,11 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add / edit meal type controls (Paid only) -->
|
<!-- Add meal type row (Paid only) -->
|
||||||
<div v-if="canAddMealType || activeMealTypes.length > 1" class="add-meal-type-row">
|
<div v-if="canAddMealType" class="add-meal-type-row">
|
||||||
<button v-if="canAddMealType && !editing" class="add-meal-type-btn" @click="$emit('add-meal-type')">
|
<button class="add-meal-type-btn" @click="$emit('add-meal-type')">
|
||||||
+ Add meal type
|
+ Add meal type
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="activeMealTypes.length > 1"
|
|
||||||
class="edit-types-btn"
|
|
||||||
:class="{ active: editing }"
|
|
||||||
@click="editing = !editing"
|
|
||||||
>{{ editing ? 'Done' : 'Edit types' }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -90,44 +60,22 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useMealPlanStore } from '../stores/mealPlan'
|
import { useMealPlanStore } from '../stores/mealPlan'
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
activeMealTypes: string[]
|
activeMealTypes: string[]
|
||||||
canAddMealType: boolean
|
canAddMealType: boolean
|
||||||
mealTypeChanging?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
defineEmits<{
|
||||||
(e: 'slot-click', payload: { dayOfWeek: number; mealType: string }): void
|
(e: 'slot-click', payload: { dayOfWeek: number; mealType: string }): void
|
||||||
(e: 'add-meal-type'): void
|
(e: 'add-meal-type'): void
|
||||||
(e: 'remove-meal-type', mealType: string): void
|
|
||||||
(e: 'reorder-meal-types', newOrder: string[]): void
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const store = useMealPlanStore()
|
const store = useMealPlanStore()
|
||||||
const { getSlot } = store
|
const { getSlot } = store
|
||||||
|
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
const editing = ref(false)
|
|
||||||
|
|
||||||
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
|
||||||
function onMoveUp(idx: number) {
|
|
||||||
if (idx === 0) return
|
|
||||||
const arr = [...props.activeMealTypes]
|
|
||||||
const tmp = arr[idx - 1]!
|
|
||||||
arr[idx - 1] = arr[idx]!
|
|
||||||
arr[idx] = tmp
|
|
||||||
emit('reorder-meal-types', arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMoveDown(idx: number) {
|
|
||||||
if (idx === props.activeMealTypes.length - 1) return
|
|
||||||
const arr = [...props.activeMealTypes]
|
|
||||||
const tmp = arr[idx]!
|
|
||||||
arr[idx] = arr[idx + 1]!
|
|
||||||
arr[idx + 1] = tmp
|
|
||||||
emit('reorder-meal-types', arr)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -169,38 +117,10 @@ function onMoveDown(idx: number) {
|
||||||
.slot-title { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; color: var(--color-text); }
|
.slot-title { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; color: var(--color-text); }
|
||||||
.slot-empty { opacity: 0.25; font-size: 1rem; }
|
.slot-empty { opacity: 0.25; font-size: 1rem; }
|
||||||
|
|
||||||
.add-meal-type-row { padding: 0.4rem 0 0.2rem; display: flex; gap: 0.75rem; align-items: center; }
|
.add-meal-type-row { padding: 0.4rem 0 0.2rem; }
|
||||||
.add-meal-type-btn { font-size: 0.75rem; background: none; border: none; cursor: pointer; color: var(--color-accent); padding: 0; }
|
.add-meal-type-btn { font-size: 0.75rem; background: none; border: none; cursor: pointer; color: var(--color-accent); padding: 0; }
|
||||||
.edit-types-btn {
|
|
||||||
font-size: 0.75rem; background: none; border: none; cursor: pointer;
|
|
||||||
color: var(--color-text-secondary); padding: 0;
|
|
||||||
}
|
|
||||||
.edit-types-btn:hover { color: var(--color-text); }
|
|
||||||
.edit-types-btn.active { color: var(--color-accent); font-weight: 600; }
|
|
||||||
|
|
||||||
/* Edit mode — expand label column to fit controls */
|
|
||||||
.row-editing, .headers-editing { grid-template-columns: auto repeat(7, 1fr); }
|
|
||||||
.label-editing {
|
|
||||||
flex-direction: row; align-items: center; gap: 2px;
|
|
||||||
opacity: 1; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.label-text { flex: 1; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600; padding: 0 2px; }
|
|
||||||
.reorder-btn {
|
|
||||||
background: none; border: 1px solid var(--color-border); border-radius: 3px;
|
|
||||||
cursor: pointer; font-size: 0.6rem; padding: 1px 3px; line-height: 1;
|
|
||||||
color: var(--color-text-secondary); min-width: 16px;
|
|
||||||
}
|
|
||||||
.reorder-btn:hover:not(:disabled) { border-color: var(--color-accent); color: var(--color-accent); }
|
|
||||||
.reorder-btn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
.remove-btn {
|
|
||||||
background: none; border: none; cursor: pointer; font-size: 0.65rem;
|
|
||||||
color: var(--color-text-secondary); padding: 1px 3px; border-radius: 3px; line-height: 1;
|
|
||||||
}
|
|
||||||
.remove-btn:hover:not(:disabled) { color: var(--color-error, #e05252); background: var(--color-error-subtle, #fef2f2); }
|
|
||||||
.remove-btn:disabled { opacity: 0.25; cursor: default; }
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.day-headers, .meal-row { grid-template-columns: 2.5rem repeat(7, 1fr); }
|
.day-headers, .meal-row { grid-template-columns: 2.5rem repeat(7, 1fr); }
|
||||||
.row-editing, .headers-editing { grid-template-columns: auto repeat(7, 1fr); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -14,88 +14,18 @@
|
||||||
Week of {{ p.week_start }}
|
Week of {{ p.week_start }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
|
<button class="new-plan-btn" @click="onNewPlan">+ New week</button>
|
||||||
{{ planCreating ? 'Creating…' : '+ New week' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="planError" class="plan-error">{{ planError }}</p>
|
|
||||||
|
|
||||||
<template v-if="activePlan">
|
<template v-if="activePlan">
|
||||||
<!-- Compact expandable week grid (always visible) -->
|
<!-- Compact expandable week grid (always visible) -->
|
||||||
<MealPlanGrid
|
<MealPlanGrid
|
||||||
:active-meal-types="activePlan.meal_types"
|
:active-meal-types="activePlan.meal_types"
|
||||||
:can-add-meal-type="canAddMealType"
|
:can-add-meal-type="canAddMealType"
|
||||||
:meal-type-changing="mealTypeAdding"
|
|
||||||
@slot-click="onSlotClick"
|
@slot-click="onSlotClick"
|
||||||
@add-meal-type="onAddMealType"
|
@add-meal-type="onAddMealType"
|
||||||
@remove-meal-type="onRemoveMealType"
|
|
||||||
@reorder-meal-types="onReorderMealTypes"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Slot editor panel -->
|
|
||||||
<div v-if="slotEditing" class="slot-editor card">
|
|
||||||
<div class="slot-editor-header">
|
|
||||||
<span class="slot-editor-title">
|
|
||||||
{{ DAY_LABELS[slotEditing.dayOfWeek] }} · {{ slotEditing.mealType }}
|
|
||||||
</span>
|
|
||||||
<button class="close-btn" @click="slotEditing = null" aria-label="Close">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom label -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Custom label</label>
|
|
||||||
<input
|
|
||||||
v-model="slotCustomLabel"
|
|
||||||
class="form-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Taco night, Leftovers…"
|
|
||||||
maxlength="80"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pick from saved recipes -->
|
|
||||||
<div v-if="savedStore.saved.length" class="form-group">
|
|
||||||
<label class="form-label">Or pick a saved recipe</label>
|
|
||||||
<select class="week-select" v-model="slotRecipeId">
|
|
||||||
<option :value="null">— None —</option>
|
|
||||||
<option v-for="r in savedStore.saved" :key="r.recipe_id" :value="r.recipe_id">
|
|
||||||
{{ r.title }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p v-else class="slot-hint">Save recipes from the Recipes tab to pick them here.</p>
|
|
||||||
|
|
||||||
<div class="slot-editor-actions">
|
|
||||||
<button class="btn-secondary" @click="slotEditing = null">Cancel</button>
|
|
||||||
<button
|
|
||||||
v-if="currentSlot"
|
|
||||||
class="btn-danger-subtle"
|
|
||||||
@click="onClearSlot"
|
|
||||||
:disabled="slotSaving"
|
|
||||||
>Clear slot</button>
|
|
||||||
<button
|
|
||||||
class="btn-primary"
|
|
||||||
@click="onSaveSlot"
|
|
||||||
:disabled="slotSaving"
|
|
||||||
>{{ slotSaving ? 'Saving…' : 'Save' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Meal type picker -->
|
|
||||||
<div v-if="addingMealType" class="meal-type-picker card">
|
|
||||||
<span class="slot-editor-title">Add meal type</span>
|
|
||||||
<div class="chip-row">
|
|
||||||
<button
|
|
||||||
v-for="t in availableMealTypes"
|
|
||||||
:key="t"
|
|
||||||
class="btn-chip"
|
|
||||||
:disabled="mealTypeAdding"
|
|
||||||
@click="onPickMealType(t)"
|
|
||||||
>{{ t }}</button>
|
|
||||||
</div>
|
|
||||||
<button class="close-link" @click="addingMealType = false">Cancel</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Panel tabs: Shopping List | Prep Schedule -->
|
<!-- Panel tabs: Shopping List | Prep Schedule -->
|
||||||
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
|
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
|
||||||
<button
|
<button
|
||||||
|
|
@ -134,9 +64,7 @@
|
||||||
|
|
||||||
<div v-else-if="!loading" class="empty-plan-state">
|
<div v-else-if="!loading" class="empty-plan-state">
|
||||||
<p>No meal plan yet for this week.</p>
|
<p>No meal plan yet for this week.</p>
|
||||||
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
|
<button class="new-plan-btn" @click="onNewPlan">Start planning</button>
|
||||||
{{ planCreating ? 'Creating…' : 'Start planning' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -145,154 +73,49 @@
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useMealPlanStore } from '../stores/mealPlan'
|
import { useMealPlanStore } from '../stores/mealPlan'
|
||||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
|
||||||
import MealPlanGrid from './MealPlanGrid.vue'
|
import MealPlanGrid from './MealPlanGrid.vue'
|
||||||
import ShoppingListPanel from './ShoppingListPanel.vue'
|
import ShoppingListPanel from './ShoppingListPanel.vue'
|
||||||
import PrepSessionView from './PrepSessionView.vue'
|
import PrepSessionView from './PrepSessionView.vue'
|
||||||
import type { MealPlanSlot } from '../services/api'
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'shopping', label: 'Shopping List' },
|
{ id: 'shopping', label: 'Shopping List' },
|
||||||
{ id: 'prep', label: 'Prep Schedule' },
|
{ id: 'prep', label: 'Prep Schedule' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
|
||||||
const ALL_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack']
|
|
||||||
|
|
||||||
type TabId = typeof TABS[number]['id']
|
type TabId = typeof TABS[number]['id']
|
||||||
|
|
||||||
const store = useMealPlanStore()
|
const store = useMealPlanStore()
|
||||||
const savedStore = useSavedRecipesStore()
|
|
||||||
const { plans, activePlan, loading } = storeToRefs(store)
|
const { plans, activePlan, loading } = storeToRefs(store)
|
||||||
|
|
||||||
const activeTab = ref<TabId>('shopping')
|
const activeTab = ref<TabId>('shopping')
|
||||||
const planError = ref<string | null>(null)
|
|
||||||
const planCreating = ref(false)
|
|
||||||
|
|
||||||
// ── slot editor ───────────────────────────────────────────────────────────────
|
|
||||||
const slotEditing = ref<{ dayOfWeek: number; mealType: string } | null>(null)
|
|
||||||
const slotCustomLabel = ref('')
|
|
||||||
const slotRecipeId = ref<number | null>(null)
|
|
||||||
const slotSaving = ref(false)
|
|
||||||
|
|
||||||
const currentSlot = computed((): MealPlanSlot | undefined => {
|
|
||||||
if (!slotEditing.value) return undefined
|
|
||||||
return store.getSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── meal type picker ──────────────────────────────────────────────────────────
|
|
||||||
const addingMealType = ref(false)
|
|
||||||
const mealTypeAdding = ref(false)
|
|
||||||
|
|
||||||
const availableMealTypes = computed(() =>
|
|
||||||
ALL_MEAL_TYPES.filter(t => !activePlan.value?.meal_types.includes(t))
|
|
||||||
)
|
|
||||||
|
|
||||||
// canAddMealType is a UI hint — backend enforces the paid gate authoritatively
|
// canAddMealType is a UI hint — backend enforces the paid gate authoritatively
|
||||||
const canAddMealType = computed(() =>
|
const canAddMealType = computed(() =>
|
||||||
(activePlan.value?.meal_types.length ?? 0) < 4
|
(activePlan.value?.meal_types.length ?? 0) < 4
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => store.loadPlans())
|
||||||
await Promise.all([store.loadPlans(), savedStore.load()])
|
|
||||||
store.autoSelectPlan(mondayOfCurrentWeek())
|
|
||||||
})
|
|
||||||
|
|
||||||
function mondayOfCurrentWeek(): string {
|
|
||||||
const today = new Date()
|
|
||||||
const day = today.getDay() // 0=Sun, 1=Mon...
|
|
||||||
// Build date string from local parts to avoid UTC-offset day drift
|
|
||||||
const d = new Date(today)
|
|
||||||
d.setDate(today.getDate() - ((day + 6) % 7))
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onNewPlan() {
|
async function onNewPlan() {
|
||||||
planError.value = null
|
const today = new Date()
|
||||||
planCreating.value = true
|
const day = today.getDay()
|
||||||
const weekStart = mondayOfCurrentWeek()
|
// Compute Monday of current week (getDay: 0=Sun, 1=Mon...)
|
||||||
try {
|
const monday = new Date(today)
|
||||||
await store.createPlan(weekStart, ['dinner'])
|
monday.setDate(today.getDate() - ((day + 6) % 7))
|
||||||
} catch (err: unknown) {
|
const weekStart = monday.toISOString().split('T')[0] ?? monday.toISOString().slice(0, 10)
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
await store.createPlan(weekStart, ['dinner'])
|
||||||
if (msg.includes('409') || msg.toLowerCase().includes('already exists')) {
|
|
||||||
const existing = plans.value.find(p => p.week_start === weekStart)
|
|
||||||
if (existing) await store.setActivePlan(existing.id)
|
|
||||||
} else {
|
|
||||||
planError.value = `Couldn't create plan: ${msg}`
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
planCreating.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSelectPlan(planId: number) {
|
async function onSelectPlan(planId: number) {
|
||||||
if (planId) await store.setActivePlan(planId)
|
if (planId) await store.setActivePlan(planId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSlotClick(payload: { dayOfWeek: number; mealType: string }) {
|
function onSlotClick(_: { dayOfWeek: number; mealType: string }) {
|
||||||
slotEditing.value = payload
|
// Recipe picker integration filed as follow-up
|
||||||
const existing = store.getSlot(payload.dayOfWeek, payload.mealType)
|
|
||||||
slotCustomLabel.value = existing?.custom_label ?? ''
|
|
||||||
slotRecipeId.value = existing?.recipe_id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSaveSlot() {
|
|
||||||
if (!slotEditing.value) return
|
|
||||||
slotSaving.value = true
|
|
||||||
try {
|
|
||||||
await store.upsertSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType, {
|
|
||||||
recipe_id: slotRecipeId.value,
|
|
||||||
custom_label: slotCustomLabel.value.trim() || null,
|
|
||||||
})
|
|
||||||
slotEditing.value = null
|
|
||||||
} finally {
|
|
||||||
slotSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onClearSlot() {
|
|
||||||
if (!slotEditing.value) return
|
|
||||||
slotSaving.value = true
|
|
||||||
try {
|
|
||||||
await store.clearSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
|
|
||||||
slotEditing.value = null
|
|
||||||
} finally {
|
|
||||||
slotSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddMealType() {
|
function onAddMealType() {
|
||||||
addingMealType.value = true
|
// Add meal type picker — Paid gate enforced by backend
|
||||||
}
|
|
||||||
|
|
||||||
async function onPickMealType(mealType: string) {
|
|
||||||
mealTypeAdding.value = true
|
|
||||||
try {
|
|
||||||
await store.addMealType(mealType)
|
|
||||||
addingMealType.value = false
|
|
||||||
} finally {
|
|
||||||
mealTypeAdding.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onRemoveMealType(mealType: string) {
|
|
||||||
mealTypeAdding.value = true
|
|
||||||
try {
|
|
||||||
await store.removeMealType(mealType)
|
|
||||||
} finally {
|
|
||||||
mealTypeAdding.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onReorderMealTypes(newOrder: string[]) {
|
|
||||||
mealTypeAdding.value = true
|
|
||||||
try {
|
|
||||||
await store.reorderMealTypes(newOrder)
|
|
||||||
} finally {
|
|
||||||
mealTypeAdding.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -312,29 +135,6 @@ async function onReorderMealTypes(newOrder: string[]) {
|
||||||
}
|
}
|
||||||
.new-plan-btn:hover { background: var(--color-accent); color: white; }
|
.new-plan-btn:hover { background: var(--color-accent); color: white; }
|
||||||
|
|
||||||
/* Slot editor */
|
|
||||||
.slot-editor, .meal-type-picker {
|
|
||||||
padding: 1rem; border-radius: 8px;
|
|
||||||
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
||||||
display: flex; flex-direction: column; gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.slot-editor-header { display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.slot-editor-title { font-size: 0.85rem; font-weight: 600; }
|
|
||||||
.close-btn {
|
|
||||||
background: none; border: none; cursor: pointer; font-size: 0.9rem;
|
|
||||||
color: var(--color-text-secondary); padding: 0.1rem 0.3rem; border-radius: 4px;
|
|
||||||
}
|
|
||||||
.close-btn:hover { background: var(--color-surface-2); }
|
|
||||||
.slot-hint { font-size: 0.8rem; opacity: 0.55; margin: 0; }
|
|
||||||
.slot-editor-actions { display: flex; gap: 0.5rem; justify-content: flex-end; flex-wrap: wrap; }
|
|
||||||
|
|
||||||
.chip-row { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
|
||||||
.close-link {
|
|
||||||
background: none; border: none; cursor: pointer; font-size: 0.8rem;
|
|
||||||
color: var(--color-text-secondary); align-self: flex-start; padding: 0;
|
|
||||||
}
|
|
||||||
.close-link:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.panel-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
|
.panel-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
|
||||||
.panel-tab {
|
.panel-tab {
|
||||||
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
|
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
|
||||||
|
|
@ -350,11 +150,4 @@ async function onReorderMealTypes(newOrder: string[]) {
|
||||||
.tab-panel { padding-top: 0.75rem; }
|
.tab-panel { padding-top: 0.75rem; }
|
||||||
|
|
||||||
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
|
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
|
||||||
|
|
||||||
.plan-error {
|
|
||||||
font-size: 0.82rem; color: var(--color-error, #e05252);
|
|
||||||
background: var(--color-error-subtle, #fef2f2);
|
|
||||||
border: 1px solid var(--color-error, #e05252); border-radius: 6px;
|
|
||||||
padding: 0.4rem 0.75rem; margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -175,8 +175,7 @@ async function uploadFile(file: File) {
|
||||||
|
|
||||||
async function loadReceipts() {
|
async function loadReceipts() {
|
||||||
try {
|
try {
|
||||||
const raw = await receiptsAPI.listReceipts()
|
const data = await receiptsAPI.listReceipts()
|
||||||
const data = Array.isArray(raw) ? raw : []
|
|
||||||
// Fetch OCR data for each receipt
|
// Fetch OCR data for each receipt
|
||||||
receipts.value = await Promise.all(
|
receipts.value = await Promise.all(
|
||||||
data.map(async (receipt: any) => {
|
data.map(async (receipt: any) => {
|
||||||
|
|
|
||||||
|
|
@ -15,19 +15,8 @@
|
||||||
<div v-if="loadingDomains" class="text-secondary text-sm">Loading…</div>
|
<div v-if="loadingDomains" class="text-secondary text-sm">Loading…</div>
|
||||||
|
|
||||||
<div v-else-if="activeDomain" class="browser-body">
|
<div v-else-if="activeDomain" class="browser-body">
|
||||||
<!-- Corpus unavailable notice — shown when all category counts are 0 -->
|
|
||||||
<div v-if="allCountsZero" class="browser-unavailable card p-md text-secondary text-sm">
|
|
||||||
Recipe library is not available on this instance yet. Browse categories will appear once the recipe corpus is loaded.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category list + Surprise Me -->
|
<!-- Category list + Surprise Me -->
|
||||||
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
|
<div class="category-list mb-md flex flex-wrap gap-xs">
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
|
|
||||||
@click="selectCategory('_all')"
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat.category"
|
:key="cat.category"
|
||||||
|
|
@ -36,7 +25,6 @@
|
||||||
>
|
>
|
||||||
{{ cat.category }}
|
{{ cat.category }}
|
||||||
<span class="cat-count">{{ cat.recipe_count }}</span>
|
<span class="cat-count">{{ cat.recipe_count }}</span>
|
||||||
<span v-if="cat.has_subcategories" class="cat-drill-indicator" title="Has subcategories">›</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="categories.length > 1"
|
v-if="categories.length > 1"
|
||||||
|
|
@ -48,64 +36,11 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subcategory row — shown when the active category has subcategories -->
|
|
||||||
<div
|
|
||||||
v-if="activeCategoryHasSubs && (subcategories.length > 0 || loadingSubcategories)"
|
|
||||||
class="subcategory-list mb-md flex flex-wrap gap-xs"
|
|
||||||
>
|
|
||||||
<span v-if="loadingSubcategories" class="text-secondary text-xs">Loading…</span>
|
|
||||||
<template v-else>
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
|
|
||||||
@click="selectSubcategory(null)"
|
|
||||||
>
|
|
||||||
All {{ activeCategory }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="sub in subcategories"
|
|
||||||
:key="sub.subcategory"
|
|
||||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
|
|
||||||
@click="selectSubcategory(sub.subcategory)"
|
|
||||||
>
|
|
||||||
{{ sub.subcategory }}
|
|
||||||
<span class="cat-count">{{ sub.recipe_count }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recipe grid -->
|
<!-- Recipe grid -->
|
||||||
<template v-if="activeCategory">
|
<template v-if="activeCategory">
|
||||||
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Search + sort controls -->
|
|
||||||
<div class="browser-controls flex gap-sm mb-sm flex-wrap align-center">
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
@input="onSearchInput"
|
|
||||||
type="search"
|
|
||||||
placeholder="Filter by title…"
|
|
||||||
class="browser-search"
|
|
||||||
/>
|
|
||||||
<div class="sort-btns flex gap-xs">
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'default' }]"
|
|
||||||
@click="setSort('default')"
|
|
||||||
title="Corpus order"
|
|
||||||
>Default</button>
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha' }]"
|
|
||||||
@click="setSort('alpha')"
|
|
||||||
title="Alphabetical A→Z"
|
|
||||||
>A→Z</button>
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha_desc' }]"
|
|
||||||
@click="setSort('alpha_desc')"
|
|
||||||
title="Alphabetical Z→A"
|
|
||||||
>Z→A</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="results-header flex-between mb-sm">
|
<div class="results-header flex-between mb-sm">
|
||||||
<span class="text-sm text-secondary">
|
<span class="text-sm text-secondary">
|
||||||
{{ total }} recipes
|
{{ total }} recipes
|
||||||
|
|
@ -166,7 +101,7 @@
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="!allCountsZero" class="text-secondary text-sm">Loading recipes…</div>
|
<div v-else class="text-secondary text-sm">Loading recipes…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!loadingDomains" class="text-secondary text-sm">Loading…</div>
|
<div v-else-if="!loadingDomains" class="text-secondary text-sm">Loading…</div>
|
||||||
|
|
@ -185,7 +120,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, type BrowserRecipe } from '../services/api'
|
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserRecipe } from '../services/api'
|
||||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
import { useInventoryStore } from '../stores/inventory'
|
import { useInventoryStore } from '../stores/inventory'
|
||||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||||
|
|
@ -201,9 +136,6 @@ const domains = ref<BrowserDomain[]>([])
|
||||||
const activeDomain = ref<string | null>(null)
|
const activeDomain = ref<string | null>(null)
|
||||||
const categories = ref<BrowserCategory[]>([])
|
const categories = ref<BrowserCategory[]>([])
|
||||||
const activeCategory = ref<string | null>(null)
|
const activeCategory = ref<string | null>(null)
|
||||||
const subcategories = ref<BrowserSubcategory[]>([])
|
|
||||||
const activeSubcategory = ref<string | null>(null)
|
|
||||||
const loadingSubcategories = ref(false)
|
|
||||||
const recipes = ref<BrowserRecipe[]>([])
|
const recipes = ref<BrowserRecipe[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
@ -211,18 +143,8 @@ const pageSize = 20
|
||||||
const loadingDomains = ref(false)
|
const loadingDomains = ref(false)
|
||||||
const loadingRecipes = ref(false)
|
const loadingRecipes = ref(false)
|
||||||
const savingRecipe = ref<BrowserRecipe | null>(null)
|
const savingRecipe = ref<BrowserRecipe | null>(null)
|
||||||
const searchQuery = ref('')
|
|
||||||
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc'>('default')
|
|
||||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
const allCountsZero = computed(() =>
|
|
||||||
categories.value.length > 0 && categories.value.every(c => c.recipe_count === 0)
|
|
||||||
)
|
|
||||||
const activeCategoryHasSubs = computed(() => {
|
|
||||||
if (!activeCategory.value || activeCategory.value === '_all') return false
|
|
||||||
return categories.value.find(c => c.category === activeCategory.value)?.has_subcategories ?? false
|
|
||||||
})
|
|
||||||
|
|
||||||
const pantryItems = computed(() =>
|
const pantryItems = computed(() =>
|
||||||
inventoryStore.items
|
inventoryStore.items
|
||||||
|
|
@ -250,34 +172,15 @@ onMounted(async () => {
|
||||||
if (!savedStore.savedIds.size) savedStore.load()
|
if (!savedStore.savedIds.size) savedStore.load()
|
||||||
})
|
})
|
||||||
|
|
||||||
function onSearchInput() {
|
|
||||||
if (searchDebounce) clearTimeout(searchDebounce)
|
|
||||||
searchDebounce = setTimeout(() => {
|
|
||||||
page.value = 1
|
|
||||||
loadRecipes()
|
|
||||||
}, 350)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSort(s: 'default' | 'alpha' | 'alpha_desc') {
|
|
||||||
if (sortOrder.value === s) return
|
|
||||||
sortOrder.value = s
|
|
||||||
page.value = 1
|
|
||||||
loadRecipes()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectDomain(domainId: string) {
|
async function selectDomain(domainId: string) {
|
||||||
activeDomain.value = domainId
|
activeDomain.value = domainId
|
||||||
activeCategory.value = null
|
activeCategory.value = null
|
||||||
recipes.value = []
|
recipes.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
page.value = 1
|
page.value = 1
|
||||||
searchQuery.value = ''
|
|
||||||
sortOrder.value = 'default'
|
|
||||||
categories.value = await browserAPI.listCategories(domainId)
|
categories.value = await browserAPI.listCategories(domainId)
|
||||||
// Auto-select the most-populated category so content appears immediately.
|
// Auto-select the most-populated category so content appears immediately
|
||||||
// Skip when all counts are 0 (corpus not seeded) — no point loading an empty result.
|
if (categories.value.length > 0) {
|
||||||
const hasRecipes = categories.value.some(c => c.recipe_count > 0)
|
|
||||||
if (hasRecipes) {
|
|
||||||
const top = categories.value.reduce((best, c) =>
|
const top = categories.value.reduce((best, c) =>
|
||||||
c.recipe_count > best.recipe_count ? c : best, categories.value[0]!)
|
c.recipe_count > best.recipe_count ? c : best, categories.value[0]!)
|
||||||
selectCategory(top.category)
|
selectCategory(top.category)
|
||||||
|
|
@ -292,27 +195,6 @@ function surpriseMe() {
|
||||||
|
|
||||||
async function selectCategory(category: string) {
|
async function selectCategory(category: string) {
|
||||||
activeCategory.value = category
|
activeCategory.value = category
|
||||||
activeSubcategory.value = null
|
|
||||||
subcategories.value = []
|
|
||||||
page.value = 1
|
|
||||||
searchQuery.value = ''
|
|
||||||
sortOrder.value = 'default'
|
|
||||||
|
|
||||||
// Fetch subcategories in the background when the category supports them,
|
|
||||||
// then immediately start loading recipes at the full-category level.
|
|
||||||
const catMeta = categories.value.find(c => c.category === category)
|
|
||||||
if (catMeta?.has_subcategories) {
|
|
||||||
loadingSubcategories.value = true
|
|
||||||
browserAPI.listSubcategories(activeDomain.value!, category)
|
|
||||||
.then(subs => { subcategories.value = subs })
|
|
||||||
.finally(() => { loadingSubcategories.value = false })
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadRecipes()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectSubcategory(subcat: string | null) {
|
|
||||||
activeSubcategory.value = subcat
|
|
||||||
page.value = 1
|
page.value = 1
|
||||||
await loadRecipes()
|
await loadRecipes()
|
||||||
}
|
}
|
||||||
|
|
@ -335,9 +217,6 @@ async function loadRecipes() {
|
||||||
pantry_items: pantryItems.value.length > 0
|
pantry_items: pantryItems.value.length > 0
|
||||||
? pantryItems.value.join(',')
|
? pantryItems.value.join(',')
|
||||||
: undefined,
|
: undefined,
|
||||||
subcategory: activeSubcategory.value ?? undefined,
|
|
||||||
q: searchQuery.value.trim() || undefined,
|
|
||||||
sort: sortOrder.value !== 'default' ? sortOrder.value : undefined,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
recipes.value = result.recipes
|
recipes.value = result.recipes
|
||||||
|
|
@ -400,68 +279,6 @@ async function doUnsave(recipeId: number) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-drill-indicator {
|
|
||||||
margin-left: var(--spacing-xs);
|
|
||||||
opacity: 0.5;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcategory-list {
|
|
||||||
padding-left: var(--spacing-sm);
|
|
||||||
border-left: 2px solid var(--color-border);
|
|
||||||
margin-left: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcat-btn {
|
|
||||||
font-size: var(--font-size-xs, 0.78rem);
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcat-btn.active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subcat-btn.active .cat-count {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-controls {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-search {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
max-width: 260px;
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-search:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-btn {
|
|
||||||
font-size: var(--font-size-xs, 0.75rem);
|
|
||||||
padding: 2px var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-btn.active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-grid {
|
.recipe-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -36,20 +36,6 @@
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="detail-body">
|
<div class="detail-body">
|
||||||
|
|
||||||
<!-- Serving multiplier -->
|
|
||||||
<div class="serving-scale-row">
|
|
||||||
<span class="serving-scale-label text-sm text-muted">Scale:</span>
|
|
||||||
<div class="serving-scale-btns" role="group" aria-label="Serving multiplier">
|
|
||||||
<button
|
|
||||||
v-for="n in [1, 2, 3, 4]"
|
|
||||||
:key="n"
|
|
||||||
:class="['scale-btn', { active: servingScale === n }]"
|
|
||||||
:aria-pressed="servingScale === n"
|
|
||||||
@click="servingScale = n"
|
|
||||||
>{{ n }}×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ingredients: have vs. need in a two-column layout -->
|
<!-- Ingredients: have vs. need in a two-column layout -->
|
||||||
<div class="ingredients-grid">
|
<div class="ingredients-grid">
|
||||||
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
||||||
|
|
@ -57,7 +43,7 @@
|
||||||
<ul class="ingredient-list">
|
<ul class="ingredient-list">
|
||||||
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
|
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
|
||||||
<span class="ing-icon ing-icon-have">✓</span>
|
<span class="ing-icon ing-icon-have">✓</span>
|
||||||
<span>{{ scaleIngredient(ing, servingScale) }}</span>
|
<span>{{ ing }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -80,7 +66,7 @@
|
||||||
:checked="checkedIngredients.has(ing)"
|
:checked="checkedIngredients.has(ing)"
|
||||||
@change="toggleIngredient(ing)"
|
@change="toggleIngredient(ing)"
|
||||||
/>
|
/>
|
||||||
<span class="ing-name">{{ scaleIngredient(ing, servingScale) }}</span>
|
<span class="ing-name">{{ ing }}</span>
|
||||||
</label>
|
</label>
|
||||||
<a
|
<a
|
||||||
v-if="groceryLinkFor(ing)"
|
v-if="groceryLinkFor(ing)"
|
||||||
|
|
@ -262,69 +248,6 @@ const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
|
||||||
const cookDone = ref(false)
|
const cookDone = ref(false)
|
||||||
const shareCopied = ref(false)
|
const shareCopied = ref(false)
|
||||||
|
|
||||||
// Serving scale multiplier: 1×, 2×, 3×, 4×
|
|
||||||
const servingScale = ref(1)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scale a freeform ingredient string by a multiplier.
|
|
||||||
* Handles integers, decimals, and simple fractions (1/2, 1/4, 3/4, etc.).
|
|
||||||
* Ranges like "2-3" are scaled on both ends.
|
|
||||||
* Returns the original string unchanged if no leading number is found.
|
|
||||||
*/
|
|
||||||
function scaleIngredient(ing: string, scale: number): string {
|
|
||||||
if (scale === 1) return ing
|
|
||||||
|
|
||||||
// Match an optional leading fraction OR decimal OR integer,
|
|
||||||
// optionally followed by a space and another fraction (mixed number like "1 1/2")
|
|
||||||
const numPat = String.raw`(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)`
|
|
||||||
const rangePat = new RegExp(`^${numPat}(?:\\s*-\\s*${numPat})?`)
|
|
||||||
|
|
||||||
const m = ing.match(rangePat)
|
|
||||||
if (!m) return ing
|
|
||||||
|
|
||||||
function parseFrac(s: string): number {
|
|
||||||
const mixed = s.match(/^(\d+)\s+(\d+)\/(\d+)$/)
|
|
||||||
if (mixed) return parseInt(mixed[1]!) + parseInt(mixed[2]!) / parseInt(mixed[3]!)
|
|
||||||
const frac = s.match(/^(\d+)\/(\d+)$/)
|
|
||||||
if (frac) return parseInt(frac[1]!) / parseInt(frac[2]!)
|
|
||||||
return parseFloat(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtNum(n: number): string {
|
|
||||||
// Try to express as a simple fraction for common baking values
|
|
||||||
const fracs: [number, string][] = [
|
|
||||||
[0.125, '1/8'], [0.25, '1/4'], [0.333, '1/3'], [0.5, '1/2'],
|
|
||||||
[0.667, '2/3'], [0.75, '3/4'],
|
|
||||||
]
|
|
||||||
for (const [val, str] of fracs) {
|
|
||||||
if (Math.abs(n - Math.round(n / val) * val) < 0.01 && n < 1) return str
|
|
||||||
}
|
|
||||||
// Mixed numbers
|
|
||||||
const whole = Math.floor(n)
|
|
||||||
const remainder = n - whole
|
|
||||||
if (whole > 0 && remainder > 0.05) {
|
|
||||||
for (const [val, str] of fracs) {
|
|
||||||
if (Math.abs(remainder - val) < 0.05) return `${whole} ${str}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Round to reasonable precision
|
|
||||||
return whole > 0 && remainder < 0.05 ? `${whole}` : n.toFixed(1).replace(/\.0$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const low = parseFrac(m[1]!)
|
|
||||||
const scaledLow = fmtNum(low * scale)
|
|
||||||
|
|
||||||
let scaled: string
|
|
||||||
if (m[2] !== undefined) {
|
|
||||||
const high = parseFrac(m[2])
|
|
||||||
scaled = `${scaledLow}-${fmtNum(high * scale)}`
|
|
||||||
} else {
|
|
||||||
scaled = scaledLow
|
|
||||||
}
|
|
||||||
|
|
||||||
return scaled + ing.slice(m[0].length)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shopping: add purchased ingredients to pantry
|
// Shopping: add purchased ingredients to pantry
|
||||||
const checkedIngredients = ref<Set<string>>(new Set())
|
const checkedIngredients = ref<Set<string>>(new Set())
|
||||||
const addingToPantry = ref(false)
|
const addingToPantry = ref(false)
|
||||||
|
|
@ -404,7 +327,6 @@ function groceryLinkFor(ingredient: string): GroceryLink | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCook() {
|
function handleCook() {
|
||||||
recipesStore.logCook(props.recipe.id, props.recipe.title)
|
|
||||||
cookDone.value = true
|
cookDone.value = true
|
||||||
emit('cooked', props.recipe)
|
emit('cooked', props.recipe)
|
||||||
}
|
}
|
||||||
|
|
@ -523,40 +445,6 @@ function handleCook() {
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Serving scale row ──────────────────────────────────── */
|
|
||||||
.serving-scale-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.serving-scale-label {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.serving-scale-btns {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scale-btn {
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scale-btn.active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-on-primary, #fff);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Ingredients grid ───────────────────────────────────── */
|
/* ── Ingredients grid ───────────────────────────────────── */
|
||||||
.ingredients-grid {
|
.ingredients-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -169,17 +169,6 @@
|
||||||
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
|
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Can Make Now toggle -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="flex-start gap-sm shopping-toggle">
|
|
||||||
<input type="checkbox" v-model="recipesStore.pantryMatchOnly" :disabled="recipesStore.shoppingMode" />
|
|
||||||
<span class="form-label" style="margin-bottom: 0;">Can make now (no missing ingredients)</span>
|
|
||||||
</label>
|
|
||||||
<p v-if="recipesStore.pantryMatchOnly && !recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
|
|
||||||
Only recipes where every ingredient is in your pantry — no substitutions, no shopping.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shopping Mode (temporary home — moves to Shopping tab in #71) -->
|
<!-- Shopping Mode (temporary home — moves to Shopping tab in #71) -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="flex-start gap-sm shopping-toggle">
|
<label class="flex-start gap-sm shopping-toggle">
|
||||||
|
|
@ -191,8 +180,8 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Max Missing — hidden in shopping mode or pantry-match-only mode -->
|
<!-- Max Missing — hidden in shopping mode -->
|
||||||
<div v-if="!recipesStore.shoppingMode && !recipesStore.pantryMatchOnly" class="form-group">
|
<div v-if="!recipesStore.shoppingMode" class="form-group">
|
||||||
<label class="form-label" for="max-missing">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
|
<label class="form-label" for="max-missing">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
|
||||||
<input
|
<input
|
||||||
id="max-missing"
|
id="max-missing"
|
||||||
|
|
@ -287,10 +276,7 @@
|
||||||
@click="handleSuggest"
|
@click="handleSuggest"
|
||||||
>
|
>
|
||||||
<span v-if="recipesStore.loading && !isLoadingMore">
|
<span v-if="recipesStore.loading && !isLoadingMore">
|
||||||
<span class="spinner spinner-sm inline-spinner"></span>
|
<span class="spinner spinner-sm inline-spinner"></span> Finding recipes…
|
||||||
<span v-if="recipesStore.jobStatus === 'queued'">Queued…</span>
|
|
||||||
<span v-else-if="recipesStore.jobStatus === 'running'">Generating…</span>
|
|
||||||
<span v-else>Finding recipes…</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>Suggest Recipes</span>
|
<span v-else>Suggest Recipes</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -315,9 +301,7 @@
|
||||||
|
|
||||||
<!-- Screen reader announcement for loading + results -->
|
<!-- Screen reader announcement for loading + results -->
|
||||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||||
<span v-if="recipesStore.loading && recipesStore.jobStatus === 'queued'">Recipe request queued, waiting for model…</span>
|
<span v-if="recipesStore.loading">Finding recipes…</span>
|
||||||
<span v-else-if="recipesStore.loading && recipesStore.jobStatus === 'running'">Generating your recipe now…</span>
|
|
||||||
<span v-else-if="recipesStore.loading">Finding recipes…</span>
|
|
||||||
<span v-else-if="recipesStore.result">
|
<span v-else-if="recipesStore.result">
|
||||||
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
|
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -375,14 +359,6 @@
|
||||||
:aria-pressed="filterMissing === 2"
|
:aria-pressed="filterMissing === 2"
|
||||||
@click="filterMissing = filterMissing === 2 ? null : 2"
|
@click="filterMissing = filterMissing === 2 ? null : 2"
|
||||||
>≤2 missing</button>
|
>≤2 missing</button>
|
||||||
<!-- Complexity filter chips (#55 / #58) -->
|
|
||||||
<button
|
|
||||||
v-for="cx in ['easy', 'moderate', 'involved']"
|
|
||||||
:key="cx"
|
|
||||||
:class="['filter-chip', { active: filterComplexity === cx }]"
|
|
||||||
:aria-pressed="filterComplexity === cx"
|
|
||||||
@click="filterComplexity = filterComplexity === cx ? null : cx"
|
|
||||||
>{{ cx }}</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="hasActiveFilters"
|
v-if="hasActiveFilters"
|
||||||
class="filter-chip filter-chip-clear"
|
class="filter-chip filter-chip-clear"
|
||||||
|
|
@ -390,33 +366,6 @@
|
||||||
@click="clearFilters"
|
@click="clearFilters"
|
||||||
><span aria-hidden="true">✕</span> Clear</button>
|
><span aria-hidden="true">✕</span> Clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zero-decision picks (#53 Surprise Me / #57 Just Pick One) -->
|
|
||||||
<div v-if="filteredSuggestions.length > 0" class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
|
|
||||||
<button class="btn btn-secondary btn-sm" @click="pickSurprise" :disabled="filteredSuggestions.length === 0">
|
|
||||||
🎲 Surprise me
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-sm" @click="pickBest" :disabled="filteredSuggestions.length === 0">
|
|
||||||
⚡ Just pick one
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Spotlight (Surprise Me / Just Pick One result) -->
|
|
||||||
<div v-if="spotlightRecipe" class="card spotlight-card slide-up mb-md">
|
|
||||||
<div class="flex-between mb-sm">
|
|
||||||
<h3 class="text-lg font-bold">{{ spotlightRecipe.title }}</h3>
|
|
||||||
<div class="flex gap-xs" style="align-items:center">
|
|
||||||
<span v-if="spotlightRecipe.complexity" :class="['status-badge', `complexity-${spotlightRecipe.complexity}`]">{{ spotlightRecipe.complexity }}</span>
|
|
||||||
<span v-if="spotlightRecipe.estimated_time_min" class="status-badge status-neutral">~{{ spotlightRecipe.estimated_time_min }}m</span>
|
|
||||||
<button class="btn-icon" @click="spotlightRecipe = null" aria-label="Dismiss">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-secondary mb-xs">{{ spotlightRecipe.match_count }} ingredients matched from your pantry</p>
|
|
||||||
<button class="btn btn-primary btn-sm" @click="selectedRecipe = spotlightRecipe; spotlightRecipe = null">
|
|
||||||
Cook this
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-sm ml-sm" @click="pickSurprise">Try another</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No suggestions -->
|
<!-- No suggestions -->
|
||||||
|
|
@ -443,8 +392,6 @@
|
||||||
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
|
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
|
||||||
<div class="flex flex-wrap gap-xs" style="align-items:center">
|
<div class="flex flex-wrap gap-xs" style="align-items:center">
|
||||||
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
|
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
|
||||||
<span v-if="recipe.complexity" :class="['status-badge', `complexity-${recipe.complexity}`]">{{ recipe.complexity }}</span>
|
|
||||||
<span v-if="recipe.estimated_time_min" class="status-badge status-neutral">~{{ recipe.estimated_time_min }}m</span>
|
|
||||||
<span class="status-badge status-info">Level {{ recipe.level }}</span>
|
<span class="status-badge status-info">Level {{ recipe.level }}</span>
|
||||||
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
|
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
|
||||||
<button
|
<button
|
||||||
|
|
@ -781,8 +728,6 @@ const selectedRecipe = ref<RecipeSuggestion | null>(null)
|
||||||
const filterText = ref('')
|
const filterText = ref('')
|
||||||
const filterLevel = ref<number | null>(null)
|
const filterLevel = ref<number | null>(null)
|
||||||
const filterMissing = ref<number | null>(null)
|
const filterMissing = ref<number | null>(null)
|
||||||
const filterComplexity = ref<string | null>(null)
|
|
||||||
const spotlightRecipe = ref<RecipeSuggestion | null>(null)
|
|
||||||
|
|
||||||
const availableLevels = computed(() => {
|
const availableLevels = computed(() => {
|
||||||
if (!recipesStore.result) return []
|
if (!recipesStore.result) return []
|
||||||
|
|
@ -806,35 +751,17 @@ const filteredSuggestions = computed(() => {
|
||||||
if (filterMissing.value !== null) {
|
if (filterMissing.value !== null) {
|
||||||
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
|
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
|
||||||
}
|
}
|
||||||
if (filterComplexity.value !== null) {
|
|
||||||
items = items.filter((r) => r.complexity === filterComplexity.value)
|
|
||||||
}
|
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActiveFilters = computed(
|
const hasActiveFilters = computed(
|
||||||
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null || filterComplexity.value !== null
|
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null
|
||||||
)
|
)
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
filterText.value = ''
|
filterText.value = ''
|
||||||
filterLevel.value = null
|
filterLevel.value = null
|
||||||
filterMissing.value = null
|
filterMissing.value = null
|
||||||
filterComplexity.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickSurprise() {
|
|
||||||
const pool = filteredSuggestions.value
|
|
||||||
if (!pool.length) return
|
|
||||||
const exclude = spotlightRecipe.value?.id
|
|
||||||
const candidates = pool.length > 1 ? pool.filter((r) => r.id !== exclude) : pool
|
|
||||||
spotlightRecipe.value = candidates[Math.floor(Math.random() * candidates.length)] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickBest() {
|
|
||||||
const pool = filteredSuggestions.value
|
|
||||||
if (!pool.length) return
|
|
||||||
spotlightRecipe.value = pool[0] ?? null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
|
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
|
||||||
|
|
@ -956,19 +883,6 @@ const pantryItems = computed(() => {
|
||||||
return sorted.map((item) => item.product_name).filter(Boolean) as string[]
|
return sorted.map((item) => item.product_name).filter(Boolean) as string[]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Secondary-state items: expired but still usable in specific recipes.
|
|
||||||
// Maps product_name → secondary_state label (e.g. "Bread" → "stale").
|
|
||||||
// Sent alongside pantry_items so the recipe engine can boost relevant recipes.
|
|
||||||
const secondaryPantryItems = computed<Record<string, string>>(() => {
|
|
||||||
const result: Record<string, string> = {}
|
|
||||||
for (const item of inventoryStore.items) {
|
|
||||||
if (item.secondary_state && item.product_name) {
|
|
||||||
result[item.product_name] = item.secondary_state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// Grocery links relevant to a specific recipe's missing ingredients
|
// Grocery links relevant to a specific recipe's missing ingredients
|
||||||
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
|
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
|
||||||
if (!recipesStore.result) return []
|
if (!recipesStore.result) return []
|
||||||
|
|
@ -1043,12 +957,12 @@ function onNutritionInput(key: NutritionKey, e: Event) {
|
||||||
// Suggest handler
|
// Suggest handler
|
||||||
async function handleSuggest() {
|
async function handleSuggest() {
|
||||||
isLoadingMore.value = false
|
isLoadingMore.value = false
|
||||||
await recipesStore.suggest(pantryItems.value, secondaryPantryItems.value)
|
await recipesStore.suggest(pantryItems.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLoadMore() {
|
async function handleLoadMore() {
|
||||||
isLoadingMore.value = true
|
isLoadingMore.value = true
|
||||||
await recipesStore.loadMore(pantryItems.value, secondaryPantryItems.value)
|
await recipesStore.loadMore(pantryItems.value)
|
||||||
isLoadingMore.value = false
|
isLoadingMore.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1547,11 +1461,6 @@ details[open] .collapsible-summary::before {
|
||||||
padding: var(--spacing-xs) var(--spacing-md);
|
padding: var(--spacing-xs) var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spotlight-card {
|
|
||||||
border: 2px solid var(--color-primary);
|
|
||||||
background: linear-gradient(135deg, var(--color-bg-elevated) 0%, rgba(232, 168, 32, 0.06) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-section {
|
.results-section {
|
||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,6 @@
|
||||||
>{{ tag }}</span>
|
>{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Last cooked hint -->
|
|
||||||
<div v-if="lastCookedLabel(recipe.recipe_id)" class="last-cooked-hint text-xs text-muted mt-xs">
|
|
||||||
{{ lastCookedLabel(recipe.recipe_id) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notes preview with expand/collapse -->
|
<!-- Notes preview with expand/collapse -->
|
||||||
<div v-if="recipe.notes" class="mt-xs">
|
<div v-if="recipe.notes" class="mt-xs">
|
||||||
<div
|
<div
|
||||||
|
|
@ -151,7 +146,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
import { useRecipesStore } from '../stores/recipes'
|
|
||||||
import type { SavedRecipe } from '../services/api'
|
import type { SavedRecipe } from '../services/api'
|
||||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||||
|
|
||||||
|
|
@ -161,24 +155,7 @@ const emit = defineEmits<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const store = useSavedRecipesStore()
|
const store = useSavedRecipesStore()
|
||||||
const recipesStore = useRecipesStore()
|
|
||||||
const editingRecipe = ref<SavedRecipe | null>(null)
|
const editingRecipe = ref<SavedRecipe | null>(null)
|
||||||
|
|
||||||
function lastCookedLabel(recipeId: number): string | null {
|
|
||||||
const entries = recipesStore.cookLog.filter((e) => e.id === recipeId)
|
|
||||||
if (entries.length === 0) return null
|
|
||||||
const latestMs = Math.max(...entries.map((e) => e.cookedAt))
|
|
||||||
const diffMs = Date.now() - latestMs
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
||||||
if (diffDays === 0) return 'Last made: today'
|
|
||||||
if (diffDays === 1) return 'Last made: yesterday'
|
|
||||||
if (diffDays < 7) return `Last made: ${diffDays} days ago`
|
|
||||||
if (diffDays < 14) return 'Last made: 1 week ago'
|
|
||||||
const diffWeeks = Math.floor(diffDays / 7)
|
|
||||||
if (diffDays < 60) return `Last made: ${diffWeeks} weeks ago`
|
|
||||||
const diffMonths = Math.floor(diffDays / 30)
|
|
||||||
return `Last made: ${diffMonths} month${diffMonths !== 1 ? 's' : ''} ago`
|
|
||||||
}
|
|
||||||
const showNewCollection = ref(false)
|
const showNewCollection = ref(false)
|
||||||
|
|
||||||
// #44: two-step remove confirmation
|
// #44: two-step remove confirmation
|
||||||
|
|
@ -363,11 +340,6 @@ async function createCollection() {
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.last-cooked-hint {
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
|
||||||
|
|
@ -64,41 +64,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Units -->
|
|
||||||
<section class="mt-md">
|
|
||||||
<h3 class="text-lg font-semibold mb-xs">Units</h3>
|
|
||||||
<p class="text-sm text-secondary mb-sm">
|
|
||||||
Choose how quantities and temperatures are displayed in your pantry and recipes.
|
|
||||||
</p>
|
|
||||||
<div class="flex-start gap-sm mb-sm" role="group" aria-label="Unit system">
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-sm', settingsStore.unitSystem === 'metric' ? 'btn-primary' : 'btn-secondary']"
|
|
||||||
:aria-pressed="settingsStore.unitSystem === 'metric'"
|
|
||||||
@click="settingsStore.unitSystem = 'metric'"
|
|
||||||
>
|
|
||||||
Metric (g, ml, °C)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['btn', 'btn-sm', settingsStore.unitSystem === 'imperial' ? 'btn-primary' : 'btn-secondary']"
|
|
||||||
:aria-pressed="settingsStore.unitSystem === 'imperial'"
|
|
||||||
@click="settingsStore.unitSystem = 'imperial'"
|
|
||||||
>
|
|
||||||
Imperial (oz, cups, °F)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-start gap-sm">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
: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</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Display Preferences -->
|
<!-- Display Preferences -->
|
||||||
<section class="mt-md">
|
<section class="mt-md">
|
||||||
<h3 class="text-lg font-semibold mb-xs">Display</h3>
|
<h3 class="text-lg font-semibold mb-xs">Display</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
<template>
|
|
||||||
<li class="shopping-row" :class="{ 'shopping-row--checked': item.checked }">
|
|
||||||
<button class="check-btn" :aria-label="item.checked ? 'Uncheck' : 'Check'" @click="$emit('toggle')">
|
|
||||||
<span class="check-box" :class="{ 'check-box--checked': item.checked }">
|
|
||||||
{{ item.checked ? '✓' : '' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="row-body">
|
|
||||||
<span class="row-name">{{ item.name }}</span>
|
|
||||||
<span v-if="item.quantity || item.unit" class="row-qty">
|
|
||||||
{{ item.quantity ? item.quantity : '' }}{{ item.unit ? ' ' + item.unit : '' }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Affiliate links -->
|
|
||||||
<div v-if="!item.checked && item.grocery_links.length > 0" class="grocery-links">
|
|
||||||
<a
|
|
||||||
v-for="link in item.grocery_links"
|
|
||||||
:key="link.retailer"
|
|
||||||
:href="link.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="grocery-link"
|
|
||||||
:title="'Buy on ' + link.retailer"
|
|
||||||
>
|
|
||||||
{{ retailerIcon(link.retailer) }} {{ link.retailer }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row-actions">
|
|
||||||
<button
|
|
||||||
v-if="item.checked"
|
|
||||||
class="btn btn-success btn-xs"
|
|
||||||
title="Confirm purchase → add to pantry"
|
|
||||||
@click="$emit('confirm')"
|
|
||||||
>
|
|
||||||
+ Pantry
|
|
||||||
</button>
|
|
||||||
<button class="btn-icon" aria-label="Remove" @click="$emit('remove')">✕</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ShoppingItem } from '@/services/api'
|
|
||||||
|
|
||||||
defineProps<{ item: ShoppingItem }>()
|
|
||||||
defineEmits<{
|
|
||||||
toggle: []
|
|
||||||
remove: []
|
|
||||||
confirm: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function retailerIcon(retailer: string): string {
|
|
||||||
if (retailer.toLowerCase().includes('amazon')) return '📦'
|
|
||||||
if (retailer.toLowerCase().includes('instacart')) return '🛒'
|
|
||||||
if (retailer.toLowerCase().includes('walmart')) return '🏪'
|
|
||||||
return '🔗'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.shopping-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-sm);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-row:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-row--checked .row-name {
|
|
||||||
text-decoration: line-through;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-box {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 2px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
transition: background 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-box--checked {
|
|
||||||
background: var(--color-success);
|
|
||||||
border-color: var(--color-success);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-name {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-qty {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grocery-links {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grocery-link {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grocery-link:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-xs {
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
line-height: 1;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,359 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="shopping-view">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="shopping-header">
|
|
||||||
<div class="shopping-title-row">
|
|
||||||
<h2 class="shopping-title">Shopping List</h2>
|
|
||||||
<span v-if="store.totalCount > 0" class="shopping-count badge">
|
|
||||||
{{ store.checkedCount }}/{{ store.totalCount }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="shopping-actions">
|
|
||||||
<button class="btn btn-secondary btn-sm" @click="showAddForm = !showAddForm">
|
|
||||||
+ Add item
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="store.checkedCount > 0"
|
|
||||||
class="btn btn-secondary btn-sm"
|
|
||||||
@click="handleClearChecked"
|
|
||||||
>
|
|
||||||
Clear checked ({{ store.checkedCount }})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add item form -->
|
|
||||||
<div v-if="showAddForm" class="card card-sm add-form">
|
|
||||||
<div class="add-form-fields">
|
|
||||||
<input
|
|
||||||
v-model="newItem.name"
|
|
||||||
class="input"
|
|
||||||
placeholder="Item name"
|
|
||||||
@keyup.enter="handleAdd"
|
|
||||||
ref="nameInput"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="newItem.quantity"
|
|
||||||
class="input input-sm"
|
|
||||||
type="number"
|
|
||||||
placeholder="Qty"
|
|
||||||
min="0"
|
|
||||||
step="0.1"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="newItem.unit"
|
|
||||||
class="input input-sm"
|
|
||||||
placeholder="Unit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="add-form-footer">
|
|
||||||
<button class="btn btn-primary btn-sm" :disabled="!newItem.name.trim()" @click="handleAdd">
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary btn-sm" @click="showAddForm = false">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<div v-if="store.loading" class="shopping-empty">Loading…</div>
|
|
||||||
|
|
||||||
<!-- Error -->
|
|
||||||
<div v-else-if="store.error" class="card card-error shopping-error">
|
|
||||||
{{ store.error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div v-else-if="store.totalCount === 0" class="shopping-empty">
|
|
||||||
<div class="empty-icon">🛒</div>
|
|
||||||
<p class="empty-title">Your list is empty</p>
|
|
||||||
<p class="empty-hint">Add items manually or use "Add to list" from any recipe.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Items -->
|
|
||||||
<div v-else class="shopping-sections">
|
|
||||||
<!-- Unchecked -->
|
|
||||||
<ul v-if="store.uncheckedItems.length > 0" class="shopping-list">
|
|
||||||
<ShoppingItemRow
|
|
||||||
v-for="item in store.uncheckedItems"
|
|
||||||
:key="item.id"
|
|
||||||
:item="item"
|
|
||||||
@toggle="store.toggleChecked(item.id)"
|
|
||||||
@remove="store.removeItem(item.id)"
|
|
||||||
@confirm="openConfirmModal(item)"
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- Checked / in-cart -->
|
|
||||||
<div v-if="store.checkedItems.length > 0" class="checked-section">
|
|
||||||
<button class="checked-toggle" @click="showChecked = !showChecked">
|
|
||||||
{{ showChecked ? '▾' : '▸' }} In cart ({{ store.checkedCount }})
|
|
||||||
</button>
|
|
||||||
<ul v-if="showChecked" class="shopping-list shopping-list--checked">
|
|
||||||
<ShoppingItemRow
|
|
||||||
v-for="item in store.checkedItems"
|
|
||||||
:key="item.id"
|
|
||||||
:item="item"
|
|
||||||
@toggle="store.toggleChecked(item.id)"
|
|
||||||
@remove="store.removeItem(item.id)"
|
|
||||||
@confirm="openConfirmModal(item)"
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirm purchase modal -->
|
|
||||||
<div v-if="confirmItem" class="modal-backdrop" @click.self="confirmItem = null">
|
|
||||||
<div class="modal card">
|
|
||||||
<h3 class="modal-title">Confirm purchase</h3>
|
|
||||||
<p class="modal-body">
|
|
||||||
Add <strong>{{ confirmItem.name }}</strong> to your pantry?
|
|
||||||
</p>
|
|
||||||
<div class="modal-fields">
|
|
||||||
<label class="field-label">Location</label>
|
|
||||||
<select v-model="confirmLocation" class="input">
|
|
||||||
<option value="pantry">Pantry</option>
|
|
||||||
<option value="fridge">Fridge</option>
|
|
||||||
<option value="freezer">Freezer</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-primary" @click="handleConfirmPurchase">
|
|
||||||
Add to pantry
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" @click="confirmItem = null">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
|
||||||
import { useShoppingStore } from '@/stores/shopping'
|
|
||||||
import type { ShoppingItem } from '@/services/api'
|
|
||||||
import ShoppingItemRow from './ShoppingItemRow.vue'
|
|
||||||
|
|
||||||
const store = useShoppingStore()
|
|
||||||
|
|
||||||
const showAddForm = ref(false)
|
|
||||||
const showChecked = ref(true)
|
|
||||||
const nameInput = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
const newItem = ref({ name: '', quantity: undefined as number | undefined, unit: '' })
|
|
||||||
|
|
||||||
const confirmItem = ref<ShoppingItem | null>(null)
|
|
||||||
const confirmLocation = ref('pantry')
|
|
||||||
|
|
||||||
onMounted(() => store.fetchItems())
|
|
||||||
|
|
||||||
async function handleAdd() {
|
|
||||||
if (!newItem.value.name.trim()) return
|
|
||||||
await store.addItem({
|
|
||||||
name: newItem.value.name.trim(),
|
|
||||||
quantity: newItem.value.quantity || undefined,
|
|
||||||
unit: newItem.value.unit.trim() || undefined,
|
|
||||||
})
|
|
||||||
newItem.value = { name: '', quantity: undefined, unit: '' }
|
|
||||||
await nextTick()
|
|
||||||
nameInput.value?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleClearChecked() {
|
|
||||||
if (!confirm(`Remove ${store.checkedCount} checked items?`)) return
|
|
||||||
await store.clearChecked()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openConfirmModal(item: ShoppingItem) {
|
|
||||||
confirmItem.value = item
|
|
||||||
confirmLocation.value = 'pantry'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConfirmPurchase() {
|
|
||||||
if (!confirmItem.value) return
|
|
||||||
await store.confirmPurchase(confirmItem.value.id, confirmLocation.value)
|
|
||||||
confirmItem.value = null
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.shopping-view {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
max-width: 680px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-count {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #1e1c1a;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 99px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-form {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-form-fields {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-form-fields .input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-sm {
|
|
||||||
max-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-form-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--spacing-xl) var(--spacing-md);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 var(--spacing-xs);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-hint {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-error {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 0 var(--spacing-md);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checked-section {
|
|
||||||
margin-top: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checked-toggle {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: var(--spacing-xs) 0;
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopping-list--checked {
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirm modal */
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 100;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
margin: 0 0 var(--spacing-sm);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
margin: 0 0 var(--spacing-md);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-fields {
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.shopping-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -91,36 +91,13 @@ export interface InventoryItem {
|
||||||
sublocation: string | null
|
sublocation: string | null
|
||||||
purchase_date: string | null
|
purchase_date: string | null
|
||||||
expiration_date: string | null
|
expiration_date: string | null
|
||||||
opened_date: string | null
|
|
||||||
opened_expiry_date: string | null
|
|
||||||
secondary_state: string | null
|
|
||||||
secondary_uses: string[] | null
|
|
||||||
secondary_warning: string | null
|
|
||||||
status: string
|
status: string
|
||||||
source: string
|
source: string
|
||||||
notes: string | null
|
notes: string | null
|
||||||
disposal_reason: string | null
|
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BarcodeScanResult {
|
|
||||||
barcode: string
|
|
||||||
barcode_type: string
|
|
||||||
product: Product | null
|
|
||||||
inventory_item: InventoryItem | null
|
|
||||||
added_to_inventory: boolean
|
|
||||||
needs_manual_entry: boolean
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BarcodeScanResponse {
|
|
||||||
success: boolean
|
|
||||||
barcodes_found: number
|
|
||||||
results: BarcodeScanResult[]
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InventoryItemUpdate {
|
export interface InventoryItemUpdate {
|
||||||
quantity?: number
|
quantity?: number
|
||||||
unit?: string
|
unit?: string
|
||||||
|
|
@ -190,7 +167,7 @@ export const inventoryAPI = {
|
||||||
*/
|
*/
|
||||||
async listItems(params?: {
|
async listItems(params?: {
|
||||||
location?: string
|
location?: string
|
||||||
item_status?: string
|
status?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
offset?: number
|
offset?: number
|
||||||
}): Promise<InventoryItem[]> {
|
}): Promise<InventoryItem[]> {
|
||||||
|
|
@ -245,7 +222,7 @@ export const inventoryAPI = {
|
||||||
location: string = 'pantry',
|
location: string = 'pantry',
|
||||||
quantity: number = 1.0,
|
quantity: number = 1.0,
|
||||||
autoAdd: boolean = true
|
autoAdd: boolean = true
|
||||||
): Promise<BarcodeScanResponse> {
|
): Promise<any> {
|
||||||
const response = await api.post('/inventory/scan/text', {
|
const response = await api.post('/inventory/scan/text', {
|
||||||
barcode,
|
barcode,
|
||||||
location,
|
location,
|
||||||
|
|
@ -256,29 +233,10 @@ export const inventoryAPI = {
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark item as consumed fully or partially.
|
* Mark item as consumed
|
||||||
* Pass quantity to decrement; omit to consume all.
|
|
||||||
*/
|
*/
|
||||||
async consumeItem(itemId: number, quantity?: number): Promise<InventoryItem> {
|
async consumeItem(itemId: number): Promise<void> {
|
||||||
const body = quantity !== undefined ? { quantity } : undefined
|
await api.post(`/inventory/items/${itemId}/consume`)
|
||||||
const response = await api.post(`/inventory/items/${itemId}/consume`, body)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark item as discarded (not used, spoiled, etc).
|
|
||||||
*/
|
|
||||||
async discardItem(itemId: number, reason?: string): Promise<InventoryItem> {
|
|
||||||
const response = await api.post(`/inventory/items/${itemId}/discard`, { reason: reason ?? null })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark item as opened today — starts secondary shelf-life tracking
|
|
||||||
*/
|
|
||||||
async openItem(itemId: number): Promise<InventoryItem> {
|
|
||||||
const response = await api.post(`/inventory/items/${itemId}/open`)
|
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -498,8 +456,6 @@ export interface RecipeSuggestion {
|
||||||
is_wildcard: boolean
|
is_wildcard: boolean
|
||||||
nutrition: NutritionPanel | null
|
nutrition: NutritionPanel | null
|
||||||
source_url: string | null
|
source_url: string | null
|
||||||
complexity: 'easy' | 'moderate' | 'involved' | null
|
|
||||||
estimated_time_min: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NutritionFilters {
|
export interface NutritionFilters {
|
||||||
|
|
@ -524,18 +480,8 @@ export interface RecipeResult {
|
||||||
rate_limit_count: number
|
rate_limit_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RecipeJobStatusValue = 'queued' | 'running' | 'done' | 'failed'
|
|
||||||
|
|
||||||
export interface RecipeJobStatus {
|
|
||||||
job_id: string
|
|
||||||
status: RecipeJobStatusValue
|
|
||||||
result: RecipeResult | null
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeRequest {
|
export interface RecipeRequest {
|
||||||
pantry_items: string[]
|
pantry_items: string[]
|
||||||
secondary_pantry_items: Record<string, string>
|
|
||||||
level: number
|
level: number
|
||||||
constraints: string[]
|
constraints: string[]
|
||||||
allergies: string[]
|
allergies: string[]
|
||||||
|
|
@ -548,9 +494,6 @@ export interface RecipeRequest {
|
||||||
nutrition_filters: NutritionFilters
|
nutrition_filters: NutritionFilters
|
||||||
excluded_ids: number[]
|
excluded_ids: number[]
|
||||||
shopping_mode: boolean
|
shopping_mode: boolean
|
||||||
pantry_match_only: boolean
|
|
||||||
complexity_filter: string | null
|
|
||||||
max_time_min: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Staple {
|
export interface Staple {
|
||||||
|
|
@ -598,20 +541,7 @@ export interface BuildRequest {
|
||||||
|
|
||||||
export const recipesAPI = {
|
export const recipesAPI = {
|
||||||
async suggest(req: RecipeRequest): Promise<RecipeResult> {
|
async suggest(req: RecipeRequest): Promise<RecipeResult> {
|
||||||
// Allow up to 120s — cf-orch model cold-start can take 60+ seconds on first request
|
const response = await api.post('/recipes/suggest', req)
|
||||||
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Submit an async job for L3/L4 generation. Returns job_id + initial status. */
|
|
||||||
async suggestAsync(req: RecipeRequest): Promise<{ job_id: string; status: string }> {
|
|
||||||
const response = await api.post('/recipes/suggest', req, { params: { async: 'true' }, timeout: 15000 })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Poll an async job. Returns the full status including result once done. */
|
|
||||||
async pollJob(jobId: string): Promise<RecipeJobStatus> {
|
|
||||||
const response = await api.get(`/recipes/jobs/${jobId}`, { timeout: 10000 })
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
||||||
|
|
@ -852,11 +782,6 @@ export const mealPlanAPI = {
|
||||||
return resp.data
|
return resp.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateMealTypes(planId: number, mealTypes: string[]): Promise<MealPlan> {
|
|
||||||
const resp = await api.patch<MealPlan>(`/meal-plans/${planId}`, { meal_types: mealTypes })
|
|
||||||
return resp.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise<MealPlanSlot> {
|
async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise<MealPlanSlot> {
|
||||||
const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
|
const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
|
||||||
return resp.data
|
return resp.data
|
||||||
|
|
@ -902,12 +827,6 @@ export interface BrowserDomain {
|
||||||
export interface BrowserCategory {
|
export interface BrowserCategory {
|
||||||
category: string
|
category: string
|
||||||
recipe_count: number
|
recipe_count: number
|
||||||
has_subcategories: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrowserSubcategory {
|
|
||||||
subcategory: string
|
|
||||||
recipe_count: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserRecipe {
|
export interface BrowserRecipe {
|
||||||
|
|
@ -934,96 +853,16 @@ export const browserAPI = {
|
||||||
const response = await api.get(`/recipes/browse/${domain}`)
|
const response = await api.get(`/recipes/browse/${domain}`)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
async listSubcategories(domain: string, category: string): Promise<BrowserSubcategory[]> {
|
|
||||||
const response = await api.get(
|
|
||||||
`/recipes/browse/${domain}/${encodeURIComponent(category)}/subcategories`
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
async browse(domain: string, category: string, params?: {
|
async browse(domain: string, category: string, params?: {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
pantry_items?: string
|
pantry_items?: string
|
||||||
subcategory?: string
|
|
||||||
q?: string
|
|
||||||
sort?: string
|
|
||||||
}): Promise<BrowserResult> {
|
}): Promise<BrowserResult> {
|
||||||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shopping List ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface GroceryLink {
|
|
||||||
ingredient: string
|
|
||||||
retailer: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShoppingItem {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
quantity: number | null
|
|
||||||
unit: string | null
|
|
||||||
category: string | null
|
|
||||||
checked: boolean
|
|
||||||
notes: string | null
|
|
||||||
source: string
|
|
||||||
recipe_id: number | null
|
|
||||||
sort_order: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
grocery_links: GroceryLink[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShoppingItemCreate {
|
|
||||||
name: string
|
|
||||||
quantity?: number
|
|
||||||
unit?: string
|
|
||||||
category?: string
|
|
||||||
notes?: string
|
|
||||||
source?: string
|
|
||||||
recipe_id?: number
|
|
||||||
sort_order?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShoppingItemUpdate {
|
|
||||||
name?: string
|
|
||||||
quantity?: number
|
|
||||||
unit?: string
|
|
||||||
category?: string
|
|
||||||
checked?: boolean
|
|
||||||
notes?: string
|
|
||||||
sort_order?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const shoppingAPI = {
|
|
||||||
list: (includeChecked = true) =>
|
|
||||||
api.get<ShoppingItem[]>('/shopping', { params: { include_checked: includeChecked } }).then(r => r.data),
|
|
||||||
|
|
||||||
add: (item: ShoppingItemCreate) =>
|
|
||||||
api.post<ShoppingItem>('/shopping', item).then(r => r.data),
|
|
||||||
|
|
||||||
addFromRecipe: (recipeId: number, includeCovered = false) =>
|
|
||||||
api.post<ShoppingItem[]>('/shopping/from-recipe', { recipe_id: recipeId, include_covered: includeCovered }).then(r => r.data),
|
|
||||||
|
|
||||||
update: (id: number, update: ShoppingItemUpdate) =>
|
|
||||||
api.patch<ShoppingItem>(`/shopping/${id}`, update).then(r => r.data),
|
|
||||||
|
|
||||||
remove: (id: number) =>
|
|
||||||
api.delete(`/shopping/${id}`),
|
|
||||||
|
|
||||||
clearChecked: () =>
|
|
||||||
api.delete('/shopping/checked'),
|
|
||||||
|
|
||||||
clearAll: () =>
|
|
||||||
api.delete('/shopping/all'),
|
|
||||||
|
|
||||||
confirmPurchase: (id: number, location = 'pantry', quantity?: number, unit?: string) =>
|
|
||||||
api.post(`/shopping/${id}/confirm`, { location, quantity, unit }).then(r => r.data),
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Orch Usage ────────────────────────────────────────────────────────────────
|
// ── Orch Usage ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getOrchUsage(): Promise<OrchUsage | null> {
|
export async function getOrchUsage(): Promise<OrchUsage | null> {
|
||||||
|
|
@ -1031,22 +870,4 @@ export async function getOrchUsage(): Promise<OrchUsage | null> {
|
||||||
return resp.data
|
return resp.data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session Bootstrap ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface SessionInfo {
|
|
||||||
auth: 'local' | 'anon' | 'authed'
|
|
||||||
tier: string
|
|
||||||
has_byok: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Call once on app load. Logs auth= + tier= server-side for analytics. */
|
|
||||||
export async function bootstrapSession(): Promise<SessionInfo | null> {
|
|
||||||
try {
|
|
||||||
const resp = await api.get<SessionInfo>('/session/bootstrap')
|
|
||||||
return resp.data
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,11 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await inventoryAPI.listItems({
|
items.value = await inventoryAPI.listItems({
|
||||||
item_status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||||
location: locationFilter.value === 'all' ? undefined : locationFilter.value,
|
location: locationFilter.value === 'all' ? undefined : locationFilter.value,
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
})
|
})
|
||||||
items.value = Array.isArray(result) ? result : []
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || 'Failed to fetch inventory items'
|
error.value = err.response?.data?.detail || 'Failed to fetch inventory items'
|
||||||
console.error('Error fetching inventory:', err)
|
console.error('Error fetching inventory:', err)
|
||||||
|
|
|
||||||
|
|
@ -34,27 +34,12 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
||||||
async function loadPlans() {
|
async function loadPlans() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await mealPlanAPI.list()
|
plans.value = await mealPlanAPI.list()
|
||||||
plans.value = Array.isArray(result) ? result : []
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-select the best available plan without a round-trip.
|
|
||||||
* Prefers the plan whose week_start matches preferredWeekStart (current week's Monday).
|
|
||||||
* Falls back to the first plan in the list (most recent, since list is DESC).
|
|
||||||
* No-ops if a plan is already active or no plans exist.
|
|
||||||
*/
|
|
||||||
function autoSelectPlan(preferredWeekStart?: string) {
|
|
||||||
if (activePlan.value || plans.value.length === 0) return
|
|
||||||
const match = preferredWeekStart
|
|
||||||
? (plans.value.find(p => p.week_start === preferredWeekStart) ?? plans.value[0])
|
|
||||||
: plans.value[0]
|
|
||||||
if (match) activePlan.value = match ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createPlan(weekStart: string, mealTypes: string[]): Promise<MealPlan> {
|
async function createPlan(weekStart: string, mealTypes: string[]): Promise<MealPlan> {
|
||||||
const plan = await mealPlanAPI.create(weekStart, mealTypes)
|
const plan = await mealPlanAPI.create(weekStart, mealTypes)
|
||||||
plans.value = [plan, ...plans.value]
|
plans.value = [plan, ...plans.value]
|
||||||
|
|
@ -125,28 +110,6 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addMealType(mealType: string): Promise<void> {
|
|
||||||
if (!activePlan.value) return
|
|
||||||
const current = activePlan.value.meal_types
|
|
||||||
if (current.includes(mealType)) return
|
|
||||||
const updated = await mealPlanAPI.updateMealTypes(activePlan.value.id, [...current, mealType])
|
|
||||||
activePlan.value = updated
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeMealType(mealType: string): Promise<void> {
|
|
||||||
if (!activePlan.value) return
|
|
||||||
const next = activePlan.value.meal_types.filter(t => t !== mealType)
|
|
||||||
if (next.length === 0) return // always keep at least one
|
|
||||||
const updated = await mealPlanAPI.updateMealTypes(activePlan.value.id, next)
|
|
||||||
activePlan.value = updated
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reorderMealTypes(newOrder: string[]): Promise<void> {
|
|
||||||
if (!activePlan.value) return
|
|
||||||
const updated = await mealPlanAPI.updateMealTypes(activePlan.value.id, newOrder)
|
|
||||||
activePlan.value = updated
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updatePrepTask(taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<void> {
|
async function updatePrepTask(taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<void> {
|
||||||
if (!activePlan.value || !prepSession.value) return
|
if (!activePlan.value || !prepSession.value) return
|
||||||
const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data)
|
const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data)
|
||||||
|
|
@ -166,8 +129,7 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
||||||
return {
|
return {
|
||||||
plans, activePlan, shoppingList, prepSession,
|
plans, activePlan, shoppingList, prepSession,
|
||||||
loading, shoppingListLoading, prepLoading, slots,
|
loading, shoppingListLoading, prepLoading, slots,
|
||||||
getSlot, loadPlans, autoSelectPlan, createPlan, setActivePlan,
|
getSlot, loadPlans, createPlan, setActivePlan,
|
||||||
addMealType, removeMealType, reorderMealTypes,
|
|
||||||
upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
|
upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type RecipeJobStatusValue, type NutritionFilters } from '../services/api'
|
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type NutritionFilters } from '../services/api'
|
||||||
|
|
||||||
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
|
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
|
||||||
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
@ -121,7 +121,6 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
const result = ref<RecipeResult | null>(null)
|
const result = ref<RecipeResult | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const jobStatus = ref<RecipeJobStatusValue | null>(null)
|
|
||||||
|
|
||||||
// Request parameters
|
// Request parameters
|
||||||
const level = ref(1)
|
const level = ref(1)
|
||||||
|
|
@ -133,9 +132,6 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
const category = ref<string | null>(null)
|
const category = ref<string | null>(null)
|
||||||
const wildcardConfirmed = ref(false)
|
const wildcardConfirmed = ref(false)
|
||||||
const shoppingMode = ref(false)
|
const shoppingMode = ref(false)
|
||||||
const pantryMatchOnly = ref(false)
|
|
||||||
const complexityFilter = ref<string | null>(null)
|
|
||||||
const maxTimeMin = ref<number | null>(null)
|
|
||||||
const nutritionFilters = ref<NutritionFilters>({
|
const nutritionFilters = ref<NutritionFilters>({
|
||||||
max_calories: null,
|
max_calories: null,
|
||||||
max_sugar_g: null,
|
max_sugar_g: null,
|
||||||
|
|
@ -164,15 +160,10 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
|
|
||||||
const dismissedCount = computed(() => dismissedIds.value.size)
|
const dismissedCount = computed(() => dismissedIds.value.size)
|
||||||
|
|
||||||
function _buildRequest(
|
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest {
|
||||||
pantryItems: string[],
|
|
||||||
secondaryPantryItems: Record<string, string> = {},
|
|
||||||
extraExcluded: number[] = [],
|
|
||||||
): RecipeRequest {
|
|
||||||
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
|
const excluded = new Set([...dismissedIds.value, ...extraExcluded])
|
||||||
return {
|
return {
|
||||||
pantry_items: pantryItems,
|
pantry_items: pantryItems,
|
||||||
secondary_pantry_items: secondaryPantryItems,
|
|
||||||
level: level.value,
|
level: level.value,
|
||||||
constraints: constraints.value,
|
constraints: constraints.value,
|
||||||
allergies: allergies.value,
|
allergies: allergies.value,
|
||||||
|
|
@ -185,9 +176,6 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
nutrition_filters: nutritionFilters.value,
|
nutrition_filters: nutritionFilters.value,
|
||||||
excluded_ids: [...excluded],
|
excluded_ids: [...excluded],
|
||||||
shopping_mode: shoppingMode.value,
|
shopping_mode: shoppingMode.value,
|
||||||
pantry_match_only: pantryMatchOnly.value,
|
|
||||||
complexity_filter: complexityFilter.value,
|
|
||||||
max_time_min: maxTimeMin.value,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,68 +185,29 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function suggest(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
|
async function suggest(pantryItems: string[]) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
jobStatus.value = null
|
|
||||||
seenIds.value = new Set()
|
seenIds.value = new Set()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (level.value >= 3) {
|
result.value = await recipesAPI.suggest(_buildRequest(pantryItems))
|
||||||
await _suggestAsync(pantryItems, secondaryPantryItems)
|
_trackSeen(result.value.suggestions)
|
||||||
} else {
|
|
||||||
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
|
|
||||||
_trackSeen(result.value.suggestions)
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
jobStatus.value = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _suggestAsync(pantryItems: string[], secondaryPantryItems: Record<string, string>) {
|
async function loadMore(pantryItems: string[]) {
|
||||||
const queued = await recipesAPI.suggestAsync(_buildRequest(pantryItems, secondaryPantryItems))
|
|
||||||
|
|
||||||
// CLOUD_MODE or future sync fallback: server returned result directly (status 200)
|
|
||||||
if ('suggestions' in queued) {
|
|
||||||
result.value = queued as unknown as RecipeResult
|
|
||||||
_trackSeen(result.value.suggestions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jobStatus.value = 'queued'
|
|
||||||
const { job_id } = queued
|
|
||||||
const deadline = Date.now() + 90_000
|
|
||||||
const POLL_MS = 2_500
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, POLL_MS))
|
|
||||||
const poll = await recipesAPI.pollJob(job_id)
|
|
||||||
jobStatus.value = poll.status
|
|
||||||
|
|
||||||
if (poll.status === 'done') {
|
|
||||||
result.value = poll.result
|
|
||||||
if (result.value) _trackSeen(result.value.suggestions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (poll.status === 'failed') {
|
|
||||||
throw new Error(poll.error ?? 'Recipe generation failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Recipe generation timed out — the model may be busy. Try again.')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
|
|
||||||
if (!result.value || loading.value) return
|
if (!result.value || loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Exclude everything already shown (dismissed + all seen this session)
|
// Exclude everything already shown (dismissed + all seen this session)
|
||||||
const more = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems, [...seenIds.value]))
|
const more = await recipesAPI.suggest(_buildRequest(pantryItems, [...seenIds.value]))
|
||||||
if (more.suggestions.length === 0) {
|
if (more.suggestions.length === 0) {
|
||||||
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
|
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -348,7 +297,6 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
result,
|
result,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
jobStatus,
|
|
||||||
level,
|
level,
|
||||||
constraints,
|
constraints,
|
||||||
allergies,
|
allergies,
|
||||||
|
|
@ -358,9 +306,6 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
category,
|
category,
|
||||||
wildcardConfirmed,
|
wildcardConfirmed,
|
||||||
shoppingMode,
|
shoppingMode,
|
||||||
pantryMatchOnly,
|
|
||||||
complexityFilter,
|
|
||||||
maxTimeMin,
|
|
||||||
nutritionFilters,
|
nutritionFilters,
|
||||||
dismissedIds,
|
dismissedIds,
|
||||||
dismissedCount,
|
dismissedCount,
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,10 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { settingsAPI } from '../services/api'
|
import { settingsAPI } from '../services/api'
|
||||||
import type { UnitSystem } from '../utils/units'
|
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
// State
|
// State
|
||||||
const cookingEquipment = ref<string[]>([])
|
const cookingEquipment = ref<string[]>([])
|
||||||
const unitSystem = ref<UnitSystem>('metric')
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saved = ref(false)
|
const saved = ref(false)
|
||||||
|
|
||||||
|
|
@ -20,15 +18,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [rawEquipment, rawUnits] = await Promise.allSettled([
|
const raw = await settingsAPI.getSetting('cooking_equipment')
|
||||||
settingsAPI.getSetting('cooking_equipment'),
|
if (raw) {
|
||||||
settingsAPI.getSetting('unit_system'),
|
cookingEquipment.value = JSON.parse(raw)
|
||||||
])
|
|
||||||
if (rawEquipment.status === 'fulfilled' && rawEquipment.value) {
|
|
||||||
cookingEquipment.value = JSON.parse(rawEquipment.value)
|
|
||||||
}
|
|
||||||
if (rawUnits.status === 'fulfilled' && rawUnits.value) {
|
|
||||||
unitSystem.value = rawUnits.value as UnitSystem
|
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to load settings:', err)
|
console.error('Failed to load settings:', err)
|
||||||
|
|
@ -40,10 +32,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
async function save() {
|
async function save() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value))
|
||||||
settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)),
|
|
||||||
settingsAPI.setSetting('unit_system', unitSystem.value),
|
|
||||||
])
|
|
||||||
saved.value = true
|
saved.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
saved.value = false
|
saved.value = false
|
||||||
|
|
@ -58,7 +47,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
cookingEquipment,
|
cookingEquipment,
|
||||||
unitSystem,
|
|
||||||
loading,
|
loading,
|
||||||
saved,
|
saved,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { shoppingAPI, type ShoppingItem, type ShoppingItemCreate, type ShoppingItemUpdate } from '@/services/api'
|
|
||||||
|
|
||||||
export const useShoppingStore = defineStore('shopping', () => {
|
|
||||||
const items = ref<ShoppingItem[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
|
|
||||||
const uncheckedItems = computed(() => items.value.filter(i => !i.checked))
|
|
||||||
const checkedItems = computed(() => items.value.filter(i => i.checked))
|
|
||||||
const totalCount = computed(() => items.value.length)
|
|
||||||
const checkedCount = computed(() => checkedItems.value.length)
|
|
||||||
|
|
||||||
async function fetchItems(includeChecked = true) {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
items.value = await shoppingAPI.list(includeChecked)
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : 'Failed to load shopping list'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addItem(item: ShoppingItemCreate) {
|
|
||||||
const created = await shoppingAPI.add(item)
|
|
||||||
items.value = [...items.value, created]
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addFromRecipe(recipeId: number, includeCovered = false) {
|
|
||||||
const added = await shoppingAPI.addFromRecipe(recipeId, includeCovered)
|
|
||||||
items.value = [...items.value, ...added]
|
|
||||||
return added
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleChecked(id: number) {
|
|
||||||
const item = items.value.find(i => i.id === id)
|
|
||||||
if (!item) return
|
|
||||||
const updated = await shoppingAPI.update(id, { checked: !item.checked })
|
|
||||||
items.value = items.value.map(i => i.id === id ? updated : i)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateItem(id: number, update: ShoppingItemUpdate) {
|
|
||||||
const updated = await shoppingAPI.update(id, update)
|
|
||||||
items.value = items.value.map(i => i.id === id ? updated : i)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeItem(id: number) {
|
|
||||||
await shoppingAPI.remove(id)
|
|
||||||
items.value = items.value.filter(i => i.id !== id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearChecked() {
|
|
||||||
await shoppingAPI.clearChecked()
|
|
||||||
items.value = items.value.filter(i => !i.checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearAll() {
|
|
||||||
await shoppingAPI.clearAll()
|
|
||||||
items.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmPurchase(id: number, location = 'pantry', quantity?: number, unit?: string) {
|
|
||||||
const inventoryItem = await shoppingAPI.confirmPurchase(id, location, quantity, unit)
|
|
||||||
// Mark checked in local state (server also marks it)
|
|
||||||
items.value = items.value.map(i => i.id === id ? { ...i, checked: true } : i)
|
|
||||||
return inventoryItem
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
items, loading, error,
|
|
||||||
uncheckedItems, checkedItems, totalCount, checkedCount,
|
|
||||||
fetchItems, addItem, addFromRecipe,
|
|
||||||
toggleChecked, updateItem, removeItem,
|
|
||||||
clearChecked, clearAll, confirmPurchase,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -111,7 +111,6 @@
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.align-center { align-items: center; }
|
|
||||||
|
|
||||||
.flex-responsive {
|
.flex-responsive {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -650,31 +649,6 @@
|
||||||
border: 1px solid var(--color-info-border);
|
border: 1px solid var(--color-info-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-neutral {
|
|
||||||
background: rgba(255, 248, 235, 0.06);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Recipe complexity badges */
|
|
||||||
.complexity-easy {
|
|
||||||
background: var(--color-success-bg);
|
|
||||||
color: var(--color-success-light);
|
|
||||||
border: 1px solid var(--color-success-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.complexity-moderate {
|
|
||||||
background: var(--color-warning-bg);
|
|
||||||
color: var(--color-warning-light);
|
|
||||||
border: 1px solid var(--color-warning-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.complexity-involved {
|
|
||||||
background: var(--color-error-bg);
|
|
||||||
color: var(--color-error-light);
|
|
||||||
border: 1px solid var(--color-error-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
ANIMATION UTILITIES
|
ANIMATION UTILITIES
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
/**
|
|
||||||
* Unit conversion utilities — mirrors app/utils/units.py.
|
|
||||||
* Source of truth: metric (g, ml). Display conversion happens here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type UnitSystem = 'metric' | 'imperial'
|
|
||||||
|
|
||||||
// ── Conversion thresholds ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const IMPERIAL_MASS: [number, string, number][] = [
|
|
||||||
[453.592, 'lb', 453.592],
|
|
||||||
[0, 'oz', 28.3495],
|
|
||||||
]
|
|
||||||
|
|
||||||
const METRIC_MASS: [number, string, number][] = [
|
|
||||||
[1000, 'kg', 1000],
|
|
||||||
[0, 'g', 1],
|
|
||||||
]
|
|
||||||
|
|
||||||
const IMPERIAL_VOLUME: [number, string, number][] = [
|
|
||||||
[3785.41, 'gal', 3785.41],
|
|
||||||
[946.353, 'qt', 946.353],
|
|
||||||
[473.176, 'pt', 473.176],
|
|
||||||
[236.588, 'cup', 236.588],
|
|
||||||
[0, 'fl oz', 29.5735],
|
|
||||||
]
|
|
||||||
|
|
||||||
const METRIC_VOLUME: [number, string, number][] = [
|
|
||||||
[1000, 'l', 1000],
|
|
||||||
[0, 'ml', 1],
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a stored metric quantity to a display quantity + unit.
|
|
||||||
* baseUnit must be 'g', 'ml', or 'each'.
|
|
||||||
*/
|
|
||||||
export function convertFromMetric(
|
|
||||||
quantity: number,
|
|
||||||
baseUnit: string,
|
|
||||||
preferred: UnitSystem = 'metric',
|
|
||||||
): [number, string] {
|
|
||||||
if (baseUnit === 'each') return [quantity, 'each']
|
|
||||||
|
|
||||||
const thresholds =
|
|
||||||
baseUnit === 'g'
|
|
||||||
? preferred === 'imperial' ? IMPERIAL_MASS : METRIC_MASS
|
|
||||||
: baseUnit === 'ml'
|
|
||||||
? preferred === 'imperial' ? IMPERIAL_VOLUME : METRIC_VOLUME
|
|
||||||
: null
|
|
||||||
|
|
||||||
if (!thresholds) return [Math.round(quantity * 100) / 100, baseUnit]
|
|
||||||
|
|
||||||
for (const [min, unit, factor] of thresholds) {
|
|
||||||
if (quantity >= min) {
|
|
||||||
return [Math.round((quantity / factor) * 100) / 100, unit]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [Math.round(quantity * 100) / 100, baseUnit]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format a quantity + unit for display, e.g. "1.5 kg" or "3.2 oz". */
|
|
||||||
export function formatQuantity(
|
|
||||||
quantity: number,
|
|
||||||
baseUnit: string,
|
|
||||||
preferred: UnitSystem = 'metric',
|
|
||||||
): string {
|
|
||||||
const [qty, unit] = convertFromMetric(quantity, baseUnit, preferred)
|
|
||||||
if (unit === 'each') return `${qty}`
|
|
||||||
return `${qty} ${unit}`
|
|
||||||
}
|
|
||||||
68
mkdocs.yml
68
mkdocs.yml
|
|
@ -1,68 +0,0 @@
|
||||||
site_name: Kiwi
|
|
||||||
site_description: Pantry tracking and leftover recipe suggestions — scan barcodes, photograph receipts, and cook what you already have before it expires.
|
|
||||||
site_author: Circuit Forge LLC
|
|
||||||
site_url: https://docs.circuitforge.tech/kiwi
|
|
||||||
repo_url: https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi
|
|
||||||
repo_name: Circuit-Forge/kiwi
|
|
||||||
|
|
||||||
theme:
|
|
||||||
name: material
|
|
||||||
palette:
|
|
||||||
- scheme: default
|
|
||||||
primary: light green
|
|
||||||
accent: green
|
|
||||||
toggle:
|
|
||||||
icon: material/brightness-7
|
|
||||||
name: Switch to dark mode
|
|
||||||
- scheme: slate
|
|
||||||
primary: light green
|
|
||||||
accent: green
|
|
||||||
toggle:
|
|
||||||
icon: material/brightness-4
|
|
||||||
name: Switch to light mode
|
|
||||||
features:
|
|
||||||
- navigation.tabs
|
|
||||||
- navigation.sections
|
|
||||||
- navigation.expand
|
|
||||||
- navigation.top
|
|
||||||
- search.suggest
|
|
||||||
- search.highlight
|
|
||||||
- content.code.copy
|
|
||||||
|
|
||||||
markdown_extensions:
|
|
||||||
- admonition
|
|
||||||
- pymdownx.details
|
|
||||||
- pymdownx.superfences:
|
|
||||||
custom_fences:
|
|
||||||
- name: mermaid
|
|
||||||
class: mermaid
|
|
||||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
|
||||||
- pymdownx.highlight:
|
|
||||||
anchor_linenums: true
|
|
||||||
- pymdownx.tabbed:
|
|
||||||
alternate_style: true
|
|
||||||
- tables
|
|
||||||
- toc:
|
|
||||||
permalink: true
|
|
||||||
|
|
||||||
nav:
|
|
||||||
- Home: index.md
|
|
||||||
- Getting Started:
|
|
||||||
- Installation: getting-started/installation.md
|
|
||||||
- Quick Start: getting-started/quick-start.md
|
|
||||||
- LLM Backend (Optional): getting-started/llm-setup.md
|
|
||||||
- User Guide:
|
|
||||||
- Inventory: user-guide/inventory.md
|
|
||||||
- Barcode Scanning: user-guide/barcode.md
|
|
||||||
- Receipt OCR: user-guide/receipt-ocr.md
|
|
||||||
- Recipe Browser: user-guide/recipes.md
|
|
||||||
- Saved Recipes: user-guide/saved-recipes.md
|
|
||||||
- Leftover Mode: user-guide/leftover-mode.md
|
|
||||||
- Settings: user-guide/settings.md
|
|
||||||
- Reference:
|
|
||||||
- Recipe Engine: reference/recipe-engine.md
|
|
||||||
- Tier System: reference/tier-system.md
|
|
||||||
- Architecture: reference/architecture.md
|
|
||||||
|
|
||||||
extra_javascript:
|
|
||||||
- plausible.js
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kiwi"
|
name = "kiwi"
|
||||||
version = "0.3.0"
|
version = "0.2.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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue