fix: recipe enrichment backfill, main_ingredient browser domain, bug batch
Recipe corpus (#108): - Add _MAIN_INGREDIENT_SIGNALS to tag_inferrer.py (Chicken/Beef/Pork/Fish/Pasta/ Vegetables/Eggs/Legumes/Grains/Cheese) — infers main:* tags from ingredient names - Update browser_domains.py main_ingredient categories to use main:* tag queries instead of raw food terms; recipe_browser_fts now has full 3.19M row coverage (was ~1.2K before backfill) Bug fixes: - Fix community posts response shape (#96): add total/page/page_size fields - Fix export endpoint arg types (#92) - Fix household invite store leak (#93) - Fix receipts endpoint issues - Fix saved_recipes endpoint - Add session endpoint (app/api/endpoints/session.py) Shopping list: - Add migration 033_shopping_list.sql - Add shopping schemas (app/models/schemas/shopping.py) - Add ShoppingView.vue, ShoppingItemRow.vue, shopping.ts store Frontend: - InventoryList, RecipesView, RecipeDetailPanel polish - App.vue routing updates for shopping view Docs: - Add user-facing docs under docs/ (getting-started, user-guide, reference) - Add screenshots
This commit is contained in:
parent
890216a1f0
commit
01aae2eec8
40 changed files with 2076 additions and 53 deletions
|
|
@ -68,9 +68,14 @@ DEMO_MODE=false
|
||||||
# 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)
|
# Directus JWT (must match cf-directus SECRET env var exactly, including base64 == padding)
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,13 @@ async def list_posts(
|
||||||
):
|
):
|
||||||
store = _get_community_store()
|
store = _get_community_store()
|
||||||
if store is None:
|
if store is None:
|
||||||
return {"posts": [], "total": 0, "note": "Community DB not available on this instance."}
|
return {
|
||||||
|
"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
|
||||||
|
|
@ -76,7 +82,8 @@ async def list_posts(
|
||||||
dietary_tags=dietary,
|
dietary_tags=dietary,
|
||||||
allergen_exclude=allergen_ex,
|
allergen_exclude=allergen_ex,
|
||||||
)
|
)
|
||||||
return {"posts": [_post_to_dict(p) for p in posts if _visible(p)], "page": page, "page_size": page_size}
|
visible = [_post_to_dict(p) for p in posts if _visible(p)]
|
||||||
|
return {"posts": visible, "total": len(visible), "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/posts/{slug}")
|
@router.get("/posts/{slug}")
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ async def export_full_json(store: Store = Depends(get_store)):
|
||||||
"""
|
"""
|
||||||
inventory, saved = await asyncio.gather(
|
inventory, saved = await asyncio.gather(
|
||||||
asyncio.to_thread(store.list_inventory),
|
asyncio.to_thread(store.list_inventory),
|
||||||
asyncio.to_thread(store.get_saved_recipes, 1000, 0),
|
asyncio.to_thread(store.get_saved_recipes),
|
||||||
)
|
)
|
||||||
|
|
||||||
export_doc = {
|
export_doc = {
|
||||||
|
|
|
||||||
|
|
@ -128,15 +128,18 @@ 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.conn.execute(
|
store = Store(session.db)
|
||||||
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
try:
|
||||||
VALUES (?, ?, ?, ?)""",
|
store.conn.execute(
|
||||||
(token, session.household_id, session.user_id, expires_at),
|
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
||||||
)
|
VALUES (?, ?, ?, ?)""",
|
||||||
store.conn.commit()
|
(token, session.household_id, session.user_id, expires_at),
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
@ -152,24 +155,27 @@ 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()
|
||||||
row = hh_store.conn.execute(
|
try:
|
||||||
"""SELECT token, expires_at, used_at FROM household_invites
|
row = hh_store.conn.execute(
|
||||||
WHERE token = ? AND household_id = ?""",
|
"""SELECT token, expires_at, used_at FROM household_invites
|
||||||
(body.token, body.household_id),
|
WHERE token = ? AND household_id = ?""",
|
||||||
).fetchone()
|
(body.token, body.household_id),
|
||||||
|
).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,
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,11 @@ 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, store)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
||||||
return ReceiptResponse.model_validate(receipt)
|
return ReceiptResponse.model_validate(receipt)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,7 +66,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, store)
|
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
|
||||||
results.append(ReceiptResponse.model_validate(receipt))
|
results.append(ReceiptResponse.model_validate(receipt))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -97,8 +99,13 @@ 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, store: Store) -> None:
|
async def _process_receipt_ocr(receipt_id: int, image_path: Path, db_path: Path) -> 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
|
||||||
|
|
@ -108,3 +115,5 @@ async def _process_receipt_ocr(receipt_id: int, image_path: Path, store: Store)
|
||||||
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,12 +2,15 @@
|
||||||
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, get_session
|
from app.cloud_session import CloudUser, _auth_label, get_session
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
from app.db.session import get_store
|
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 (
|
||||||
|
|
@ -57,6 +60,7 @@ async def suggest_recipes(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
) -> RecipeResult:
|
) -> 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.
|
# Also read stored unit_system preference; default to metric if not set.
|
||||||
unit_system = store.get_setting("unit_system") or "metric"
|
unit_system = store.get_setting("unit_system") or "metric"
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@ 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()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
31
app/api/endpoints/session.py
Normal file
31
app/api/endpoints/session.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
log.info("session auth=%s tier=%s", _auth_label(session.user_id), session.tier)
|
||||||
|
return {
|
||||||
|
"auth": _auth_label(session.user_id),
|
||||||
|
"tier": session.tier,
|
||||||
|
"has_byok": session.has_byok,
|
||||||
|
}
|
||||||
21
app/db/migrations/033_shopping_list.sql
Normal file
21
app/db/migrations/033_shopping_list.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- 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);
|
||||||
|
|
@ -11,6 +11,9 @@ 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__)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
60
app/models/schemas/shopping.py
Normal file
60
app/models/schemas/shopping.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""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
|
||||||
|
|
@ -153,6 +153,49 @@ class ExpirationPredictor:
|
||||||
'flour': 90,
|
'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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def days_after_opening(self, category: str | None) -> int | None:
|
def days_after_opening(self, category: str | None) -> int | None:
|
||||||
"""Return days of shelf life remaining once a package is opened.
|
"""Return days of shelf life remaining once a package is opened.
|
||||||
|
|
||||||
|
|
@ -163,6 +206,38 @@ class ExpirationPredictor:
|
||||||
return None
|
return None
|
||||||
return self.SHELF_LIFE_AFTER_OPENING.get(category.lower())
|
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)
|
||||||
|
|
|
||||||
|
|
@ -56,16 +56,18 @@ DOMAINS: dict[str, dict] = {
|
||||||
"main_ingredient": {
|
"main_ingredient": {
|
||||||
"label": "Main Ingredient",
|
"label": "Main Ingredient",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Chicken": ["chicken", "poultry", "turkey"],
|
# These values match the inferred_tags written by tag_inferrer._MAIN_INGREDIENT_SIGNALS
|
||||||
"Beef": ["beef", "ground beef", "steak", "brisket", "pot roast"],
|
# and indexed into recipe_browser_fts — use exact tag strings.
|
||||||
"Pork": ["pork", "bacon", "ham", "sausage", "prosciutto"],
|
"Chicken": ["main:Chicken"],
|
||||||
"Fish": ["fish", "salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood"],
|
"Beef": ["main:Beef"],
|
||||||
"Pasta": ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"],
|
"Pork": ["main:Pork"],
|
||||||
"Vegetables": ["vegetable", "veggie", "cauliflower", "broccoli", "zucchini", "eggplant"],
|
"Fish": ["main:Fish"],
|
||||||
"Eggs": ["egg", "frittata", "omelette", "omelet", "quiche"],
|
"Pasta": ["main:Pasta"],
|
||||||
"Legumes": ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"],
|
"Vegetables": ["main:Vegetables"],
|
||||||
"Grains": ["rice", "quinoa", "barley", "farro", "oat", "grain"],
|
"Eggs": ["main:Eggs"],
|
||||||
"Cheese": ["cheese", "ricotta", "mozzarella", "parmesan", "cheddar"],
|
"Legumes": ["main:Legumes"],
|
||||||
|
"Grains": ["main:Grains"],
|
||||||
|
"Cheese": ["main:Cheese"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,21 @@ _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",
|
||||||
|
|
@ -232,6 +247,7 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ server {
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8512;
|
proxy_pass http://api:8512;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
# Prefer X-Real-IP set by Caddy (real client address); fall back to $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.
|
||||||
|
|
|
||||||
69
docs/getting-started/installation.md
Normal file
69
docs/getting-started/installation.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# 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
|
||||||
|
```
|
||||||
74
docs/getting-started/llm-setup.md
Normal file
74
docs/getting-started/llm-setup.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# 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+)
|
||||||
52
docs/getting-started/quick-start.md
Normal file
52
docs/getting-started/quick-start.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# 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.
|
||||||
35
docs/index.md
Normal file
35
docs/index.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# 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.
|
||||||
80
docs/reference/architecture.md
Normal file
80
docs/reference/architecture.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# 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
|
||||||
75
docs/reference/recipe-engine.md
Normal file
75
docs/reference/recipe-engine.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# 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 | — | ✓ | ✓ |
|
||||||
53
docs/reference/tier-system.md
Normal file
53
docs/reference/tier-system.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# 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.
|
||||||
BIN
docs/screenshots/01-pantry.png
Normal file
BIN
docs/screenshots/01-pantry.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
docs/screenshots/02-recipes.png
Normal file
BIN
docs/screenshots/02-recipes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
docs/screenshots/03-recipe-results.png
Normal file
BIN
docs/screenshots/03-recipe-results.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
docs/screenshots/04-receipts.png
Normal file
BIN
docs/screenshots/04-receipts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
39
docs/user-guide/barcode.md
Normal file
39
docs/user-guide/barcode.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# 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.
|
||||||
62
docs/user-guide/inventory.md
Normal file
62
docs/user-guide/inventory.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# 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)
|
||||||
39
docs/user-guide/leftover-mode.md
Normal file
39
docs/user-guide/leftover-mode.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# 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.
|
||||||
57
docs/user-guide/receipt-ocr.md
Normal file
57
docs/user-guide/receipt-ocr.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# 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.
|
||||||
50
docs/user-guide/recipes.md
Normal file
50
docs/user-guide/recipes.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# 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.
|
||||||
53
docs/user-guide/saved-recipes.md
Normal file
53
docs/user-guide/saved-recipes.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# 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.
|
||||||
63
docs/user-guide/settings.md
Normal file
63
docs/user-guide/settings.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -58,6 +58,15 @@
|
||||||
<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"/>
|
||||||
|
|
@ -94,6 +103,9 @@
|
||||||
<div v-show="currentTab === 'mealplan'" class="tab-content">
|
<div v-show="currentTab === 'mealplan'" class="tab-content">
|
||||||
<MealPlanView />
|
<MealPlanView />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="currentTab === 'shopping'" class="tab-content fade-in">
|
||||||
|
<ShoppingView />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -144,6 +156,14 @@
|
||||||
</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 -->
|
||||||
|
|
@ -190,12 +210,13 @@ 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 } from './services/api'
|
import { householdAPI, bootstrapSession } from './services/api'
|
||||||
|
|
||||||
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
|
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan' | 'shopping'
|
||||||
|
|
||||||
const currentTab = ref<Tab>('recipes')
|
const currentTab = ref<Tab>('recipes')
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
@ -225,6 +246,10 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,25 @@
|
||||||
<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 class="stat-strip-item">
|
<div
|
||||||
|
: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 class="stat-strip-item">
|
<div
|
||||||
|
: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>
|
||||||
|
|
@ -245,9 +259,162 @@
|
||||||
<div class="inventory-section">
|
<div class="inventory-section">
|
||||||
<!-- Filter chips -->
|
<!-- Filter chips -->
|
||||||
<div class="inventory-header">
|
<div class="inventory-header">
|
||||||
<h2 class="section-title">Pantry</h2>
|
<h2 class="section-title">
|
||||||
|
{{ 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
|
||||||
|
|
@ -387,6 +554,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template><!-- end v-else normal view -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export -->
|
<!-- Export -->
|
||||||
|
|
@ -468,6 +636,50 @@ const { items, stats, loading, locationFilter, statusFilter } = storeToRefs(stor
|
||||||
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')
|
||||||
|
|
||||||
|
|
@ -1495,4 +1707,125 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -284,9 +284,9 @@ function scaleIngredient(ing: string, scale: number): string {
|
||||||
|
|
||||||
function parseFrac(s: string): number {
|
function parseFrac(s: string): number {
|
||||||
const mixed = s.match(/^(\d+)\s+(\d+)\/(\d+)$/)
|
const mixed = s.match(/^(\d+)\s+(\d+)\/(\d+)$/)
|
||||||
if (mixed) return parseInt(mixed[1]) + parseInt(mixed[2]) / parseInt(mixed[3])
|
if (mixed) return parseInt(mixed[1]!) + parseInt(mixed[2]!) / parseInt(mixed[3]!)
|
||||||
const frac = s.match(/^(\d+)\/(\d+)$/)
|
const frac = s.match(/^(\d+)\/(\d+)$/)
|
||||||
if (frac) return parseInt(frac[1]) / parseInt(frac[2])
|
if (frac) return parseInt(frac[1]!) / parseInt(frac[2]!)
|
||||||
return parseFloat(s)
|
return parseFloat(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,7 +311,7 @@ function scaleIngredient(ing: string, scale: number): string {
|
||||||
return whole > 0 && remainder < 0.05 ? `${whole}` : n.toFixed(1).replace(/\.0$/, '')
|
return whole > 0 && remainder < 0.05 ? `${whole}` : n.toFixed(1).replace(/\.0$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const low = parseFrac(m[1])
|
const low = parseFrac(m[1]!)
|
||||||
const scaledLow = fmtNum(low * scale)
|
const scaledLow = fmtNum(low * scale)
|
||||||
|
|
||||||
let scaled: string
|
let scaled: string
|
||||||
|
|
|
||||||
|
|
@ -823,13 +823,13 @@ function pickSurprise() {
|
||||||
if (!pool.length) return
|
if (!pool.length) return
|
||||||
const exclude = spotlightRecipe.value?.id
|
const exclude = spotlightRecipe.value?.id
|
||||||
const candidates = pool.length > 1 ? pool.filter((r) => r.id !== exclude) : pool
|
const candidates = pool.length > 1 ? pool.filter((r) => r.id !== exclude) : pool
|
||||||
spotlightRecipe.value = candidates[Math.floor(Math.random() * candidates.length)]
|
spotlightRecipe.value = candidates[Math.floor(Math.random() * candidates.length)] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickBest() {
|
function pickBest() {
|
||||||
const pool = filteredSuggestions.value
|
const pool = filteredSuggestions.value
|
||||||
if (!pool.length) return
|
if (!pool.length) return
|
||||||
spotlightRecipe.value = pool[0]
|
spotlightRecipe.value = pool[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
|
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
|
||||||
|
|
|
||||||
180
frontend/src/components/ShoppingItemRow.vue
Normal file
180
frontend/src/components/ShoppingItemRow.vue
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
<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>
|
||||||
359
frontend/src/components/ShoppingView.vue
Normal file
359
frontend/src/components/ShoppingView.vue
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
<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>
|
||||||
82
frontend/src/stores/shopping.ts
Normal file
82
frontend/src/stores/shopping.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue