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)
|
||||
|
||||
|
||||
@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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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>
|
||||
</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)
|
||||
|
|
|
|||
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