Compare commits

...

10 commits

Author SHA1 Message Date
19c0664637 fix(review): address code review findings before merge
- update_prep_task: move whitelist guard above filter so invalid column
  check runs on raw kwargs (was dead code — set(filtered) - allowed is
  always empty); fixes latent SQL injection path for future callers
- main.py: move register_kiwi_programs() into lifespan context manager
  so it runs once at startup, not at module import time
- MealPlanView.vue: remove debug console.log stubs from onSlotClick and
  onAddMealType (follow-up issue handlers, not ready for production)
2026-04-12 14:16:24 -07:00
e52c406d0a docs(bsl): document cf-text/LLMRouter routing chain in llm_timing and llm_planner 2026-04-12 14:07:32 -07:00
4281b0ce19 feat(services/bsl): add llm_router — cf-text via cf-orch on cloud, LLMRouter (ollama/vllm) local fallback
refs kiwi#68
2026-04-12 14:07:13 -07:00
f54127a8cc fix(meal-planner): add GET prep-session endpoint, fix list_plans schema, replace assert with ValueError
- Add GET /{plan_id}/prep-session endpoint so frontend can retrieve existing sessions without creating
- Fix list_plans response_model from list[dict] to list[PlanSummary] with proper _plan_summary() mapping
- Replace assert in store.update_prep_task with ValueError (assert is stripped under python -O)
- Add day_of_week 0-6 validation to upsert_slot endpoint
- Remove MagicMock sqlite artifact files left by pytest (already in .gitignore)
2026-04-12 14:04:53 -07:00
062b5d16a1 feat(services/bsl): add llm_planner — LLM-assisted full-week meal plan generation (Paid/BYOK) 2026-04-12 13:58:04 -07:00
5f094eb37a feat(services/bsl): add llm_timing — estimate cook times via LLM for missing corpus data (Paid/BYOK) 2026-04-12 13:58:03 -07:00
2baa8c49a9 feat(frontend): add MealPlan tab with grid, shopping list, and prep schedule
closes kiwi#68, kiwi#71
2026-04-12 13:57:55 -07:00
faaa6fbf86 feat(frontend): add PrepSessionView with editable task durations 2026-04-12 13:57:48 -07:00
67b521559e feat(frontend): add ShoppingListPanel with pantry diff and affiliate links 2026-04-12 13:57:48 -07:00
a7fc441105 feat(frontend): add MealPlanGrid compact-expandable week grid component 2026-04-12 13:57:47 -07:00
11 changed files with 823 additions and 8 deletions

View file

@ -92,12 +92,17 @@ async def create_plan(
return _plan_summary(plan, slots)
@router.get("/", response_model=list[dict])
@router.get("/", response_model=list[PlanSummary])
async def list_plans(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> list[dict]:
return await asyncio.to_thread(store.list_meal_plans)
) -> list[PlanSummary]:
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)
@ -124,6 +129,8 @@ async def upsert_slot(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> 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:
raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.")
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
@ -197,6 +204,28 @@ async def get_shopping_list(
# ── 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)
async def create_prep_session(
plan_id: int,

View file

@ -1114,8 +1114,10 @@ class Store:
def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None:
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}
assert all(k in allowed for k in updates), f"Unexpected column(s): {set(updates) - allowed}"
invalid = set(kwargs) - allowed # check raw kwargs BEFORE filtering
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:
return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,))
set_clause = ", ".join(f"{k} = ?" for k in updates)

View file

@ -11,8 +11,6 @@ from app.api.routes import api_router
from app.core.config import settings
from app.services.meal_plan.affiliates import register_kiwi_programs
register_kiwi_programs()
logger = logging.getLogger(__name__)
@ -20,6 +18,7 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
logger.info("Starting Kiwi API...")
settings.ensure_dirs()
register_kiwi_programs()
# Start LLM background task scheduler
from app.tasks.scheduler import get_scheduler

View 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 []

View 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)

View 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

View file

@ -46,6 +46,18 @@
<span class="sidebar-label">Receipts</span>
</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')">
<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"/>
@ -79,6 +91,9 @@
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
<SettingsView />
</div>
<div v-show="currentTab === 'mealplan'" class="tab-content">
<MealPlanView />
</div>
</div>
</main>
</div>
@ -118,6 +133,17 @@
</svg>
<span class="nav-label">Settings</span>
</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>
<!-- 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 RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue'
import MealPlanView from './components/MealPlanView.vue'
import FeedbackButton from './components/FeedbackButton.vue'
import { useInventoryStore } from './stores/inventory'
import { useEasterEggs } from './composables/useEasterEggs'
import { householdAPI } from './services/api'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
const currentTab = ref<Tab>('inventory')
const sidebarCollapsed = ref(false)

View 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>

View 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>

View 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>

View 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>