Compare commits

..

No commits in common. "19c0664637a5d7e392ac53328f54e0a19fcc6805" and "543c64ea30b1346ed0c466131973e078a8d2afd2" have entirely different histories.

11 changed files with 8 additions and 823 deletions

View file

@ -92,17 +92,12 @@ async def create_plan(
return _plan_summary(plan, slots)
@router.get("/", response_model=list[PlanSummary])
@router.get("/", response_model=list[dict])
async def list_plans(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> 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
) -> list[dict]:
return await asyncio.to_thread(store.list_meal_plans)
@router.get("/{plan_id}", response_model=PlanSummary)
@ -129,8 +124,6 @@ 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)
@ -204,28 +197,6 @@ 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,10 +1114,8 @@ class Store:
def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None:
allowed = {"duration_minutes", "sequence_order", "notes", "equipment"}
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}
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}"
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,6 +11,8 @@ 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__)
@ -18,7 +20,6 @@ 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

@ -1,91 +0,0 @@
# 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

@ -1,96 +0,0 @@
# 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

@ -1,65 +0,0 @@
# 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,18 +46,6 @@
<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"/>
@ -91,9 +79,6 @@
<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>
@ -133,17 +118,6 @@
</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 -->
@ -189,13 +163,12 @@ 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' | 'mealplan'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
const currentTab = ref<Tab>('inventory')
const sidebarCollapsed = ref(false)

View file

@ -1,126 +0,0 @@
<!-- 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

@ -1,153 +0,0 @@
<!-- 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

@ -1,115 +0,0 @@
<!-- 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

@ -1,112 +0,0 @@
<!-- 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>