Compare commits
10 commits
543c64ea30
...
19c0664637
| Author | SHA1 | Date | |
|---|---|---|---|
| 19c0664637 | |||
| e52c406d0a | |||
| 4281b0ce19 | |||
| f54127a8cc | |||
| 062b5d16a1 | |||
| 5f094eb37a | |||
| 2baa8c49a9 | |||
| faaa6fbf86 | |||
| 67b521559e | |||
| a7fc441105 |
11 changed files with 823 additions and 8 deletions
|
|
@ -92,12 +92,17 @@ async def create_plan(
|
||||||
return _plan_summary(plan, slots)
|
return _plan_summary(plan, slots)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[dict])
|
@router.get("/", response_model=list[PlanSummary])
|
||||||
async def list_plans(
|
async def list_plans(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
) -> list[dict]:
|
) -> list[PlanSummary]:
|
||||||
return await asyncio.to_thread(store.list_meal_plans)
|
plans = await asyncio.to_thread(store.list_meal_plans)
|
||||||
|
result = []
|
||||||
|
for p in plans:
|
||||||
|
slots = await asyncio.to_thread(store.get_plan_slots, p["id"])
|
||||||
|
result.append(_plan_summary(p, slots))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plan_id}", response_model=PlanSummary)
|
@router.get("/{plan_id}", response_model=PlanSummary)
|
||||||
|
|
@ -124,6 +129,8 @@ async def upsert_slot(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
) -> SlotSummary:
|
) -> SlotSummary:
|
||||||
|
if day_of_week < 0 or day_of_week > 6:
|
||||||
|
raise HTTPException(status_code=422, detail="day_of_week must be 0-6.")
|
||||||
if meal_type not in VALID_MEAL_TYPES:
|
if meal_type not in VALID_MEAL_TYPES:
|
||||||
raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.")
|
raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.")
|
||||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
||||||
|
|
@ -197,6 +204,28 @@ async def get_shopping_list(
|
||||||
|
|
||||||
# ── prep session ──────────────────────────────────────────────────────────────
|
# ── prep session ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{plan_id}/prep-session", response_model=PrepSessionSummary)
|
||||||
|
async def get_prep_session(
|
||||||
|
plan_id: int,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
store: Store = Depends(get_store),
|
||||||
|
) -> PrepSessionSummary:
|
||||||
|
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
||||||
|
if plan is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Plan not found.")
|
||||||
|
prep_session = await asyncio.to_thread(store.get_prep_session_for_plan, plan_id)
|
||||||
|
if prep_session is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No prep session for this plan.")
|
||||||
|
raw_tasks = await asyncio.to_thread(store.get_prep_tasks, prep_session["id"])
|
||||||
|
return PrepSessionSummary(
|
||||||
|
id=prep_session["id"],
|
||||||
|
plan_id=plan_id,
|
||||||
|
scheduled_date=prep_session["scheduled_date"],
|
||||||
|
status=prep_session["status"],
|
||||||
|
tasks=[_prep_task_summary(t) for t in raw_tasks],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{plan_id}/prep-session", response_model=PrepSessionSummary)
|
@router.post("/{plan_id}/prep-session", response_model=PrepSessionSummary)
|
||||||
async def create_prep_session(
|
async def create_prep_session(
|
||||||
plan_id: int,
|
plan_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1114,8 +1114,10 @@ class Store:
|
||||||
|
|
||||||
def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None:
|
def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None:
|
||||||
allowed = {"duration_minutes", "sequence_order", "notes", "equipment"}
|
allowed = {"duration_minutes", "sequence_order", "notes", "equipment"}
|
||||||
updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
|
invalid = set(kwargs) - allowed # check raw kwargs BEFORE filtering
|
||||||
assert all(k in allowed for k in updates), f"Unexpected column(s): {set(updates) - allowed}"
|
if invalid:
|
||||||
|
raise ValueError(f"Unexpected column(s) in update_prep_task: {invalid}")
|
||||||
|
updates = {k: v for k, v in kwargs.items() if v is not None}
|
||||||
if not updates:
|
if not updates:
|
||||||
return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,))
|
return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,))
|
||||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ from app.api.routes import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.meal_plan.affiliates import register_kiwi_programs
|
from app.services.meal_plan.affiliates import register_kiwi_programs
|
||||||
|
|
||||||
register_kiwi_programs()
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,6 +18,7 @@ logger = logging.getLogger(__name__)
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Starting Kiwi API...")
|
logger.info("Starting Kiwi API...")
|
||||||
settings.ensure_dirs()
|
settings.ensure_dirs()
|
||||||
|
register_kiwi_programs()
|
||||||
|
|
||||||
# Start LLM background task scheduler
|
# Start LLM background task scheduler
|
||||||
from app.tasks.scheduler import get_scheduler
|
from app.tasks.scheduler import get_scheduler
|
||||||
|
|
|
||||||
91
app/services/meal_plan/llm_planner.py
Normal file
91
app/services/meal_plan/llm_planner.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# app/services/meal_plan/llm_planner.py
|
||||||
|
# BSL 1.1 — LLM feature
|
||||||
|
"""LLM-assisted full-week meal plan generation.
|
||||||
|
|
||||||
|
Returns suggestions for human review — never writes to the DB directly.
|
||||||
|
The API endpoint presents the suggestions and waits for user approval
|
||||||
|
before calling store.upsert_slot().
|
||||||
|
|
||||||
|
Routing: pass a router from get_meal_plan_router() in llm_router.py.
|
||||||
|
Cloud: cf-text via cf-orch (3B-7B GGUF, ~2GB VRAM).
|
||||||
|
Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PLAN_SYSTEM = """\
|
||||||
|
You are a practical meal planning assistant. Given a pantry inventory and
|
||||||
|
dietary preferences, suggest a week of dinners (or other configured meals).
|
||||||
|
|
||||||
|
Prioritise ingredients that are expiring soon. Prefer variety across the week.
|
||||||
|
Respect all dietary restrictions.
|
||||||
|
|
||||||
|
Respond with a JSON array only — no prose, no markdown fences.
|
||||||
|
Each item: {"day": 0-6, "meal_type": "dinner", "recipe_id": <int or null>, "suggestion": "<recipe name>"}
|
||||||
|
|
||||||
|
day 0 = Monday, day 6 = Sunday.
|
||||||
|
If you cannot match a known recipe_id, set recipe_id to null and provide a suggestion name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlanSuggestion:
|
||||||
|
day: int # 0 = Monday
|
||||||
|
meal_type: str
|
||||||
|
recipe_id: int | None
|
||||||
|
suggestion: str # human-readable name
|
||||||
|
|
||||||
|
|
||||||
|
def generate_plan(
|
||||||
|
pantry_items: list[str],
|
||||||
|
meal_types: list[str],
|
||||||
|
dietary_notes: str,
|
||||||
|
router,
|
||||||
|
) -> list[PlanSuggestion]:
|
||||||
|
"""Return a list of PlanSuggestion for user review.
|
||||||
|
|
||||||
|
Never writes to DB — caller must upsert slots after user approves.
|
||||||
|
Returns an empty list if router is None or response is unparseable.
|
||||||
|
"""
|
||||||
|
if router is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
pantry_text = "\n".join(f"- {item}" for item in pantry_items[:50])
|
||||||
|
meal_text = ", ".join(meal_types)
|
||||||
|
user_msg = (
|
||||||
|
f"Meal types: {meal_text}\n"
|
||||||
|
f"Dietary notes: {dietary_notes or 'none'}\n\n"
|
||||||
|
f"Pantry (partial):\n{pantry_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = router.complete(
|
||||||
|
system=_PLAN_SYSTEM,
|
||||||
|
user=user_msg,
|
||||||
|
max_tokens=512,
|
||||||
|
temperature=0.7,
|
||||||
|
)
|
||||||
|
items = json.loads(response.strip())
|
||||||
|
suggestions = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
day = item.get("day")
|
||||||
|
meal_type = item.get("meal_type", "dinner")
|
||||||
|
if not isinstance(day, int) or day < 0 or day > 6:
|
||||||
|
continue
|
||||||
|
suggestions.append(PlanSuggestion(
|
||||||
|
day=day,
|
||||||
|
meal_type=meal_type,
|
||||||
|
recipe_id=item.get("recipe_id"),
|
||||||
|
suggestion=str(item.get("suggestion", "")),
|
||||||
|
))
|
||||||
|
return suggestions
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("LLM plan generation failed: %s", exc)
|
||||||
|
return []
|
||||||
96
app/services/meal_plan/llm_router.py
Normal file
96
app/services/meal_plan/llm_router.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# app/services/meal_plan/llm_router.py
|
||||||
|
# BSL 1.1 — LLM feature
|
||||||
|
"""Provide a router-compatible LLM client for meal plan generation tasks.
|
||||||
|
|
||||||
|
Cloud (CF_ORCH_URL set):
|
||||||
|
Allocates a cf-text service via cf-orch (3B-7B GGUF, ~2GB VRAM).
|
||||||
|
Returns an _OrchTextRouter that wraps the cf-text HTTP endpoint
|
||||||
|
with a .complete(system, user, **kwargs) interface.
|
||||||
|
|
||||||
|
Local / self-hosted (no CF_ORCH_URL):
|
||||||
|
Returns an LLMRouter instance which tries ollama, vllm, or any
|
||||||
|
backend configured in ~/.config/circuitforge/llm.yaml.
|
||||||
|
|
||||||
|
Both paths expose the same interface so llm_timing.py and llm_planner.py
|
||||||
|
need no knowledge of the backend.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# cf-orch service name and VRAM budget for meal plan LLM tasks.
|
||||||
|
# These are lighter than recipe_llm (4.0 GB) — cf-text handles them.
|
||||||
|
_SERVICE_TYPE = "cf-text"
|
||||||
|
_TTL_S = 120.0
|
||||||
|
_CALLER = "kiwi-meal-plan"
|
||||||
|
|
||||||
|
|
||||||
|
class _OrchTextRouter:
|
||||||
|
"""Thin adapter that makes a cf-text HTTP endpoint look like LLMRouter."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
system: str = "",
|
||||||
|
user: str = "",
|
||||||
|
max_tokens: int = 512,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
**_kwargs,
|
||||||
|
) -> str:
|
||||||
|
from openai import OpenAI
|
||||||
|
client = OpenAI(base_url=self._base_url + "/v1", api_key="any")
|
||||||
|
messages = []
|
||||||
|
if system:
|
||||||
|
messages.append({"role": "system", "content": system})
|
||||||
|
messages.append({"role": "user", "content": user})
|
||||||
|
try:
|
||||||
|
model = client.models.list().data[0].id
|
||||||
|
except Exception:
|
||||||
|
model = "local"
|
||||||
|
resp = client.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
return resp.choices[0].message.content or ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_meal_plan_router():
|
||||||
|
"""Return an LLM client for meal plan tasks.
|
||||||
|
|
||||||
|
Tries cf-orch cf-text allocation first (cloud); falls back to LLMRouter
|
||||||
|
(local ollama/vllm). Returns None if no backend is available.
|
||||||
|
"""
|
||||||
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
|
if cf_orch_url:
|
||||||
|
try:
|
||||||
|
from circuitforge_orch.client import CFOrchClient
|
||||||
|
client = CFOrchClient(cf_orch_url)
|
||||||
|
ctx = client.allocate(
|
||||||
|
service=_SERVICE_TYPE,
|
||||||
|
ttl_s=_TTL_S,
|
||||||
|
caller=_CALLER,
|
||||||
|
)
|
||||||
|
alloc = ctx.__enter__()
|
||||||
|
if alloc is not None:
|
||||||
|
return _OrchTextRouter(alloc.url), ctx
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("cf-orch cf-text allocation failed, falling back to LLMRouter: %s", exc)
|
||||||
|
|
||||||
|
# Local fallback: LLMRouter (ollama / vllm / openai-compat)
|
||||||
|
try:
|
||||||
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
return LLMRouter(), nullcontext(None)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.debug("LLMRouter: no llm.yaml and no LLM env vars — meal plan LLM disabled")
|
||||||
|
return None, nullcontext(None)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("LLMRouter init failed: %s", exc)
|
||||||
|
return None, nullcontext(None)
|
||||||
65
app/services/meal_plan/llm_timing.py
Normal file
65
app/services/meal_plan/llm_timing.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# app/services/meal_plan/llm_timing.py
|
||||||
|
# BSL 1.1 — LLM feature
|
||||||
|
"""Estimate cook times for recipes missing corpus prep/cook time fields.
|
||||||
|
|
||||||
|
Used only when tier allows `meal_plan_llm_timing`. Falls back gracefully
|
||||||
|
when no LLM backend is available.
|
||||||
|
|
||||||
|
Routing: pass a router from get_meal_plan_router() in llm_router.py.
|
||||||
|
Cloud: cf-text via cf-orch (3B GGUF, ~2GB VRAM).
|
||||||
|
Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TIMING_PROMPT = """\
|
||||||
|
You are a practical cook. Given a recipe name and its ingredients, estimate:
|
||||||
|
1. prep_time: minutes of active prep work (chopping, mixing, etc.)
|
||||||
|
2. cook_time: minutes of cooking (oven, stovetop, etc.)
|
||||||
|
|
||||||
|
Respond with ONLY two integers on separate lines:
|
||||||
|
prep_time
|
||||||
|
cook_time
|
||||||
|
|
||||||
|
If you cannot estimate, respond with:
|
||||||
|
0
|
||||||
|
0
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_timing(recipe_name: str, ingredients: list[str], router) -> tuple[int | None, int | None]:
|
||||||
|
"""Return (prep_minutes, cook_minutes) for a recipe using LLMRouter.
|
||||||
|
|
||||||
|
Returns (None, None) if the router is unavailable or the response is
|
||||||
|
unparseable. Never raises.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_name: Name of the recipe.
|
||||||
|
ingredients: List of raw ingredient strings from the corpus.
|
||||||
|
router: An LLMRouter instance (from circuitforge_core.llm).
|
||||||
|
"""
|
||||||
|
if router is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
ingredient_list = "\n".join(f"- {i}" for i in (ingredients or [])[:15])
|
||||||
|
prompt = f"Recipe: {recipe_name}\n\nIngredients:\n{ingredient_list}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = router.complete(
|
||||||
|
system=_TIMING_PROMPT,
|
||||||
|
user=prompt,
|
||||||
|
max_tokens=16,
|
||||||
|
temperature=0.0,
|
||||||
|
)
|
||||||
|
lines = response.strip().splitlines()
|
||||||
|
prep = int(lines[0].strip()) if lines else 0
|
||||||
|
cook = int(lines[1].strip()) if len(lines) > 1 else 0
|
||||||
|
if prep == 0 and cook == 0:
|
||||||
|
return None, None
|
||||||
|
return prep or None, cook or None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("LLM timing estimation failed for %r: %s", recipe_name, exc)
|
||||||
|
return None, None
|
||||||
|
|
@ -46,6 +46,18 @@
|
||||||
<span class="sidebar-label">Receipts</span>
|
<span class="sidebar-label">Receipts</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button :class="['sidebar-item', { active: currentTab === 'mealplan' }]" @click="switchTab('mealplan')" aria-label="Meal Plan">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9"/>
|
||||||
|
<line x1="8" y1="4" x2="8" y2="9"/>
|
||||||
|
<line x1="16" y1="4" x2="16" y2="9"/>
|
||||||
|
<line x1="7" y1="14" x2="11" y2="14"/>
|
||||||
|
<line x1="7" y1="17" x2="14" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sidebar-label">Meal Plan</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
|
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
|
@ -79,6 +91,9 @@
|
||||||
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
||||||
<SettingsView />
|
<SettingsView />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="currentTab === 'mealplan'" class="tab-content">
|
||||||
|
<MealPlanView />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,6 +133,17 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Settings</span>
|
<span class="nav-label">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button :class="['nav-item', { active: currentTab === 'mealplan' }]" @click="switchTab('mealplan')" aria-label="Meal Plan">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9"/>
|
||||||
|
<line x1="8" y1="4" x2="8" y2="9"/>
|
||||||
|
<line x1="16" y1="4" x2="16" y2="9"/>
|
||||||
|
<line x1="7" y1="14" x2="11" y2="14"/>
|
||||||
|
<line x1="7" y1="17" x2="14" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label">Meal Plan</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
||||||
|
|
@ -163,12 +189,13 @@ import InventoryList from './components/InventoryList.vue'
|
||||||
import ReceiptsView from './components/ReceiptsView.vue'
|
import ReceiptsView from './components/ReceiptsView.vue'
|
||||||
import RecipesView from './components/RecipesView.vue'
|
import RecipesView from './components/RecipesView.vue'
|
||||||
import SettingsView from './components/SettingsView.vue'
|
import SettingsView from './components/SettingsView.vue'
|
||||||
|
import MealPlanView from './components/MealPlanView.vue'
|
||||||
import FeedbackButton from './components/FeedbackButton.vue'
|
import FeedbackButton from './components/FeedbackButton.vue'
|
||||||
import { useInventoryStore } from './stores/inventory'
|
import { useInventoryStore } from './stores/inventory'
|
||||||
import { useEasterEggs } from './composables/useEasterEggs'
|
import { useEasterEggs } from './composables/useEasterEggs'
|
||||||
import { householdAPI } from './services/api'
|
import { householdAPI } from './services/api'
|
||||||
|
|
||||||
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
|
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
|
||||||
|
|
||||||
const currentTab = ref<Tab>('inventory')
|
const currentTab = ref<Tab>('inventory')
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
|
||||||
126
frontend/src/components/MealPlanGrid.vue
Normal file
126
frontend/src/components/MealPlanGrid.vue
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<!-- frontend/src/components/MealPlanGrid.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="meal-plan-grid">
|
||||||
|
<!-- Collapsible header (mobile) -->
|
||||||
|
<div class="grid-toggle-row">
|
||||||
|
<span class="grid-label">This week</span>
|
||||||
|
<button
|
||||||
|
class="grid-toggle-btn"
|
||||||
|
:aria-expanded="!collapsed"
|
||||||
|
:aria-label="collapsed ? 'Show plan' : 'Hide plan'"
|
||||||
|
@click="collapsed = !collapsed"
|
||||||
|
>{{ collapsed ? 'Show plan' : 'Hide plan' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="!collapsed" class="grid-body">
|
||||||
|
<!-- Day headers -->
|
||||||
|
<div class="day-headers">
|
||||||
|
<div class="meal-type-col-spacer" />
|
||||||
|
<div
|
||||||
|
v-for="(day, i) in DAY_LABELS"
|
||||||
|
:key="i"
|
||||||
|
class="day-header"
|
||||||
|
:aria-label="day"
|
||||||
|
>{{ day }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- One row per meal type -->
|
||||||
|
<div
|
||||||
|
v-for="mealType in activeMealTypes"
|
||||||
|
:key="mealType"
|
||||||
|
class="meal-row"
|
||||||
|
>
|
||||||
|
<div class="meal-type-label">{{ mealType }}</div>
|
||||||
|
<button
|
||||||
|
v-for="dayIndex in 7"
|
||||||
|
:key="dayIndex - 1"
|
||||||
|
class="slot-btn"
|
||||||
|
:class="{ filled: !!getSlot(dayIndex - 1, mealType) }"
|
||||||
|
:aria-label="`${DAY_LABELS[dayIndex - 1]} ${mealType}: ${getSlot(dayIndex - 1, mealType)?.recipe_title ?? 'empty'}`"
|
||||||
|
@click="$emit('slot-click', { dayOfWeek: dayIndex - 1, mealType })"
|
||||||
|
>
|
||||||
|
<span v-if="getSlot(dayIndex - 1, mealType)" class="slot-title">
|
||||||
|
{{ getSlot(dayIndex - 1, mealType)!.recipe_title ?? getSlot(dayIndex - 1, mealType)!.custom_label ?? '...' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="slot-empty" aria-hidden="true">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add meal type row (Paid only) -->
|
||||||
|
<div v-if="canAddMealType" class="add-meal-type-row">
|
||||||
|
<button class="add-meal-type-btn" @click="$emit('add-meal-type')">
|
||||||
|
+ Add meal type
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useMealPlanStore } from '../stores/mealPlan'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
activeMealTypes: string[]
|
||||||
|
canAddMealType: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'slot-click', payload: { dayOfWeek: number; mealType: string }): void
|
||||||
|
(e: 'add-meal-type'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useMealPlanStore()
|
||||||
|
const { getSlot } = store
|
||||||
|
|
||||||
|
const collapsed = ref(false)
|
||||||
|
|
||||||
|
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.meal-plan-grid { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.grid-toggle-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.grid-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.07em; opacity: 0.6; }
|
||||||
|
.grid-toggle-btn {
|
||||||
|
font-size: 0.75rem; background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--color-accent); padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-body { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.day-headers { display: grid; grid-template-columns: 3rem repeat(7, 1fr); gap: 3px; }
|
||||||
|
.meal-type-col-spacer { }
|
||||||
|
.day-header { text-align: center; font-size: 0.7rem; font-weight: 700; padding: 3px; background: var(--color-surface-2); border-radius: 4px; }
|
||||||
|
|
||||||
|
.meal-row { display: grid; grid-template-columns: 3rem repeat(7, 1fr); gap: 3px; align-items: start; }
|
||||||
|
.meal-type-label { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.55; display: flex; align-items: center; font-weight: 600; }
|
||||||
|
|
||||||
|
.slot-btn {
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 44px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.slot-btn:hover { border-color: var(--color-accent); }
|
||||||
|
.slot-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||||
|
.slot-btn.filled { border-color: var(--color-success); background: var(--color-success-subtle); }
|
||||||
|
.slot-title { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; color: var(--color-text); }
|
||||||
|
.slot-empty { opacity: 0.25; font-size: 1rem; }
|
||||||
|
|
||||||
|
.add-meal-type-row { padding: 0.4rem 0 0.2rem; }
|
||||||
|
.add-meal-type-btn { font-size: 0.75rem; background: none; border: none; cursor: pointer; color: var(--color-accent); padding: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.day-headers, .meal-row { grid-template-columns: 2.5rem repeat(7, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
153
frontend/src/components/MealPlanView.vue
Normal file
153
frontend/src/components/MealPlanView.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<!-- frontend/src/components/MealPlanView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="meal-plan-view">
|
||||||
|
<!-- Week picker + new plan button -->
|
||||||
|
<div class="plan-controls">
|
||||||
|
<select
|
||||||
|
class="week-select"
|
||||||
|
:value="activePlan?.id ?? ''"
|
||||||
|
aria-label="Select week"
|
||||||
|
@change="onSelectPlan(Number(($event.target as HTMLSelectElement).value))"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select a week...</option>
|
||||||
|
<option v-for="p in plans" :key="p.id" :value="p.id">
|
||||||
|
Week of {{ p.week_start }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button class="new-plan-btn" @click="onNewPlan">+ New week</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="activePlan">
|
||||||
|
<!-- Compact expandable week grid (always visible) -->
|
||||||
|
<MealPlanGrid
|
||||||
|
:active-meal-types="activePlan.meal_types"
|
||||||
|
:can-add-meal-type="canAddMealType"
|
||||||
|
@slot-click="onSlotClick"
|
||||||
|
@add-meal-type="onAddMealType"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Panel tabs: Shopping List | Prep Schedule -->
|
||||||
|
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
|
||||||
|
<button
|
||||||
|
v-for="tab in TABS"
|
||||||
|
:key="tab.id"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === tab.id"
|
||||||
|
:aria-controls="`tabpanel-${tab.id}`"
|
||||||
|
:id="`tab-${tab.id}`"
|
||||||
|
class="panel-tab"
|
||||||
|
:class="{ active: activeTab === tab.id }"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="activeTab === 'shopping'"
|
||||||
|
id="tabpanel-shopping"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-shopping"
|
||||||
|
class="tab-panel"
|
||||||
|
>
|
||||||
|
<ShoppingListPanel @load="store.loadShoppingList()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="activeTab === 'prep'"
|
||||||
|
id="tabpanel-prep"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-prep"
|
||||||
|
class="tab-panel"
|
||||||
|
>
|
||||||
|
<PrepSessionView @load="store.loadPrepSession()" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else-if="!loading" class="empty-plan-state">
|
||||||
|
<p>No meal plan yet for this week.</p>
|
||||||
|
<button class="new-plan-btn" @click="onNewPlan">Start planning</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useMealPlanStore } from '../stores/mealPlan'
|
||||||
|
import MealPlanGrid from './MealPlanGrid.vue'
|
||||||
|
import ShoppingListPanel from './ShoppingListPanel.vue'
|
||||||
|
import PrepSessionView from './PrepSessionView.vue'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'shopping', label: 'Shopping List' },
|
||||||
|
{ id: 'prep', label: 'Prep Schedule' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type TabId = typeof TABS[number]['id']
|
||||||
|
|
||||||
|
const store = useMealPlanStore()
|
||||||
|
const { plans, activePlan, loading } = storeToRefs(store)
|
||||||
|
|
||||||
|
const activeTab = ref<TabId>('shopping')
|
||||||
|
|
||||||
|
// canAddMealType is a UI hint — backend enforces the paid gate authoritatively
|
||||||
|
const canAddMealType = computed(() =>
|
||||||
|
(activePlan.value?.meal_types.length ?? 0) < 4
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => store.loadPlans())
|
||||||
|
|
||||||
|
async function onNewPlan() {
|
||||||
|
const today = new Date()
|
||||||
|
const day = today.getDay()
|
||||||
|
// Compute Monday of current week (getDay: 0=Sun, 1=Mon...)
|
||||||
|
const monday = new Date(today)
|
||||||
|
monday.setDate(today.getDate() - ((day + 6) % 7))
|
||||||
|
const weekStart = monday.toISOString().split('T')[0]
|
||||||
|
await store.createPlan(weekStart, ['dinner'])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSelectPlan(planId: number) {
|
||||||
|
if (planId) await store.setActivePlan(planId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSlotClick(_: { dayOfWeek: number; mealType: string }) {
|
||||||
|
// Recipe picker integration filed as follow-up
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddMealType() {
|
||||||
|
// Add meal type picker — Paid gate enforced by backend
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.meal-plan-view { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
.plan-controls { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.week-select {
|
||||||
|
flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px;
|
||||||
|
border: 1px solid var(--color-border); background: var(--color-surface);
|
||||||
|
color: var(--color-text); font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.new-plan-btn {
|
||||||
|
padding: 0.4rem 1rem; border-radius: 20px; font-size: 0.82rem;
|
||||||
|
background: var(--color-accent-subtle); color: var(--color-accent);
|
||||||
|
border: 1px solid var(--color-accent); cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.new-plan-btn:hover { background: var(--color-accent); color: white; }
|
||||||
|
|
||||||
|
.panel-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
|
||||||
|
.panel-tab {
|
||||||
|
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
|
||||||
|
background: none; border: 1px solid transparent; border-bottom: none; cursor: pointer;
|
||||||
|
color: var(--color-text-secondary); transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.panel-tab.active {
|
||||||
|
color: var(--color-accent); background: var(--color-accent-subtle);
|
||||||
|
border-color: var(--color-border); border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
.panel-tab:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.tab-panel { padding-top: 0.75rem; }
|
||||||
|
|
||||||
|
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
115
frontend/src/components/PrepSessionView.vue
Normal file
115
frontend/src/components/PrepSessionView.vue
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<!-- frontend/src/components/PrepSessionView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="prep-session-view">
|
||||||
|
<div v-if="loading" class="panel-loading">Building prep schedule...</div>
|
||||||
|
|
||||||
|
<template v-else-if="prepSession">
|
||||||
|
<p class="prep-intro">
|
||||||
|
Tasks are ordered to make the most of your oven and stovetop.
|
||||||
|
Edit any time estimate that looks wrong — your changes are saved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol class="task-list" role="list">
|
||||||
|
<li
|
||||||
|
v-for="task in prepSession.tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="task-item"
|
||||||
|
:class="{ 'user-edited': task.user_edited }"
|
||||||
|
>
|
||||||
|
<div class="task-header">
|
||||||
|
<span class="task-order" aria-hidden="true">{{ task.sequence_order }}</span>
|
||||||
|
<span class="task-label">{{ task.task_label }}</span>
|
||||||
|
<span v-if="task.equipment" class="task-equip">{{ task.equipment }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-meta">
|
||||||
|
<label class="duration-label">
|
||||||
|
Time estimate (min):
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="duration-input"
|
||||||
|
:value="task.duration_minutes ?? ''"
|
||||||
|
:placeholder="task.duration_minutes ? '' : 'unknown'"
|
||||||
|
:aria-label="`Duration for ${task.task_label} in minutes`"
|
||||||
|
@change="onDurationChange(task.id, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span v-if="task.user_edited" class="edited-badge" title="You edited this">edited</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="task.notes" class="task-notes">{{ task.notes }}</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div v-if="!prepSession.tasks.length" class="empty-state">
|
||||||
|
No recipes assigned yet — add some to your plan first.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<button class="load-btn" @click="$emit('load')">Build prep schedule</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMealPlanStore } from '../stores/mealPlan'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
|
defineEmits<{ (e: 'load'): void }>()
|
||||||
|
|
||||||
|
const store = useMealPlanStore()
|
||||||
|
const { prepSession, prepLoading: loading } = storeToRefs(store)
|
||||||
|
|
||||||
|
async function onDurationChange(taskId: number, value: string) {
|
||||||
|
const minutes = parseInt(value, 10)
|
||||||
|
if (!isNaN(minutes) && minutes > 0) {
|
||||||
|
await store.updatePrepTask(taskId, { duration_minutes: minutes })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prep-session-view { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.panel-loading { font-size: 0.85rem; opacity: 0.6; padding: 1rem 0; }
|
||||||
|
.prep-intro { font-size: 0.82rem; opacity: 0.65; margin: 0; }
|
||||||
|
|
||||||
|
.task-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
padding: 0.6rem 0.8rem; border-radius: 8px;
|
||||||
|
background: var(--color-surface-2); border: 1px solid var(--color-border);
|
||||||
|
display: flex; flex-direction: column; gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.task-item.user-edited { border-color: var(--color-accent); }
|
||||||
|
|
||||||
|
.task-header { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.task-order {
|
||||||
|
width: 22px; height: 22px; border-radius: 50%;
|
||||||
|
background: var(--color-accent); color: white;
|
||||||
|
font-size: 0.7rem; font-weight: 700;
|
||||||
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task-label { flex: 1; font-size: 0.88rem; font-weight: 500; }
|
||||||
|
.task-equip { font-size: 0.68rem; padding: 2px 6px; border-radius: 12px; background: var(--color-surface); opacity: 0.7; }
|
||||||
|
|
||||||
|
.task-meta { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.duration-label { font-size: 0.75rem; opacity: 0.7; display: flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.duration-input {
|
||||||
|
width: 52px; padding: 2px 4px; border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-border); background: var(--color-surface);
|
||||||
|
font-size: 0.78rem; color: var(--color-text);
|
||||||
|
}
|
||||||
|
.duration-input:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||||
|
.edited-badge { font-size: 0.65rem; opacity: 0.5; font-style: italic; }
|
||||||
|
.task-notes { font-size: 0.75rem; opacity: 0.6; }
|
||||||
|
|
||||||
|
.empty-state { font-size: 0.85rem; opacity: 0.55; padding: 1rem 0; text-align: center; }
|
||||||
|
.load-btn {
|
||||||
|
font-size: 0.85rem; padding: 0.5rem 1.2rem;
|
||||||
|
background: var(--color-accent-subtle); color: var(--color-accent);
|
||||||
|
border: 1px solid var(--color-accent); border-radius: 20px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.load-btn:hover { background: var(--color-accent); color: white; }
|
||||||
|
</style>
|
||||||
112
frontend/src/components/ShoppingListPanel.vue
Normal file
112
frontend/src/components/ShoppingListPanel.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<!-- frontend/src/components/ShoppingListPanel.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="shopping-list-panel">
|
||||||
|
<div v-if="loading" class="panel-loading">Loading shopping list...</div>
|
||||||
|
|
||||||
|
<template v-else-if="shoppingList">
|
||||||
|
<!-- Disclosure banner -->
|
||||||
|
<div v-if="shoppingList.disclosure && shoppingList.gap_items.length" class="disclosure-banner">
|
||||||
|
{{ shoppingList.disclosure }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gap items (need to buy) -->
|
||||||
|
<section v-if="shoppingList.gap_items.length" aria-label="Items to buy">
|
||||||
|
<h3 class="section-heading">To buy ({{ shoppingList.gap_items.length }})</h3>
|
||||||
|
<ul class="item-list" role="list">
|
||||||
|
<li v-for="item in shoppingList.gap_items" :key="item.ingredient_name" class="gap-item">
|
||||||
|
<label class="item-row">
|
||||||
|
<input type="checkbox" class="item-check" :aria-label="`Mark ${item.ingredient_name} as grabbed`" />
|
||||||
|
<span class="item-name">{{ item.ingredient_name }}</span>
|
||||||
|
<span v-if="item.needed_raw" class="item-qty gap">{{ item.needed_raw }}</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="item.retailer_links.length" class="retailer-links">
|
||||||
|
<a
|
||||||
|
v-for="link in item.retailer_links"
|
||||||
|
:key="link.retailer"
|
||||||
|
:href="link.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer sponsored"
|
||||||
|
class="retailer-link"
|
||||||
|
:aria-label="`Buy ${item.ingredient_name} at ${link.label}`"
|
||||||
|
>{{ link.label }}</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Covered items (already in pantry) -->
|
||||||
|
<section v-if="shoppingList.covered_items.length" aria-label="Items already in pantry">
|
||||||
|
<h3 class="section-heading covered-heading">In your pantry ({{ shoppingList.covered_items.length }})</h3>
|
||||||
|
<ul class="item-list covered-list" role="list">
|
||||||
|
<li v-for="item in shoppingList.covered_items" :key="item.ingredient_name" class="covered-item">
|
||||||
|
<span class="check-icon" aria-hidden="true">✓</span>
|
||||||
|
<span class="item-name">{{ item.ingredient_name }}</span>
|
||||||
|
<span v-if="item.have_quantity" class="item-qty">{{ item.have_quantity }} {{ item.have_unit }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="!shoppingList.gap_items.length && !shoppingList.covered_items.length" class="empty-state">
|
||||||
|
No ingredients yet — add some recipes to your plan first.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<button class="load-btn" @click="$emit('load')">Generate shopping list</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMealPlanStore } from '../stores/mealPlan'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
|
defineEmits<{ (e: 'load'): void }>()
|
||||||
|
|
||||||
|
const store = useMealPlanStore()
|
||||||
|
const { shoppingList, shoppingListLoading: loading } = storeToRefs(store)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shopping-list-panel { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.panel-loading { font-size: 0.85rem; opacity: 0.6; padding: 1rem 0; }
|
||||||
|
|
||||||
|
.disclosure-banner {
|
||||||
|
font-size: 0.72rem; opacity: 0.55; padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--color-surface-2); border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading { font-size: 0.8rem; font-weight: 600; margin: 0 0 0.5rem; }
|
||||||
|
.covered-heading { opacity: 0.6; }
|
||||||
|
|
||||||
|
.item-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
|
||||||
|
.gap-item { display: flex; flex-direction: column; gap: 3px; padding: 6px 0; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.gap-item:last-child { border-bottom: none; }
|
||||||
|
.item-row { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
|
||||||
|
.item-check { width: 16px; height: 16px; flex-shrink: 0; }
|
||||||
|
.item-name { flex: 1; font-size: 0.85rem; }
|
||||||
|
.item-qty { font-size: 0.75rem; opacity: 0.7; }
|
||||||
|
.item-qty.gap { color: var(--color-warning, #e88); opacity: 1; }
|
||||||
|
|
||||||
|
.retailer-links { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 1.5rem; }
|
||||||
|
.retailer-link {
|
||||||
|
font-size: 0.68rem; padding: 2px 8px; border-radius: 20px;
|
||||||
|
background: var(--color-surface-2); color: var(--color-accent);
|
||||||
|
text-decoration: none; border: 1px solid var(--color-border);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.retailer-link:hover { background: var(--color-accent-subtle); }
|
||||||
|
.retailer-link:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.covered-item { display: flex; align-items: center; gap: 0.5rem; padding: 4px 0; opacity: 0.6; font-size: 0.82rem; }
|
||||||
|
.check-icon { color: var(--color-success); font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.empty-state { font-size: 0.85rem; opacity: 0.55; padding: 1rem 0; text-align: center; }
|
||||||
|
.load-btn {
|
||||||
|
font-size: 0.85rem; padding: 0.5rem 1.2rem;
|
||||||
|
background: var(--color-accent-subtle); color: var(--color-accent);
|
||||||
|
border: 1px solid var(--color-accent); border-radius: 20px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.load-btn:hover { background: var(--color-accent); color: white; }
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue